diff --git a/.github/scripts/prepare-sandbox.sh b/.github/scripts/prepare-sandbox.sh new file mode 100644 index 0000000..12889d7 --- /dev/null +++ b/.github/scripts/prepare-sandbox.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-3.0-or-later +# Configure an ephemeral Ubuntu CI runner, never a user's installation. +set -euo pipefail + +# Use Ubuntu's packaged policy, including the child profile that denies +# capabilities. Do not disable AppArmor or the global userns restriction. +# https://discourse.ubuntu.com/t/understanding-apparmor-user-namespace-restriction/58007 +sudo apt-get install -y --no-install-recommends apparmor-profiles +profile=/usr/share/apparmor/extra-profiles/bwrap-userns-restrict +test -f "$profile" +sudo apparmor_parser --replace "$profile" +sudo grep -qx 'bwrap (enforce)' /sys/kernel/security/apparmor/profiles +sudo grep -qx 'unpriv_bwrap (enforce)' /sys/kernel/security/apparmor/profiles + +# Run as the ordinary runner user. Fail before compiling if namespaces or +# child confinement are unavailable; the application self-test follows later. +/usr/bin/bwrap --unshare-all --die-with-parent --new-session --clearenv \ + --cap-drop ALL --ro-bind /usr /usr \ + --symlink usr/bin /bin --symlink usr/lib /lib --symlink usr/lib64 /lib64 \ + --proc /proc --dev /dev --remount-ro /dev \ + /bin/sh -eu -c ' + test ! -e /home + test ! -e /run + test ! -e /etc + # Ubuntu awk resolves through /etc/alternatives, which is deliberately + # absent. Shell builtins keep this probe independent of those links. + seen_cap=false + while read -r key value rest; do + if test "$key" = CapEff:; then + test "$value" = 0000000000000000 + seen_cap=true + fi + done < /proc/self/status + test "$seen_cap" = true + seen_loopback=false + while read -r interface rest; do + case "$interface" in + lo:) seen_loopback=true ;; + *:) exit 1 ;; + esac + done < /proc/net/dev + test "$seen_loopback" = true + ' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8950d7..ee22303 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: - name: install libdav1d-dev run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends libdav1d-dev pkg-config + sudo apt-get install -y --no-install-recommends libdav1d-dev pkg-config bubblewrap util-linux - uses: dtolnay/rust-toolchain@stable with: components: clippy @@ -50,52 +50,78 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - libgtk-4-dev libadwaita-1-dev libdav1d-dev pkg-config + libgtk-4-dev libadwaita-1-dev libdav1d-dev pkg-config bubblewrap util-linux - uses: dtolnay/rust-toolchain@stable with: components: clippy - uses: Swatinem/rust-cache@v2 - - run: cargo clippy -p bigiris --features "gui ai" --all-targets --locked -- -D warnings + - run: cargo clippy --workspace --all-features --all-targets --locked -- -D warnings test: name: test (headless) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - name: install libdav1d-dev run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends libdav1d-dev pkg-config + sudo apt-get install -y --no-install-recommends libdav1d-dev pkg-config bubblewrap util-linux + - name: prepare distro Bubblewrap policy and verify isolation + run: bash .github/scripts/prepare-sandbox.sh - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 + # Sandbox tests exercise the distro bubblewrap policy and fail closed. + # Do not disable AppArmor or add broad container capabilities to pass. - run: cargo test --workspace --all-targets --locked test-gui: name: test (gui + ai) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - name: install GTK4/libadwaita/dav1d dev run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - libgtk-4-dev libadwaita-1-dev libdav1d-dev pkg-config + libgtk-4-dev libadwaita-1-dev libdav1d-dev pkg-config bubblewrap util-linux xvfb dbus-x11 + - name: prepare distro Bubblewrap policy and verify isolation + run: bash .github/scripts/prepare-sandbox.sh - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 # Tests in `crates/bigiris/src/gui.rs` (the 20 anchor/zoom tests) # only compile with `--features gui`, so they need their own job. - - run: cargo test -p bigiris --features "gui ai" --all-targets --locked + # Sandbox tests exercise the distro bubblewrap policy and fail closed. + # Do not disable AppArmor or add broad container capabilities to pass. + - run: cargo test --workspace --all-features --all-targets --locked + + - name: viewer lifecycle and painted zoom frames (isolated display) + env: + GSETTINGS_BACKEND: memory + GDK_BACKEND: x11 + GSK_RENDERER: cairo + BIGIRIS_TEST_ZOOM_REPORT_DIR: ${{ github.workspace }}/target/quality/ci-zoom + run: | + xvfb-run -a dbus-run-session -- cargo test -p bigiris --features "gui ai" \ + --locked viewer_lifecycle_releases_state_and_workers -- \ + --ignored --test-threads=1 + - uses: actions/upload-artifact@v4 + if: always() + with: + name: painted-zoom-frames + path: target/quality/ci-zoom build: name: build (release, gui + ai) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - name: install GTK4/libadwaita/dav1d dev run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - libgtk-4-dev libadwaita-1-dev libdav1d-dev pkg-config + libgtk-4-dev libadwaita-1-dev libdav1d-dev pkg-config bubblewrap util-linux + - name: prepare distro Bubblewrap policy and verify isolation + run: bash .github/scripts/prepare-sandbox.sh - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 # `ai` usa load-dynamic do ort: compila sem libonnxruntime presente, @@ -144,6 +170,8 @@ jobs: # Usa PKGBUILD.local (build do checkout local, sem clone remoto). # O PKGBUILD principal usa `source=git+https://github.com/xathay/bigiris`, # que makepkg nao consegue clonar em repo privado sem credenciais. + # Docker blocks nested namespaces by default. Run functional sandbox + # tests in the host test/build jobs; this job checks build + packaging. - name: makepkg working-directory: pkgbuild run: | @@ -155,3 +183,75 @@ jobs: --skipinteg \ --nocheck \ --clean + + nautilus: + name: Nautilus extension contracts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -v + - run: glib-compile-schemas --strict --dry-run data + + security: + name: RustSec dependency audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo install cargo-audit --locked + - run: cargo audit --deny unsound + + stability: + name: Synthetic core stress and stability + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: install codec dev files + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends libdav1d-dev pkg-config bubblewrap util-linux + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo build --release --locked -p bigimage-core --example quality_probe + - run: ./target/release/examples/quality_probe stress 32 + - run: ./target/release/examples/quality_probe soak 30 + + memcheck: + name: Core memory errors and leaks (Valgrind) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: install codec and debugger files + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends libdav1d-dev pkg-config bubblewrap util-linux valgrind libc6-dbg + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo build --release --locked -p bigimage-core --example quality_probe + - name: instrument synthetic decode/resize/preview/encode cycles + run: | + valgrind --leak-check=full --show-leak-kinds=definite,indirect \ + --errors-for-leak-kinds=definite,indirect --error-exitcode=1 \ + ./target/release/examples/quality_probe soak 1 320x240 + + fuzz: + name: Codec and ICC fuzzing (ASan + LSan) + runs-on: ubuntu-latest + # The repository selects stable in rust-toolchain.toml. Installing + # nightly alone does not override that choice for the Python runner. + env: + RUSTUP_TOOLCHAIN: nightly + steps: + - uses: actions/checkout@v4 + - run: sudo apt-get update && sudo apt-get install -y libdav1d-dev pkg-config + - uses: dtolnay/rust-toolchain@nightly + - uses: Swatinem/rust-cache@v2 + - run: python3 scripts/fuzz.py --seconds 30 + - uses: actions/upload-artifact@v4 + if: always() + with: + name: fuzz-evidence + path: | + target/quality/fuzz + target/quality/fuzz-artifacts diff --git a/.gitignore b/.gitignore index 0d25542..2df6501 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,8 @@ Thumbs.db # Generated *.o *.a +__pycache__/ +*.pyc # Flatpak .flatpak-builder/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57508be..e882957 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,8 +20,7 @@ sudo apt-get install -y rustc cargo pkg-config libgtk-4-dev \ libadwaita-1-dev libdav1d-dev libssl-dev gettext ``` -Rust mínimo: **1.83** (definido em `rust-toolchain.toml`; `rustup` -respeita automaticamente). +Rust mínimo: **1.92** (declarado no `Cargo.toml`; `rust-toolchain.toml` seleciona stable). ## Compilando @@ -42,8 +41,8 @@ cargo build --release --features "gui ai" -p bigiris # Workspace inteiro (headless): cargo test --workspace --all-targets --locked -# GUI + IA (precisa das libs de sistema acima): -cargo test -p bigiris --features "gui ai" --all-targets --locked +# GUI + IA em todo o workspace (precisa das libs de sistema acima): +cargo test --workspace --all-features --all-targets --locked # Self-test do binário: ./target/release/bigiris --self-test @@ -54,12 +53,72 @@ cargo test -p bigiris --features "gui ai" --all-targets --locked ```bash cargo fmt --all cargo clippy --workspace --all-targets --locked -- -D warnings -cargo clippy -p bigiris --features "gui ai" --all-targets --locked -- -D warnings +cargo clippy --workspace --all-features --all-targets --locked -- -D warnings ``` Os 3 são gates obrigatórios no CI (`.github/workflows/ci.yml`). Warnings viram erro, então PRs com clippy sujo não passam. +## Campanha de qualidade no GNOME + +O runner preserva logs, comandos, códigos de saída e duração de cada verificação +em `target/quality/`. Falhas permanecem no histórico; uma nova execução gera +novas evidências. A matriz e os resultados medidos estão em +[docs/QUALITY.md](docs/QUALITY.md). + +```bash +# Contratos Rust/Python, Clippy e arquivos de integração: +python3 scripts/quality.py --gui + +# Na sessão GNOME, inclui abrir e destruir janelas com imagens sintéticas: +python3 scripts/quality.py --gui --graphical + +# Benchmark release, 64 conversões concorrentes e estabilidade por 5 minutos: +python3 scripts/quality.py --gui --graphical --performance --soak-seconds 300 + +# Instala o auditor apenas no diretório de build deste checkout: +cargo install --locked cargo-audit --root target/quality/tools +python3 scripts/quality.py --gui --audit + +# Depois de obter dependências e atualizar a base RustSec, repetição offline: +python3 scripts/quality.py --gui --graphical --audit --offline +``` + +O runner também exige `desktop-file-validate`, `appstreamcli`, +`glib-compile-schemas` e `msgfmt`. `--graphical` exige Wayland/X11 disponível; +no CI, usamos Xvfb e uma sessão D-Bus isolada. O backend GSettings é de memória +durante os testes gráficos, para preservar as preferências pessoais. O teste +recusa execução direta sem `GSETTINGS_BACKEND=memory`. Inclui preservação do +ponto do cursor no zoom por geometria real e pixels renderizados, além de +cancelamento na navegação e liberação de estado no fechamento. + +A suíte CLI com a feature `ai` testa recusa sob RLIMIT_AS de 4 GiB antes do +download, sem cache/saída e com original preservado. Inferência real exige +8 GiB de RAM disponível e modelo na cache isolada; use `scripts/ai_smoke.py` +e consulte os limites em `docs/QUALITY.md`. + +Capturas opcionais registram somente os widgets das janelas sintéticas: + +```bash +BIGIRIS_TEST_SCREENSHOTS_DIR="$PWD/target/quality/screenshots" \ +BIGIRIS_TEST_COLOR_SCHEME=light \ +python3 scripts/quality.py --gui --graphical +``` + +Repita com `BIGIRIS_TEST_COLOR_SCHEME=dark` para o tema escuro. Para Memcheck, +compile o probe e use símbolos de depuração compatíveis com a glibc instalada: + +```bash +cargo build -p bigimage-core --example quality_probe --locked +valgrind --tool=memcheck --leak-check=full \ + --errors-for-leak-kinds=definite,indirect --error-exitcode=99 \ + ./target/debug/examples/quality_probe soak 1 320x240 +``` + +O probe mede o núcleo com o allocator padrão; o aplicativo usa mimalloc. +Estabilidade de RSS e liberação das referências GTK são verificações diferentes +de Memcheck e não demonstram, isoladamente, ausência de vazamentos. + ## Estrutura ``` @@ -128,3 +187,31 @@ etc.) traduzem a partir do pt-BR. ## Reportando vulnerabilidades Não abra PR/issue pública. Veja [SECURITY.md](SECURITY.md). + +### Fuzzing e estabilidade prolongada + +`python3 scripts/fuzz.py --seconds 120` gera somente imagens sintéticas e +instrumenta o core com ASan/LSan e cobertura LLVM. Use Rust nightly. Em hosts +que fornecem Rust pelo sistema, `--allow-bootstrap` habilita explicitamente +as opções experimentais só nesse build de desenvolvimento; o release não +usa essas opções. O relatório fica em `target/quality/fuzz/summary.json`. +A campanha limita a entrada mutada a 16 KiB e o decode completo a um milhão +de pixels; cabeçalhos maiores continuam inspecionados. Testes de limites e +o sandbox cobrem rejeições fora desse orçamento. Bibliotecas nativas ligadas +externamente não são instrumentadas. Valgrind continua sendo um gate +independente no CI. + +`python3 scripts/release_smoke.py` verifica o sandbox e os 13 formatos no +release. `python3 scripts/ai_smoke.py` executa inferência ONNX real em duas +imagens sintéticas com transparência, usando somente o cache isolado em +`target/quality/ai/`. O primeiro download exige `--allow-download`; o teste +não toca no cache pessoal e registra tempo, RAM e preservação de RGB/alpha. + +Após `cargo build --release -p bigimage-core --example quality_probe --locked`, +`python3 scripts/soak.py --seconds 28800 --background` executa oito horas do +probe em processo persistente. Consulte `target/quality/long-soak/status.json`: +`running` significa pendente, nunca aprovação. O probe exercita o core; +soak de sessão GNOME, GPU, slideshow e acessibilidade exige validação própria. + +Para distribuir a candidata, confira `docs/RELEASE-CANDIDATE.md`. Instalação, +associação MIME padrão e publicação são ações diferentes da criação do pacote. diff --git a/Cargo.lock b/Cargo.lock index 544651b..4cdad29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -108,9 +108,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arbitrary" @@ -199,9 +199,9 @@ dependencies = [ [[package]] name = "avif-serialize" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" dependencies = [ "arrayvec", ] @@ -236,11 +236,13 @@ dependencies = [ "image", "ndarray", "ort", + "rustix", "sha2", "tempfile", "thiserror 2.0.18", "tracing", "ureq", + "url", ] [[package]] @@ -250,6 +252,8 @@ dependencies = [ "fast_image_resize", "image", "kamadak-exif", + "moxcms", + "rustix", "serde", "tempfile", "thiserror 2.0.18", @@ -280,7 +284,11 @@ dependencies = [ "image", "libadwaita", "mimalloc", + "rustix", + "serde", + "serde_json", "tempfile", + "toml", "tracing", "tracing-subscriber", ] @@ -397,9 +405,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.60" +version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -548,9 +556,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] @@ -747,23 +755,9 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fax" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] - -[[package]] -name = "fax_derive" -version = "0.2.0" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" [[package]] name = "fdeflate" @@ -997,9 +991,9 @@ dependencies = [ [[package]] name = "gettext-rs" -version = "0.7.7" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5857dc1b7f0fee86961de833f434e29494d72af102ce5355738c0664222bdf" +checksum = "9df5e3fa8c077b15fdfa8ce130e2bc73d663b8aaa6d51b71421b8659980b1d80" dependencies = [ "gettext-sys", "locale_config", @@ -1033,9 +1027,9 @@ checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "gio" -version = "0.22.5" +version = "0.22.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "401b600a9795c46ff45890146968b712c96ce4e9393798804133e137bd81d89c" +checksum = "e3848bcba3a35cc0a71df8ba8ecfd799d6bfb862342a53a4a915fb62213aa4e6" dependencies = [ "futures-channel", "futures-core", @@ -1063,9 +1057,9 @@ dependencies = [ [[package]] name = "glib" -version = "0.22.5" +version = "0.22.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1b7df55594e0e787d1560e23f7e12d7360d0b22e7b7c228ec2488b9e59b1b6b" +checksum = "c207e04e51605dcf7b2924c41591b3a10e1438eaac5bcf448fb91f325381104a" dependencies = [ "bitflags", "futures-channel", @@ -1084,9 +1078,9 @@ dependencies = [ [[package]] name = "glib-macros" -version = "0.22.2" +version = "0.22.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda575994e3689b1bc12f89c3df621ead46ff292623b76b4710a3a5b79be54bb" +checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19" dependencies = [ "heck", "proc-macro2", @@ -1096,9 +1090,9 @@ dependencies = [ [[package]] name = "glib-sys" -version = "0.22.3" +version = "0.22.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1eb23a616a3dbc7fc15bbd26f58756ff0b04c8a894df3f0680cd21011db6a642" +checksum = "5f7fbac234ed5bc2a28359b7bde8e1b9cdf1441cc2d7f068e4824672d7db9445" dependencies = [ "libc", "system-deps", @@ -1106,9 +1100,9 @@ dependencies = [ [[package]] name = "gobject-sys" -version = "0.22.0" +version = "0.22.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18eda93f09d3778f38255b231b17ef67195013a592c91624a4daf8bead875565" +checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" dependencies = [ "glib-sys", "libc", @@ -1171,9 +1165,9 @@ dependencies = [ [[package]] name = "gtk4" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25d47a7ca9ec6f50b5ace32eaaf11fe152c9bbc4f780a35e42c9b7fc5b046f9c" +checksum = "7181b837f04cbe93f79441475f7a00560a92cba7a72e38cc1a68b6f8b78eaae2" dependencies = [ "cairo-rs", "field-offset", @@ -1204,9 +1198,9 @@ dependencies = [ [[package]] name = "gtk4-sys" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a25bd07084651c77bb6e7bce7d4cea8d9f98d210acee473e400a9106bc0ce50" +checksum = "20ba8e695e2640455561274e65e45f0a151619e450746007667f4b23ceae4e1b" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", @@ -1363,9 +1357,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1408,9 +1402,9 @@ dependencies = [ [[package]] name = "imgref" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" +checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2" [[package]] name = "indenter" @@ -1532,9 +1526,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.185" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libfuzzer-sys" @@ -1761,9 +1755,9 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "no_std_io2" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b51ed7824b6e07d354605f4abb3d9d300350701299da96642ee084f5ce631550" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" dependencies = [ "memchr", ] @@ -1903,15 +1897,14 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openssl" -version = "0.10.77" +version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ "bitflags", "cfg-if", "foreign-types", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -1935,9 +1928,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.113" +version = "0.9.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" dependencies = [ "cc", "libc", @@ -1981,9 +1974,9 @@ checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "pango" -version = "0.22.4" +version = "0.22.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4804fb6018c6604eac198f0f897320d3696c9af7983cde056f07cef93cac9202" +checksum = "251bdc6e6487b811be0e406a21e301e07e45c0aa8fa39e00c0c8e12a91752438" dependencies = [ "gio", "glib", @@ -2109,18 +2102,18 @@ dependencies = [ [[package]] name = "profiling" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" dependencies = [ "profiling-procmacros", ] [[package]] name = "profiling-procmacros" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" dependencies = [ "quote", "syn", @@ -2890,9 +2883,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -2903,9 +2896,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2913,9 +2906,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -2926,9 +2919,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -3078,9 +3071,9 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "winnow" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index 57d3fec..36f94d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,15 +10,16 @@ members = [ [workspace.package] version = "0.1.0" edition = "2021" -rust-version = "1.83" +rust-version = "1.92" license = "GPL-3.0-or-later" -authors = ["BigLinux team "] +authors = ["Leonardo Athayde "] repository = "https://github.com/xathay/bigiris" homepage = "https://github.com/xathay/bigiris" [workspace.dependencies] clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } +toml = "1" thiserror = "2" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } @@ -32,7 +33,7 @@ ndarray = "0.16" gtk4 = { version = "0.11", features = ["v4_12"] } libadwaita = { version = "0.9", features = ["v1_5"] } directories = "5" -gettext-rs = { version = "0.7", features = ["gettext-system"] } +gettext-rs = { version = "0.8", features = ["gettext-system"] } image = { version = "0.25", default-features = false, features = [ # Tier 1: pure-Rust formats (default in all builds) "png", diff --git a/README.md b/README.md index 55bfdd5..a4ad059 100644 --- a/README.md +++ b/README.md @@ -5,332 +5,226 @@

BigIris

- Visualizador e conversor de imagens moderno para Linux.
- Rust + GTK4/libadwaita · IA local que nunca sai do seu computador · single-binary com CLI e GUI. + Um visualizador rápido e um estúdio de imagens sob demanda.
+ Rust · GTK4/libadwaita · CLI · processamento local · GNOME/Nautilus e outros desktops

---- +BigIris segue uma ideia simples: abrir uma foto deve ser imediato e silencioso; +trabalhar em muitas fotos deve oferecer uma interface completa. Por isso o +projeto tem duas experiências no mesmo binário: + +- **Íris** é o visualizador minimalista: abre no arquivo escolhido, descobre as + imagens irmãs da pasta, navega em ordem natural e mantém a foto no centro. +- **Prisma** aparece quando é necessário converter, redimensionar, recortar, + ajustar, comparar, animar ou processar uma pasta inteira. + +O fluxo compacto e os atalhos têm o IrfanView como referência funcional. A +interface usa GTK4/libadwaita, com prioridade para o BigCommunity GNOME +e integração também com outros desktops. O resultado respeita o tema, +usa ícones simbólicos do sistema e mantém as operações avançadas fora do caminho +principal. + +## Destaques + +- Decodificação da imagem e das miniaturas fora da thread gráfica. +- Navegação rápida por pasta com coalescência: resultados antigos são descartados + quando o usuário percorre várias imagens rapidamente. +- Zoom centrado no cursor, pan por arrasto, 1:1, ajustar à janela, tela cheia, + slideshow, fita de miniaturas e histograma RGB. +- Giro e espelhamento não destrutivos, com desfazer/refazer; `Ctrl+S` confirma e + grava por substituição atômica, preservando o original se o encoder falhar. +- Processamento em lote paralelo, cancelável, com progresso, tempo e tamanho + antes/depois. +- Integração com Dolphin, Nautilus, Nemo, Thunar, PCManFM-Qt e elementary Files. +- Ação de pasta no Nautilus e no Dolphin: **BigIris → Processar esta pasta…**. +- Remoção de fundo local com BiRefNet-lite, modelo verificado por SHA-256 e sem + upload das imagens. +- Limites preventivos de 1 GiB por arquivo, 256 MP e 512 MiB por buffer de imagem. + +## Zoom e memória da IA + +Posicione o cursor sobre o detalhe da imagem e use a roda do mouse, `+`/`-` +ou os botões de zoom. A transição é gradual, com movimentos proporcionais no +touchpad, e respeita a preferência de desativar animações do GTK. A ampliação +preserva esse ponto, respeitando os limites +de rolagem da imagem. `1` exibe 100%; `0` ajusta à janela. Sem posição conhecida +do cursor, o ponto de referência é o centro da área visível. + +A remoção de fundo é opcional. O modelo atual, BiRefNet-lite FP32, atingiu +aproximadamente 6,53 GiB de RAM no ensaio local. O aplicativo exige um orçamento +inicial de **8 GiB de RAM livre** antes de baixar/carregar esse modelo e volta +a conferir o orçamento antes da inferência. Considera a memória disponível do +Linux, limites cgroup v2 e de espaço virtual; swap não substitui essa margem. +Isso é uma proteção preventiva, não uma quota rígida de memória. + +Em uma máquina com 4 GB, a remoção de fundo por esse modelo é recusada com +explicação. O visualizador e as conversões continuam disponíveis, sujeitos ao +tamanho das imagens e aos limites do pipeline. Ainda é necessária homologação +em hardware físico de pouca RAM; não há promessa de suporte a qualquer acervo. + +## Formatos + +O build padrão completo lê e grava os formatos fornecidos pelo pipeline atual: +PNG, JPEG, WebP, AVIF, TIFF, BMP, GIF, ICO, PNM, TGA, QOI, Radiance HDR e +OpenEXR. HEIC/HEIF, JPEG XL, SVG e RAW de câmeras ainda não são anunciados como +suportados. + +As conversões estáticas reencodam os pixels e removem metadados pessoais. +Perfis ICC RGB são normalizados para sRGB; PNG/JPEG recebem um perfil sRGB +gerado sem dados da imagem original. Edição estática de animações e TIFF +multipágina é recusada para impedir perda de conteúdo. O viewer exibe o +primeiro quadro/página; reprodução animada e navegação multipágina permanecem pendentes. WebP é gravado +sem perdas; ICO é limitado a 256 × 256. Consulte as limitações em +[docs/QUALITY.md](docs/QUALITY.md) antes de converter um acervo. + +## Instalação no BigCommunity GNOME + +O runtime exige `bubblewrap` e `util-linux`, com namespaces não privilegiados +disponíveis. O aplicativo recusa decodificação se o isolamento não puder iniciar. + +Para compilar e empacotar o checkout local na base Arch: -Substitui o ServiceMenu *ReImage* (KDE) e atualiza o estado da arte de viewers como Loupe e Gwenview num único app, com dois módulos: - -- **Íris** — visualizar (zoom cursor-anchored, drag pan em qualquer botão, navegação, fullscreen, slideshow, film strip, histograma, modo teatro). -- **Prisma** — transformar (converter, redimensionar, girar, espelhar, cortar, ajustar cores, comparar, metadados, animar, lote, IA). - -## O que faz diferente - -### Performance - -**Batch paralelo no nível do IrfanView.** Cada diálogo Prisma fan-outa o trabalho num pool de worker threads (= `num_cpus − 1`, deixa um core pra GUI) coordenado por `AtomicUsize` sem Mutex no caminho quente. Numa CPU 16-thread, 100 AVIFs → JPEG terminam em segundos; janela arrastável, progresso ao vivo, cancelável mid-batch sem GTK marcar "Não respondendo". Implementação em [`crates/bigiris/src/gui/batch_runner.rs`](crates/bigiris/src/gui/batch_runner.rs). - -**Encoder rápido por padrão.** AVIF emitido com ravif `speed=8` (vs. default=4 do crate `image`) — ~3× mais rápido sem perda visual perceptível. PNG default usa compressão padrão; JPEG quality controlável pelo spinner. O toggle **"Otimizar tamanho"** no diálogo Avançado força speed=4 + PNG `Best` quando o usuário troca tempo por tamanho. - -**Allocator multi-thread.** `mimalloc` como `#[global_allocator]` — heap por thread sem lock contention nas alocações que o batch faz por arquivo (decode → RGBA buffer → encode). 5-15% extra de wall-clock em CPUs ≥ 8 cores. - -**Stats pós-batch.** Quando o lote termina, o status row mostra tempo total + bytes antes/depois + médias por arquivo: - -``` -Concluído em 8.2 s · 23 gravado(s), 0 ignorado(s), 0 falha(s) -8.5 MB → 1.2 MB (−86%) · média por arquivo 378 KB → 53 KB -``` - -**Memória constante em batches grandes.** Um arquivo decodificado por vez por worker; encoder fecha o anterior antes do próximo abrir. SIMD para resize via [`fast_image_resize`](https://docs.rs/fast_image_resize). Decode caps preventivos (1 GiB / 256 MP) refusam pixel bombs antes da descompressão. - -### Privacidade & segurança - -**IA local de verdade.** Remover fundo com **BiRefNet-lite** (MIT) roda 100% offline. A imagem nunca sai do disco — sem upload, sem conta, sem API, sem créditos. Modelo (~224 MB) baixado uma única vez do mirror oficial da comunidade ONNX, **verificado por SHA-256** contra o hash gravado no binário. Allowlist FOSS rígida (MIT/Apache-2.0/BSD/MPL-2.0/CC0); pesos com cláusula *non-commercial* recusados antes do download. Sessão ONNX cacheada entre arquivos do mesmo lote — modelo carrega 1× pra batch de N arquivos. Detalhes em [docs/IA-LOCAL.md](docs/IA-LOCAL.md). - -**EXIF/GPS strip-by-default.** `EncodeOptions::strip_metadata = true` é o contrato do encode. O pipeline já dropa EXIF/IPTC/XMP/GPS implicitamente (decode → `DynamicImage` → encode), e o flag explícito existe pra que um futuro toggle "preservar" nunca regrida silenciosamente. - -**Endurecido contra entrada hostil.** - -- **Decode caps** — arquivos > 1 GiB e imagens > 256 MP rejeitados antes de descomprimir. -- **Download cap** — mirror enviando > 16 MiB acima do esperado aborta com cleanup do `.part`. -- **`O_NOFOLLOW`** em todas as escritas dos service menus — `sudo install-integrations --system` não segue symlinks plantados. -- **Thunar UCA** empacotado em `sh -c '… "$@"' progname %F` — filenames jamais reinterpretados pelo shell. -- **TLS** via `native-tls` (OpenSSL do sistema), sem `ring` no caminho de build, sem `danger_*` desativando validação. -- **`#![forbid(unsafe_code)]`** em todos os crates. - -Política de reporte em [SECURITY.md](SECURITY.md). - -### UX & integração - -**Single-binary, módulos por feature flag.** Um executável carrega CLI, viewer GTK4 e diálogos Prisma. IA atrás do feature `ai`; integrações de file manager geradas pelo próprio binário (`bigiris install-integrations --system|--user`). - -**CLI espelhada da GUI.** `bigiris convert ...`, `bigiris resize ...`, `bigiris remove-bg ...`, `bigiris adjust ...`. CI/CD testa exatamente o que o usuário usa. - -**6 gerenciadores integrados.** Dolphin, Nautilus (extensão Python top-level), Nemo, Thunar (merge `uca.xml`), PCManFM-Qt, elementary Files. Clique direito em qualquer imagem: - -``` -BigIris ▸ Visualizar em BigIris ← top: 1-clique pra abrir - Converter ▸ PNG · JPG · WebP · AVIF · TIFF · Mais opções… - Redimensionar ▸ 25% · 50% · 200% · HD · 4K · Mais opções… - Girar ▸ 90° · 180° · 270° · Auto (EXIF) · Mais opções… - Espelhar ▸ Horizontal · Vertical · Mais opções… - Ajustar cores ▸ Brilho ± · Contraste + · P&B · Vivas · Mais opções… - Para web ▸ WhatsApp · Instagram · Facebook · Twitter · Telegram · Discord · PNG otim. - Metadados ▸ Ver · Remover tudo (re-encode limpo) - Utilidades ▸ Lote · GIF animado · Comparar - IA ▸ Remover fundo (BiRefNet) - PDF ▸ Converter (LibreOffice headless) ← em documentos +```bash +cd pkgbuild +makepkg -si -p PKGBUILD.local ``` -**Diálogos Prisma seguem GNOME HIG.** `PreferencesGroup` por seção, `ExpanderRow` "Avançado" para opções de nicho (JPEG progressivo, otimização do encoder), picker de pasta de saída opcional. Tela primária fica em **Formato → Qualidade → Destino**. +A integração com o Nautilus depende de `nautilus-python`. A campanha de testes +prioriza GNOME/Wayland; veja os resultados, limites e critérios para adoção +oficial em [docs/QUALITY.md](docs/QUALITY.md), e os comandos reproduzíveis em +[CONTRIBUTING.md](CONTRIBUTING.md#campanha-de-qualidade-no-gnome). -**13 formatos Tier-1 nativos** — PNG, JPG, WebP, **AVIF (default)**, TIFF, BMP, GIF, ICO, PNM, TGA, QOI, HDR, OpenEXR. HEIC, JPEG XL e RAW por trás de feature flags. - -**i18n com gettext.** Source pt-BR, catálogos `data/po/.po`. `data/po/en.po` cobre 90 msgids de UI; helper `data/po/regen-pot.sh` regenera o template via `xgettext` e propaga via `msgmerge`. - -## Status - -#### Funcionalidade - -| Bloco | Estado | -|---|---| -| CLI completo: `convert` · `resize` · `rotate` · `flip` · `crop` · `adjust` · `remove-bg` · `upscale` · `animate` · `to-pdf` · `install-integrations` | ✓ | -| Viewer GTK4: zoom cursor-anchored · drag pan · navegação · fullscreen · slideshow · film strip · histograma · modo teatro | ✓ | -| 12 diálogos Prisma: convert · resize · rotate · flip · crop · adjust · upscale · animate · batch · compare · metadata · remove-bg | ✓ | -| 13 formatos Tier-1 (PNG · JPG · WebP · AVIF · TIFF · BMP · GIF · ICO · PNM · TGA · QOI · HDR · EXR) | ✓ | - -#### Performance - -| Bloco | Estado | -|---|---| -| Worker pool paralelo (cores − 1) em todos os batches Prisma + Animate; UI nunca freezea | ✓ | -| `mimalloc` global allocator + AVIF speed=8 default (clamp pra 4 quando "Otimizar") | ✓ | -| Stats pós-batch: tempo · bytes in/out · % delta · média por arquivo | ✓ | -| Sessão ORT cacheada entre arquivos do mesmo lote; intra-threads capadas | ✓ | -| SIMD para resize via `fast_image_resize`; decode caps (1 GiB / 256 MP) | ✓ | - -#### Segurança - -| Bloco | Estado | -|---|---| -| `#![forbid(unsafe_code)]` em todos os crates · SPDX em todo source | ✓ | -| Decode caps · download cap (16 MiB margem) · `O_NOFOLLOW` no install · Thunar shell quoting | ✓ | -| EXIF/GPS strip-by-default · TLS via `native-tls` (sem ring) | ✓ | -| FOSS license allowlist + SHA-256 nos modelos IA | ✓ | -| `SECURITY.md` · `CONTRIBUTING.md` · GitHub Security Advisories habilitado | ✓ | - -#### Empacotamento - -| Bloco | Estado | -|---|---| -| 6 gerenciadores de arquivos integrados · Nautilus detecta system-wide e evita menu duplicado | ✓ | -| AppStream metainfo · `.desktop` validado · ícone hicolor SVG · `` + `` | ✓ | -| i18n: 90 msgids em pt-BR (source) + `en.po` completo | ✓ | -| PKGBUILD principal (`xathay/bigiris`) + `.local` (build do checkout) | ✓ | -| CI: 9 jobs (fmt · 2× clippy · 2× test · build release · AppStream · desktop · makepkg) · 141 testes | ✓ | +## Instalação no OpenMandriva -#### Roadmap (M2) - -| Bloco | Estado | -|---|---| -| Glycin sandboxed decode · MDI · EXIF sidebar · preview ao vivo | em planejamento | - -## Remover fundo com IA — local, privado, sem conta +Para instalar o checkout apenas no seu usuário: ```bash -bigiris remove-bg foto.jpg # CLI → foto_nobg.png (RGBA) -bigiris --dialog=remove-bg foto.jpg # diálogo GUI com progress + compare -bigiris remove-bg *.jpg # lote (sessão ORT cacheada entre arquivos) +sudo dnf install rust cargo lib64gtk4.0-devel lib64adwaita-devel \ + onnxruntime-devel gettext bubblewrap util-linux +./install.sh ``` -Na primeira execução, o modelo (~224 MB) é baixado uma única vez do [mirror oficial da comunidade ONNX](https://huggingface.co/onnx-community/BiRefNet_lite-ONNX) para `~/.local/share/iris/models/` e **verificado por SHA-256** contra o hash fixado no binário. Qualquer divergência aborta — um mirror comprometido não consegue injetar pesos alterados. Tamanho > expected + 16 MiB também aborta, pra não encher o disco. Chamadas seguintes usam o cache local. +O instalador compila a versão release, coloca o aplicativo em `~/.local`, +registra somente os gerenciadores de arquivos detectados e atualiza o cache do +Plasma (`kbuildsycoca6`). Se o menu não aparecer em uma janela antiga do Dolphin, +feche essa janela e abra outra. -Documentação completa: [docs/IA-LOCAL.md](docs/IA-LOCAL.md). +Para o ABF/Cooker, use a receita e as instruções em +[`packaging/openmandriva/`](packaging/openmandriva/README.md). Ela usa um +tarball vendorizado para que o build seja offline e reproduzível. -## Como instalar +## Outras instalações -### Opção A — `makepkg` (Arch / BigLinux / Manjaro) - -A partir do diretório `pkgbuild/`: +No Arch, BigLinux ou Manjaro: ```bash cd pkgbuild -makepkg -si # builda do remoto (git+https://github.com/xathay/bigiris) -makepkg -si -p PKGBUILD.local # ou builda do checkout local, pula o clone +makepkg -si ``` -O hook `.install` informa que as integrações **já estão ativas system-wide** após a instalação — clique direito em qualquer imagem e o submenu "BigIris ▸" aparece nos seis gerenciadores. - -Para desinstalar: `sudo pacman -R bigiris`. - -### Opção B — `cargo install` (qualquer distro) +Em outras distribuições, após instalar Rust, GTK4, libadwaita, dav1d, OpenSSL, +gettext e ONNX Runtime: ```bash -# Dependências de sistema (Manjaro/BigLinux): -sudo pacman -S --needed gtk4 libadwaita dav1d openssl gettext hicolor-icon-theme rust pkgconf - -# Da raiz do repo: -cargo install --path crates/bigiris --features "gui ai" --locked - -# Garante PATH: -export PATH="$HOME/.cargo/bin:$PATH" - -# Instala integrações de clique direito no seu usuário: -bigiris install-integrations --user +cargo build --locked --release --features "gui ai" -p bigiris +./install.sh --no-build ``` -Para desinstalar: `bigiris uninstall-integrations && cargo uninstall bigiris` (rode o `uninstall-integrations` **antes** do `cargo uninstall`, senão o binário some). - -### Opção C — `cargo run` (dev rápido, sem instalar) +Para remover as integrações pessoais antes de apagar o binário: ```bash -cargo build --release --locked --features "gui ai" -p bigiris -./target/release/bigiris foto.jpg # viewer -./target/release/bigiris --dialog=convert foto.jpg # diálogo Prisma +bigiris uninstall-integrations ``` -## Roteiro de teste em 5 minutos +## Uso ```bash -# (a) Self-test — valida o binário -bigiris --self-test - -# (b) CLI puro -bigiris convert --to avif minha-foto.jpg -bigiris resize --max-edge 1080 minha-foto.jpg -bigiris rotate --degrees 90 minha-foto.jpg -bigiris flip --axis horizontal minha-foto.jpg -bigiris crop --rect 800x600+100+50 minha-foto.jpg -bigiris adjust --brightness 15 --contrast 10 minha-foto.jpg - -# (c) Viewer -bigiris foto.jpg *.png -# Teclado: +/- (zoom), 0 (ajustar), 1 (1:1), ←/→ (prev/next), -# Home/End, Space/Backspace, F11 (fullscreen), Esc -# Mouse: scroll = zoom centrado no cursor -# drag (qualquer botão) = pan quando zoomed in - -# (d) Diálogos modais Prisma -bigiris --dialog=convert foto.jpg -bigiris --dialog=resize foto.jpg - -# (e) Integração com file manager -# Clique direito numa imagem → submenu BigIris ▸ - -# (f) Batch IrfanView-style — selecione 100 fotos no file manager -# → BigIris ▸ Converter ▸ JPG → "Convertendo 12/100 — foto.jpg" -# → janela arrastável, cancelável, sem freeze -``` +# Visualizador / pasta +bigiris foto.jpg +bigiris *.png +bigiris -## CLI completa +# Prisma +bigiris --dialog=batch pasta-com-fotos +bigiris --dialog=convert *.jpg +bigiris --dialog=resize *.png +bigiris --dialog=crop foto.jpg +bigiris --dialog=adjust *.jpg +bigiris --dialog=compare antes.png depois.png +bigiris --dialog=remove-bg foto.jpg -```bash -# Transformações headless (todas com cap de 1 GiB / 256 MP no decode) -bigiris convert --to png foto.jpg -bigiris convert --to avif --overwrite replace *.jpg -bigiris resize --max-edge 1080 foto.png -bigiris resize --percent 50 --filter lanczos3 foto.png -bigiris resize --exact 800x600 --to webp foto.png -bigiris resize --fit 3840x3840 foto.png # upscale+downscale preservando aspecto +# CLI +bigiris convert --to avif --overwrite increment *.jpg +bigiris resize --max-edge 1080 *.png bigiris rotate --degrees 90 foto.jpg -bigiris rotate --auto --overwrite increment foto.jpg # EXIF Orientation bigiris flip --axis horizontal foto.jpg bigiris crop --rect 800x600+100+50 foto.jpg bigiris adjust --brightness 15 --contrast 10 foto.jpg -bigiris adjust --saturation -100 foto.jpg # preto e branco -bigiris adjust --gamma 0.7 foto.jpg # clareia midtones - -# IA local (BiRefNet-lite MIT, offline, sessão cacheada entre arquivos) -bigiris remove-bg foto.jpg # → foto_nobg.png (RGBA) -bigiris remove-bg *.jpg # lote - -# Upscale (Lanczos3 CPU hoje; Real-ESRGAN planejado) -bigiris upscale --factor 2 foto.jpg # 2x, 3x ou 4x - -# GIF animado bigiris animate -o saida.gif --delay 100 frame_*.png +bigiris remove-bg foto.jpg +bigiris to-pdf documento.docx +bigiris batch --profile docs/batch-profile.example.toml pasta-com-fotos -# Documentos → PDF (via LibreOffice headless) -bigiris to-pdf documento.docx planilha.xlsx apresentacao.pptx - -# Diálogos modais Prisma (usados pelo "Mais opções…" dos menus) -bigiris --dialog=convert *.jpg -bigiris --dialog=resize *.png -bigiris --dialog=rotate foto.jpg -bigiris --dialog=flip foto.jpg -bigiris --dialog=adjust foto.jpg -bigiris --dialog=remove-bg foto.jpg -bigiris --dialog=batch *.jpg -bigiris --dialog=animate frame_*.png - -# Viewer -bigiris foto.jpg # janela com imagem -bigiris # janela vazia -bigiris *.png # galeria (← → para navegar) - -# Integrações +# Integrações e diagnóstico bigiris install-integrations --user -bigiris install-integrations --system --destdir=/tmp/stage # para packaging bigiris uninstall-integrations - -# Debug bigiris --self-test -bigiris --version ``` +### Atalhos do visualizador + +| Atalho | Ação | +|---|---| +| `←` / `→`, `Home` / `End` | Navegar pela pasta | +| roda do mouse, `+` / `-` | Zoom | +| `0` / `1` | Ajustar / tamanho real | +| arrastar | Pan | +| `R` / `Shift+R` | Girar à direita / esquerda | +| `Ctrl+Z` / `Ctrl+Shift+Z` | Desfazer / refazer | +| `Ctrl+S` | Salvar edições com segurança | +| `Space` | Slideshow | +| `F` / `G` / `T` | Miniaturas / histograma / modo teatro | +| `H` | Fixar controles visíveis | +| `F11` / `Esc` | Tela cheia / fechar | + ## Arquitetura -``` +```text bigiris/ ├── crates/ -│ ├── bigimage-core/ # Decode/encode/transforms — pure Rust, headless -│ │ └── pipeline.rs # decode caps (1 GiB / 256 MP), strip-metadata default -│ ├── bigimage-ai/ # ORT + modelos (feature `onnx`) -│ │ ├── background.rs # BgSession cacheada + vectorized pre/post-process -│ │ └── download.rs # SHA-256 + FOSS allowlist + size cap (16 MiB margin) -│ ├── bigimage-integrations/ # Service menus para 6 file managers -│ │ ├── safe_fs.rs # O_NOFOLLOW write helper (anti-symlink-follow) -│ │ └── thunar.rs # UCA com sh -c '... "$@"' (anti-shell-injection) -│ └── bigiris/ # Binário único (CLI + GUI via feature `gui`) -│ └── src/gui/ -│ └── batch_runner.rs # run_batch_async + AsyncBatchEvent (worker threads) -├── data/ -│ ├── com.biglinux.Iris.{desktop,metainfo.xml,gschema.xml} -│ ├── icons/hicolor/scalable/apps/com.biglinux.Iris.svg -│ ├── nautilus/bigiris-menu.py # nautilus-python extension -│ └── po/ # i18n: bigiris.pot, en.po, LINGUAS, regen-pot.sh -├── pkgbuild/ -│ ├── PKGBUILD # build do git remoto -│ ├── PKGBUILD.local # build do checkout (usado pela CI) -│ └── bigiris.install # _refresh_caches: gtk-update-icon-cache, kbuildsycoca, etc. -└── docs/ - ├── ADR-00{1,2,3}-*.md # decisões arquiteturais - ├── IA-LOCAL.md # detalhes da IA local - └── screenshots/ # PNGs referenciados pelo AppStream metainfo +│ ├── bigimage-core/ pipeline headless e transformações +│ ├── bigimage-ai/ modelos locais e download verificado +│ ├── bigimage-integrations/ ServiceMenus e ações de gerenciadores +│ └── bigiris/ CLI, viewer Íris e diálogos Prisma +├── data/ desktop, AppStream, GSettings, ícone e i18n +├── packaging/openmandriva/ spec ABF e criação do vendor tarball +└── pkgbuild/ pacotes Arch/BigLinux ``` -ADRs explicam *por que* a estrutura está assim — `ADR-001` separa core de GUI/CLI, `ADR-002` define glycin para GUI + pure-rust para CLI, `ADR-003` justifica single-binary multi-module. +Os lotes compartilham um único pool em +[`batch_runner.rs`](crates/bigiris/src/gui/batch_runner.rs). A alocação global +usa mimalloc, o resize usa SIMD por `fast_image_resize`, e o pool limita a concorrência +conforme CPU, dimensões das imagens e memória disponível (até quatro jobs). -## Desenvolvimento +## Desenvolvimento e qualidade -Requer Rust stable ≥ 1.83 e libs de sistema (`gtk4`, `libadwaita`, `dav1d`, `openssl`, `pkgconf`, `gettext`). +Rust stable 1.92 ou posterior: ```bash -# Build sem GUI (CI headless, compila rápido) -cargo build - -# Build com viewer GTK4 + IA -cargo build --features "gui ai" -p bigiris - -# Qualidade — gates obrigatórios no CI -cargo fmt --all -cargo clippy --workspace --all-targets -- -D warnings -cargo clippy -p bigiris --features "gui ai" --all-targets -- -D warnings -cargo test --workspace -cargo test -p bigiris --features "gui ai" - -# i18n: regen .pot + msgmerge nos catálogos depois de adicionar gettext("…") -data/po/regen-pot.sh +cargo fmt --all --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-features --locked +cargo build --release --features "gui ai" -p bigiris +./target/release/bigiris --self-test + +desktop-file-validate data/com.biglinux.Iris.desktop +appstreamcli validate --no-net data/com.biglinux.Iris.metainfo.xml +glib-compile-schemas --strict --dry-run data ``` -CI executa em GitHub Actions: `fmt`, `clippy (headless)`, `clippy (gui+ai)`, `test (headless)`, `test (gui+ai)`, `build (release, gui+ai)` com `--self-test`, `appstreamcli validate`, `desktop-file-validate`, `makepkg` em container Arch. Veja [`.github/workflows/ci.yml`](.github/workflows/ci.yml). - -Para contribuir: leia [CONTRIBUTING.md](CONTRIBUTING.md). Para reportar vulnerabilidades: **não abra issue pública**, veja [SECURITY.md](SECURITY.md). +Relatos e patches são bem-vindos: leia [CONTRIBUTING.md](CONTRIBUTING.md). +Vulnerabilidades devem seguir [SECURITY.md](SECURITY.md), sem issue pública. ## Licença -GPL-3.0-or-later. Ver [LICENSE](LICENSE). Cada arquivo `.rs` / `.py` começa com `// SPDX-License-Identifier: GPL-3.0-or-later` para auditoria distro (Debian, Fedora). - -Parte da família **BigLinux**. Mantido por Leonardo Athayde. +GPL-3.0-or-later. Consulte [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md index 7cdcbd9..8b7e00c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -21,7 +21,7 @@ dos canais privados: Inclua, na medida do possível: - Versão do BigIris afetada (`bigiris --version`). -- Distro + ambiente gráfico (BigLinux/Manjaro/Arch/etc., GNOME/KDE/...). +- Distro + ambiente gráfico (OpenMandriva/BigLinux/Arch/etc., KDE/GNOME/...). - Reprodução mínima — se envolver arquivo malicioso, descreva sem anexar o exploit nesta mensagem; combine um canal seguro depois. - Impacto observado (ex.: leitura de arquivos fora do escopo, execução @@ -47,7 +47,7 @@ São considerados em escopo: - PKGBUILDs do projeto (`pkgbuild/`). Fora de escopo (encaminhe upstream): vulnerabilidades em `image`, -`fast_image_resize`, `dav1d`, `gtk4`, `libadwaita`, `ort`, `glycin`, +`fast_image_resize`, `dav1d`, `gtk4`, `libadwaita`, `ort`, ONNX Runtime ou no kernel. ## Hardening já aplicado @@ -55,8 +55,15 @@ ONNX Runtime ou no kernel. Para contexto sobre o que já é defesa em profundidade: - `#![forbid(unsafe_code)]` em todos os crates. -- Decode com cap de bytes (1 GiB) e de pixels (256 MP) antes do - decompress, refusando decode bombs e pixel bombs. +- Entradas regulares limitadas a 1 GiB, 256 MP e 512 MiB por buffer + decodificado, tanto no viewer como nas conversões. Os limites de scratch + dos codecs são best-effort. O executável confina a decodificação em processos + Bubblewrap sem rede e sem acesso a home/run/etc: `/usr` e o executável são + somente leitura, e apenas o descritor da imagem fornecida é montado. + `/dev` é remontado somente leitura e o tmpfs de `/tmp` é limitado a 64 MiB. + `prlimit` impõe 2 GiB de espaço virtual, 30 s de CPU, 60 s de prazo na + supervisão, 64 descritores e limite por arquivo de saída de 512 MiB + cabeçalho. + Não se trata de quota total de RSS/cgroup nem de filtro seccomp. - Service menus escritos com `O_NOFOLLOW` para impedir symlink-follow durante `sudo install-integrations --system`. - Modelos IA pinados por SHA-256 + allowlist FOSS de licenças @@ -64,5 +71,32 @@ Para contexto sobre o que já é defesa em profundidade: tamanho esperado por mais de 16 MiB. - Comandos do Thunar UCA empacotados em `sh -c '… "$@"' …` para que filenames não sejam reinterpretados pelo shell. -- TLS via `native-tls` (OpenSSL do sistema), sem `danger_*` desativando +- TLS configurado explicitamente via `native-tls` (OpenSSL do sistema), sem `danger_*` desativando validação de certificado. + +### Limitações a considerar antes de distribuição oficial + +Decodificação, inspeção de formatos e EXIF do executável passam pelo worker +confinado. Consumidores autônomos de `bigimage-core` usam execução local por +padrão. Encoders, GTK, TLS e inferência ONNX continuam no processo do aplicativo. +O isolamento não substitui revisão das bibliotecas nativas ou do kernel. O relatório de qualidade registra os testes realizados e +as lacunas; uma execução curta sem crescimento de RSS não prova ausência de leaks. + +Conversões estáticas removem EXIF/XMP pessoal; perfis ICC RGB são convertidos +para sRGB, e perfis inválidos ou não RGB são recusados explicitamente. +Animações e TIFF multipágina são recusados na edição estática. Não há suporte +de edição que preserve seus quadros ou páginas, nem gestão completa de HDR. Gravações novas usam arquivos privados; substituições +preservam as permissões do destino regular quando disponíveis. A publicação +atômica protege contra falha do encoder, mas não promete recuperação após queda +de energia em todos os sistemas de arquivos. + +### Admissão de memória para IA + +BiRefNet FP32 exige orçamento inicial de 8 GiB de RAM livre. A conferência +ocorre antes do download/carga e antes da inferência; considera MemAvailable, +limites dos ancestrais de cgroup v2 e RLIMIT_AS. Não conta swap. Sessões +reutilizadas creditam somente crescimento residente observado no código nativo, +com teto; a área de trabalho da imagem é acrescentada ao orçamento. A política +não é quota de RSS, não cobre cgroup v1 e não elimina competição por memória +após a conferência. ONNX permanece no processo principal. Um modelo menor +precisa de medições próprias antes de ser oferecido para máquinas de 4 GB. diff --git a/crates/bigimage-ai/Cargo.toml b/crates/bigimage-ai/Cargo.toml index 3530330..ae14644 100644 --- a/crates/bigimage-ai/Cargo.toml +++ b/crates/bigimage-ai/Cargo.toml @@ -20,15 +20,19 @@ ureq.workspace = true sha2.workspace = true directories.workspace = true image.workspace = true +tempfile = "3" +url = "2" +rustix = { version = "1", features = ["process"] } ort = { workspace = true, optional = true } ndarray = { workspace = true, optional = true } -[dev-dependencies] -tempfile = "3" - [features] default = [] # Enable ONNX Runtime-based inference (remove-bg, upscale, denoise). # Requires `libonnxruntime` at runtime (Arch: `pacman -S onnxruntime`). onnx = ["dep:ort", "dep:ndarray"] + +[[example]] +name = "onnx_memory" +required-features = ["onnx"] diff --git a/crates/bigimage-ai/examples/onnx_memory.rs b/crates/bigimage-ai/examples/onnx_memory.rs new file mode 100644 index 0000000..1c4710c --- /dev/null +++ b/crates/bigimage-ai/examples/onnx_memory.rs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Compare CPU allocator settings on the already verified local model. +use ort::{execution_providers::CPUExecutionProvider, session::Session, value::Tensor}; +use std::time::Instant; + +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args().collect(); + if args.len() != 3 || !["standard", "bounded"].contains(&args[1].as_str()) { + return Err("usage: onnx_memory standard|bounded verified-model.onnx".into()); + } + let bounded = args[1] == "bounded"; + let start = Instant::now(); + let builder = Session::builder()?.with_intra_threads(4)?; + let builder = if bounded { + builder + .with_execution_providers([CPUExecutionProvider::default() + .with_arena_allocator(false) + .build()])? + .with_memory_pattern(false)? + } else { + builder + }; + let mut session = builder.commit_from_file(&args[2])?; + for _ in 0..2 { + let input = Tensor::from_array(ndarray::Array4::::zeros((1, 3, 1024, 1024)))?; + let outputs = session.run(ort::inputs![input])?; + let (_, output) = outputs.iter().next().ok_or("missing output")?; + let (shape, data) = output.try_extract_tensor::()?; + if **shape != [1, 1, 1024, 1024] || data.len() != 1024 * 1024 { + return Err("unexpected output shape".into()); + } + } + let status = std::fs::read_to_string("/proc/self/status")?; + let value = |key: &str| { + status + .lines() + .find_map(|line| line.strip_prefix(key)?.split_whitespace().next()?.parse::().ok()) + .unwrap_or(0) + }; + println!( + "{{\"mode\":\"{}\",\"seconds\":{:.3},\"hwm_kib\":{},\"rss_kib\":{}}}", + args[1], + start.elapsed().as_secs_f64(), + value("VmHWM:"), + value("VmRSS:") + ); + Ok(()) +} diff --git a/crates/bigimage-ai/src/background.rs b/crates/bigimage-ai/src/background.rs index 7a9ab18..ff4ba2d 100644 --- a/crates/bigimage-ai/src/background.rs +++ b/crates/bigimage-ai/src/background.rs @@ -25,16 +25,19 @@ use thiserror::Error; use crate::download; use crate::download::{DownloadError, ModelSource}; +#[cfg(any(feature = "onnx", test))] +mod memory; + /// BiRefNet-lite ONNX — mirror oficial da comunidade ONNX, exportado a /// partir do repositório canônico [`ZhengPeng7/BiRefNet_lite`] (MIT) -/// pela equipe Xenova / onnx-community. Revisão fixa via `resolve/main`; +/// pela equipe Xenova / onnx-community. Revisão fixada por commit do repositório; /// o hash abaixo cobre o `.onnx` FP32 (~214 MB) tal como publicado em /// 2024-09. Se o mirror re-envelopar os pesos, a verificação em /// [`super::download::ensure`] rejeita o arquivo antes de gravá-lo na /// cache do usuário. pub const BIREFNET_LITE: ModelSource = ModelSource { id: "birefnet-lite", - url: "https://huggingface.co/onnx-community/BiRefNet_lite-ONNX/resolve/main/onnx/model.onnx", + url: "https://huggingface.co/onnx-community/BiRefNet_lite-ONNX/resolve/de15b22ba131738a16dff04aab8bdf8dc32e3ac1/onnx/model.onnx", license_spdx: "MIT", sha256: "5600024376f572a557870a5eb0afb1e5961636bef4e1e22132025467d0f03333", size_bytes: 224_005_088, @@ -89,6 +92,22 @@ pub enum BgError { /// Preprocessing / postprocessing error. #[error("processamento: {0}")] Processing(String), + /// Insufficient or unknown memory budget; model loading is refused. + #[error("memória para IA: {0}")] + Memory(String), +} + +/// Check the conservative RAM/cgroup/address-space budget before downloading. +/// This is an admission check, not a hard quota or a guarantee against OOM. +pub fn check_resources() -> Result<(), BgError> { + #[cfg(feature = "onnx")] + { + memory::MemoryGuard::new().map(|_| ()) + } + #[cfg(not(feature = "onnx"))] + { + Err(BgError::OnnxDisabled) + } } /// One-shot wrapper: ensure the model is on disk + load a session + @@ -115,8 +134,8 @@ pub fn remove_background_with_progress( /// /// Constructing a [`BgSession`] downloads (if needed) and loads the /// BiRefNet weights into an `ort` session. Each call to [`process`] -/// reuses that session — running inference takes a few hundred ms on a -/// modern CPU, while loading the session takes seconds. Batch flows +/// reuses that session. Inference time and RAM vary with the runtime and +/// hardware; consult the measured release report. Batch flows /// (dialog with N files, CLI passing many paths) should construct one /// session and call `process` per file. /// @@ -126,6 +145,8 @@ pub struct BgSession { /// don't pay the dep cost. #[cfg(feature = "onnx")] session: ort::session::Session, + #[cfg(feature = "onnx")] + memory: memory::MemoryGuard, } impl BgSession { @@ -142,13 +163,16 @@ impl BgSession { #[cfg(feature = "onnx")] { + let mut memory = memory::MemoryGuard::new()?; let model_path = download::ensure(&BIREFNET_LITE, |done, total| { progress(BgStage::Download { done, total }); })?; progress(BgStage::Infer); - + memory.check(0)?; + let before = memory::resident_bytes()?; let session = build_session(&model_path)?; - Ok(Self { session }) + memory.account_native_growth(before)?; + Ok(Self { session, memory }) } } @@ -167,6 +191,14 @@ impl BgSession { use image::imageops::FilterType; let (orig_w, orig_h) = input.dimensions(); + if u64::from(orig_w) * u64::from(orig_h) * 4 > 512 * 1024 * 1024 { + return Err(BgError::Processing( + "imagem excede o limite de saída RGBA de 512 MiB".into(), + )); + } + // RGBA source/output plus the full-size mask, beyond the model's + // fixed tensor budget. Memory is rechecked for every input. + self.memory.check(u64::from(orig_w) * u64::from(orig_h) * 9)?; let resized = input .resize_exact(INPUT_SIDE as u32, INPUT_SIDE as u32, FilterType::Lanczos3) .to_rgb8(); @@ -193,13 +225,15 @@ impl BgSession { } } + let before = memory::resident_bytes()?; let mask = run_session(&mut self.session, tensor)?; + self.memory.account_native_growth(before)?; // Mask back to 1024² u8, then resize to original dims. let mask_data: &[f32] = mask.as_slice().expect("CHW mask must be contiguous"); let mut mask_u8: Vec = Vec::with_capacity(INPUT_PIXELS); for &v in &mask_data[..INPUT_PIXELS] { - mask_u8.push((v.clamp(0.0, 1.0) * 255.0) as u8); + mask_u8.push(logit_alpha(v)); } let mask_img = image::ImageBuffer::, Vec>::from_vec( INPUT_SIDE as u32, @@ -225,7 +259,8 @@ impl BgSession { out_buf.push(src[i * 4]); out_buf.push(src[i * 4 + 1]); out_buf.push(src[i * 4 + 2]); - out_buf.push(mask_raw[i]); + out_buf + .push(((u16::from(src[i * 4 + 3]) * u16::from(mask_raw[i]) + 127) / 255) as u8); } let out = image::ImageBuffer::, Vec>::from_vec(orig_w, orig_h, out_buf) @@ -235,6 +270,17 @@ impl BgSession { } } +#[cfg(any(feature = "onnx", test))] +fn logit_alpha(value: f32) -> u8 { + let probability = if value >= 0.0 { + 1.0 / (1.0 + (-value).exp()) + } else { + let e = value.exp(); + e / (1.0 + e) + }; + (probability * 255.0).round() as u8 +} + /// Build an ORT session for `path`, capping intra-op threads so the /// inference doesn't starve the GUI / file-manager processes of CPU. /// Default ORT behavior is "use every core for one op" — fine for @@ -242,11 +288,20 @@ impl BgSession { #[cfg(feature = "onnx")] fn build_session(path: &std::path::Path) -> Result { let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(2); - let intra = cores.saturating_sub(1).max(1); - ort::session::Session::builder() - .and_then(|b| b.with_intra_threads(intra)) - .and_then(|b| b.commit_from_file(path)) - .map_err(|e| BgError::Inference(format!("load session: {e}"))) + let intra = cores.saturating_sub(1).clamp(1, 4); + // ort's dynamic loader panics on a missing/incompatible optional library. + // Translate that into a completed GUI/CLI error instead of a dead worker. + std::panic::catch_unwind(|| { + ort::session::Session::builder() + .and_then(|b| b.with_intra_threads(intra)) + .and_then(|b| b.commit_from_file(path)) + .map_err(|e| BgError::Inference(format!("load session: {e}"))) + }) + .unwrap_or_else(|_| { + Err(BgError::Inference( + "ONNX Runtime indisponível ou incompatível; instale onnxruntime para usar a IA".into(), + )) + }) } #[cfg(feature = "onnx")] @@ -271,7 +326,7 @@ fn run_session( .map_err(|e| BgError::Inference(format!("extract: {e}")))?; let shape_slice: &[i64] = shape; let dims: Vec = shape_slice.iter().map(|&d| d as usize).collect(); - if dims.len() != 4 { + if dims != [1, 1, INPUT_SIDE, INPUT_SIDE] || data.len() != INPUT_PIXELS { return Err(BgError::Processing(format!("saída não 4D: dims = {dims:?}"))); } ndarray::Array4::::from_shape_vec((dims[0], dims[1], dims[2], dims[3]), data.to_vec()) @@ -282,6 +337,16 @@ fn run_session( mod tests { use super::*; + #[test] + fn mask_logits_use_sigmoid_without_overflow() { + assert_eq!(logit_alpha(0.0), 128); + assert_eq!(logit_alpha(-1000.0), 0); + assert_eq!(logit_alpha(1000.0), 255); + assert_eq!(logit_alpha(f32::NEG_INFINITY), 0); + assert_eq!(logit_alpha(f32::INFINITY), 255); + assert!(logit_alpha(-1.0) > 0 && logit_alpha(1.0) < 255); + } + #[test] fn birefnet_lite_is_on_license_allowlist() { assert!(crate::download::allowed_licenses().contains(&BIREFNET_LITE.license_spdx)); diff --git a/crates/bigimage-ai/src/background/memory.rs b/crates/bigimage-ai/src/background/memory.rs new file mode 100644 index 0000000..6ff45fc --- /dev/null +++ b/crates/bigimage-ai/src/background/memory.rs @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Admission policy for the measured BiRefNet FP32 working set. +use super::BgError; +const GIB: u64 = 1024 * 1024 * 1024; +const REQUIRED: u64 = 8 * GIB; +#[cfg(feature = "onnx")] +const MAX_CREDIT: u64 = 13 * GIB / 2; + +fn kilobytes(text: &str, key: &str) -> Option { + let rest = text.lines().find_map(|line| line.strip_prefix(key))?; + let mut fields = rest.split_whitespace(); + let value: u64 = fields.next()?.parse().ok()?; + if fields.next()? != "kB" { + return None; + } + value.checked_mul(1024) +} +fn admit(total: u64, available: u64, credited: u64, workspace: u64) -> Result<(), BgError> { + let required = REQUIRED.saturating_sub(credited).saturating_add(workspace); + if total < REQUIRED || available < required { + return Err(BgError::Memory(format!( + "BiRefNet FP32 requer um orçamento inicial de 8 GiB de RAM livre; \ + disponíveis agora: {:.2} GiB, necessários nesta etapa: {:.2} GiB. \ + Visualizar e converter imagens continuam disponíveis. Swap não conta como RAM livre.", + available as f64 / GIB as f64, + required as f64 / GIB as f64, + ))); + } + Ok(()) +} +#[cfg(feature = "onnx")] +fn unknown(error: impl std::fmt::Display) -> BgError { + BgError::Memory(format!("não foi possível conferir o orçamento de memória: {error}")) +} +#[cfg(feature = "onnx")] +fn process_value(key: &str) -> Result { + let status = std::fs::read_to_string("/proc/self/status").map_err(unknown)?; + kilobytes(&status, key).ok_or_else(|| unknown("/proc/self/status incompleto")) +} +#[cfg(feature = "onnx")] +pub(super) fn resident_bytes() -> Result { + process_value("VmRSS:") +} + +#[cfg(feature = "onnx")] +fn capacity() -> Result<(u64, u64), BgError> { + use std::path::{Component, Path}; + let info = std::fs::read_to_string("/proc/meminfo").map_err(unknown)?; + let mut total = kilobytes(&info, "MemTotal:").ok_or_else(|| unknown("MemTotal ausente"))?; + let mut available = + kilobytes(&info, "MemAvailable:").ok_or_else(|| unknown("MemAvailable ausente"))?; + // Respect every finite ancestor limit in cgroup v2, including a 4 GiB + // container on a host with much more RAM. Never count swap as capacity. + let groups = std::fs::read_to_string("/proc/self/cgroup").map_err(unknown)?; + if let Some(relative) = groups.lines().find_map(|line| line.strip_prefix("0::")) { + if Path::new(relative) + .components() + .any(|part| !matches!(part, Component::RootDir | Component::Normal(_))) + { + return Err(unknown("cgroup inválido")); + } + let base = Path::new("/sys/fs/cgroup"); + let mut directory = base.join(relative.trim_start_matches('/')); + loop { + let limit_path = directory.join("memory.max"); + match std::fs::read_to_string(&limit_path) { + Ok(limit) if limit.trim() != "max" => { + let maximum: u64 = limit.trim().parse().map_err(unknown)?; + let current: u64 = std::fs::read_to_string(directory.join("memory.current")) + .map_err(unknown)? + .trim() + .parse() + .map_err(unknown)?; + total = total.min(maximum); + available = available.min(maximum.saturating_sub(current)); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(unknown(error)), + } + if directory == base { + break; + } + if !directory.pop() || !directory.starts_with(base) { + return Err(unknown("cgroup fora do mount")); + } + } + } + if let Some(limit) = rustix::process::getrlimit(rustix::process::Resource::As).current { + total = total.min(limit); + available = available.min(limit.saturating_sub(process_value("VmSize:")?)); + } + Ok((total, available.min(total))) +} + +#[cfg(feature = "onnx")] +pub(super) struct MemoryGuard { + credited: u64, +} +#[cfg(feature = "onnx")] +impl MemoryGuard { + pub(super) fn new() -> Result { + let guard = Self { credited: 0 }; + guard.check(0)?; + Ok(guard) + } + pub(super) fn check(&self, workspace: u64) -> Result<(), BgError> { + let (total, available) = capacity()?; + admit(total, available, self.credited.min(resident_bytes()?), workspace) + } + pub(super) fn account_native_growth(&mut self, before: u64) -> Result<(), BgError> { + let after = resident_bytes()?; + self.credited = if after >= before { + self.credited.saturating_add(after - before) + } else { + self.credited.saturating_sub(before - after) + } + .min(MAX_CREDIT); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn four_gib_is_refused_even_with_swap_or_residency_credit() { + assert!(admit(4 * GIB, 3 * GIB, 0, 0).is_err()); + assert!(admit(4 * GIB, 3 * GIB, 6 * GIB, 0).is_err()); + } + #[test] + fn reused_native_memory_does_not_require_a_second_full_budget() { + assert!(admit(16 * GIB, 8 * GIB, 0, 0).is_ok()); + assert!(admit(16 * GIB, 2 * GIB, 6 * GIB, 0).is_ok()); + assert!(admit(16 * GIB, GIB, 6 * GIB, 0).is_err()); + assert!(admit(16 * GIB, 2 * GIB, 6 * GIB, GIB).is_err()); + } + #[test] + fn low_available_memory_and_missing_units_are_rejected() { + assert!(admit(32 * GIB, 7 * GIB, 0, 0).is_err()); + assert_eq!( + kilobytes("MemAvailable: 4096 kB\nSwapFree: 9000000 kB", "MemAvailable:"), + Some(4 * 1024 * 1024) + ); + assert_eq!(kilobytes("MemAvailable: 4096 MB", "MemAvailable:"), None); + assert_eq!(kilobytes("MemAvailable: 18446744073709551615 kB", "MemAvailable:"), None); + } +} diff --git a/crates/bigimage-ai/src/download.rs b/crates/bigimage-ai/src/download.rs index 3840171..2f6033a 100644 --- a/crates/bigimage-ai/src/download.rs +++ b/crates/bigimage-ai/src/download.rs @@ -2,7 +2,7 @@ //! Download manager for IA model files. //! //! Every model we ship has a pinned `url` + `sha256`. Downloads are -//! atomic: we write to `.part`, verify the hash, and only rename +//! atomic: we write to a unique temporary sibling, verify the hash, and only rename //! into place when everything matches. Re-running over an already-valid //! file is a no-op — the hash short-circuits the copy. //! @@ -101,19 +101,18 @@ pub fn ensure( return Ok(dest); } - // Stream into a `.part` sibling. Any mid-download crash leaves a - // partial we can re-run over without polluting `dest`. - let partial = dest.with_extension("onnx.part"); - download_stream(model.url, &partial, model.size_bytes, &mut progress)?; - - // Verify before renaming — callers trust "this path has that hash". - let actual = file_sha256(&partial).map_err(DownloadError::Io)?; + let mut partial = tempfile::Builder::new() + .prefix(".bigiris-model-") + .suffix(".part") + .tempfile_in(dest.parent().ok_or(DownloadError::NoCacheDir)?) + .map_err(DownloadError::Io)?; + download_stream(model.url, partial.as_file_mut(), model.size_bytes, &mut progress)?; + let actual = file_sha256(partial.path()).map_err(DownloadError::Io)?; if !actual.eq_ignore_ascii_case(model.sha256) { - let _ = std::fs::remove_file(&partial); return Err(DownloadError::HashMismatch { expected: model.sha256.to_string(), actual }); } - - std::fs::rename(&partial, &dest).map_err(DownloadError::Io)?; + partial.as_file().sync_all().map_err(DownloadError::Io)?; + partial.persist(&dest).map_err(|e| DownloadError::Io(e.error))?; Ok(dest) } @@ -123,17 +122,62 @@ pub fn ensure( /// hostile mirror can't fill the disk before SHA-256 verification. const MAX_OVER_BYTES: u64 = 16 * 1024 * 1024; -/// Stream `url` into `dest`, calling `progress(current, total)` every -/// chunk. Uses a 64 KiB buffer — enough to amortise syscalls without -/// blowing cache locality. +fn https_url(url: &str) -> Result { + let parsed = url::Url::parse(url).map_err(|e| DownloadError::Http(e.to_string()))?; + if parsed.scheme() != "https" + || parsed.host_str().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + { + return Err(DownloadError::Http("download exige HTTPS sem credenciais na URL".to_string())); + } + Ok(parsed) +} + +fn redirect_url(current: &url::Url, location: &str) -> Result { + let joined = current.join(location).map_err(|e| DownloadError::Http(e.to_string()))?; + https_url(joined.as_str()) +} + +fn download_response(url: &str) -> Result { + let connector = + ureq::native_tls::TlsConnector::new().map_err(|e| DownloadError::Http(e.to_string()))?; + let agent = ureq::AgentBuilder::new() + .tls_connector(std::sync::Arc::new(connector)) + .redirects(0) + .timeout_connect(std::time::Duration::from_secs(15)) + .timeout_read(std::time::Duration::from_secs(60)) + .timeout_write(std::time::Duration::from_secs(60)) + .build(); + let mut current = https_url(url)?; + for _ in 0..=10 { + let response = + agent.get(current.as_str()).call().map_err(|e| DownloadError::Http(e.to_string()))?; + match response.status() { + 301 | 302 | 303 | 307 | 308 => { + let location = response.header("Location").ok_or_else(|| { + DownloadError::Http("redirecionamento sem Location".to_string()) + })?; + // Validate every hop before any request to its destination. + current = redirect_url(¤t, location)?; + } + 200..=299 => return Ok(response), + status => return Err(DownloadError::Http(format!("status HTTP inesperado: {status}"))), + } + } + Err(DownloadError::Http("muitos redirecionamentos".to_string())) +} + +/// Stream into an exclusively created temporary file; callers own cleanup. fn download_stream( url: &str, - dest: &Path, + file: &mut std::fs::File, expected_total: u64, progress: &mut impl FnMut(u64, u64), ) -> Result<(), DownloadError> { - tracing::info!(%url, ?dest, "baixando modelo"); - let resp = ureq::get(url).call().map_err(|e| DownloadError::Http(e.to_string()))?; + tracing::info!(%url, "baixando modelo"); + let resp = download_response(url)?; + let total: u64 = resp.header("Content-Length").and_then(|v| v.parse().ok()).unwrap_or(expected_total); @@ -143,7 +187,6 @@ fn download_stream( let budget = expected_total.saturating_add(MAX_OVER_BYTES); let mut reader = resp.into_reader(); - let mut file = std::fs::File::create(dest).map_err(DownloadError::Io)?; let mut buf = vec![0u8; 64 * 1024]; let mut done = 0u64; @@ -152,19 +195,15 @@ fn download_stream( if n == 0 { break; } - file.write_all(&buf[..n]).map_err(DownloadError::Io)?; done = done.saturating_add(n as u64); if done > budget { - // Drop the partial; the caller's `ensure` flow already swallows - // any remove error so we don't leak filesystem state to it. - drop(file); - let _ = std::fs::remove_file(dest); return Err(DownloadError::Http(format!( "servidor enviou mais que o esperado ({} MB > limite de {} MB)", done / (1024 * 1024), budget / (1024 * 1024), ))); } + file.write_all(&buf[..n]).map_err(DownloadError::Io)?; progress(done, total); } file.flush().map_err(DownloadError::Io)?; @@ -197,14 +236,23 @@ mod tests { use super::*; use tempfile::TempDir; + #[test] + fn urls_refuse_plaintext_and_credentials_before_network_access() { + assert!(https_url("http://example.org/model.onnx").is_err()); + assert!(https_url("https://user:password@example.org/model.onnx").is_err()); + let base = https_url("https://example.org/models/a").unwrap(); + assert!(redirect_url(&base, "http://example.org/model").is_err()); + assert_eq!(redirect_url(&base, "../model").unwrap().as_str(), "https://example.org/model"); + assert!(redirect_url(&base, "//user:password@example.org/model").is_err()); + } + #[test] fn file_sha256_matches_known_sample() { let dir = TempDir::new().unwrap(); let path = dir.path().join("a.bin"); std::fs::write(&path, b"hello, bigiris\n").unwrap(); - // Precomputed: sha256("hello, bigiris\n") = 7f2f9b9a…f9d let digest = file_sha256(&path).unwrap(); - assert_eq!(digest.len(), 64, "sha256 hex must be 64 chars"); + assert_eq!(digest, "4a30443c709da4a8901dcba8bd388efda3da7d23772b5d5d6c7c0e0c2c368744"); } #[test] diff --git a/crates/bigimage-core/Cargo.toml b/crates/bigimage-core/Cargo.toml index 233e411..5696492 100644 --- a/crates/bigimage-core/Cargo.toml +++ b/crates/bigimage-core/Cargo.toml @@ -14,23 +14,11 @@ tracing.workspace = true image.workspace = true fast_image_resize.workspace = true kamadak-exif.workspace = true -serde = { workspace = true, optional = true } - -[dev-dependencies] tempfile = "3" +moxcms = { version = "0.8", features = ["in_place"] } +rustix = { version = "1", features = ["fs"] } +serde = { workspace = true, optional = true } [features] default = [] serde = ["dep:serde"] - -# Tier-2 format gates. Reserved now so downstream Cargo.toml and CLI can -# opt in without churn; decoder/encoder wiring lands in a follow-up -# iteration (each brings in a system C lib). -# -# heic: libheif-rs — HEIC/HEIF (iPhone, Apple ecosystem). -# jxl : jpegxl-rs (encode) + jxl-oxide (decode, pure-Rust) — next-gen JPEG. -# raw : rawler — Canon CR2/CR3, Nikon NEF, Sony ARW, Fuji RAF, Olympus ORF, -# Adobe DNG, Pentax PEF, Panasonic RW2 (decode only). -heic = [] -jxl = [] -raw = [] diff --git a/crates/bigimage-core/examples/avif_tuning.rs b/crates/bigimage-core/examples/avif_tuning.rs new file mode 100644 index 0000000..2211544 --- /dev/null +++ b/crates/bigimage-core/examples/avif_tuning.rs @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Compare AVIF threading with identical pixels, quality and speed. +use image::ImageEncoder; +use std::time::Instant; +fn main() -> Result<(), Box> { + let img = image::RgbaImage::from_fn(1920, 1080, |x, y| { + image::Rgba([ + (x.wrapping_mul(13) ^ y.wrapping_mul(7)) as u8, + (x / 8) as u8, + (y / 4) as u8, + 255, + ]) + }); + let mut records = Vec::new(); + for threads in [Some(1), Some(2), Some(4), None] { + let start = Instant::now(); + let mut bytes = Vec::new(); + let encoder = image::codecs::avif::AvifEncoder::new_with_speed_quality(&mut bytes, 8, 80) + .with_num_threads(threads); + encoder.write_image( + img.as_raw(), + img.width(), + img.height(), + image::ExtendedColorType::Rgba8, + )?; + records.push(format!( + "{{\"threads\":{},\"ms\":{:.3},\"bytes\":{}}}", + threads.map_or("null".to_string(), |n| n.to_string()), + start.elapsed().as_secs_f64() * 1000.0, + bytes.len() + )); + } + println!( + "{{\"width\":1920,\"height\":1080,\"speed\":8,\"quality\":80,\"results\":[{}]}}", + records.join(",") + ); + Ok(()) +} diff --git a/crates/bigimage-core/examples/quality_corpus.rs b/crates/bigimage-core/examples/quality_corpus.rs new file mode 100644 index 0000000..c348f72 --- /dev/null +++ b/crates/bigimage-core/examples/quality_corpus.rs @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Generate synthetic codec/ICC seeds; never reads personal images. +use std::io::Write; +fn main() -> Result<(), Box> { + let root = + std::path::PathBuf::from(std::env::args_os().nth(1).ok_or("provide output directory")?); + std::fs::create_dir_all(root.join("decode"))?; + std::fs::create_dir_all(root.join("icc"))?; + let input = root.join("decode/source.png"); + image::RgbImage::from_fn(32, 24, |x, y| image::Rgb([(x * 7) as u8, (y * 11) as u8, 150])) + .save(&input)?; + for format in bigimage_core::Format::all() { + bigimage_core::convert_file_to( + &input, + None, + *format, + &bigimage_core::EncodeOptions::default(), + bigimage_core::OverwritePolicy::Replace, + )?; + } + for (name, profile) in [ + ("srgb", moxcms::ColorProfile::new_srgb()), + ("display-p3", moxcms::ColorProfile::new_display_p3()), + ] { + std::fs::File::create(root.join("icc").join(name))?.write_all(&profile.encode()?)?; + } + Ok(()) +} diff --git a/crates/bigimage-core/examples/quality_probe.rs b/crates/bigimage-core/examples/quality_probe.rs new file mode 100644 index 0000000..67804c4 --- /dev/null +++ b/crates/bigimage-core/examples/quality_probe.rs @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Reproducible, synthetic-only performance and stability probe. +//! cargo run --release -p bigimage-core --example quality_probe -- benchmark 10 +//! cargo run --release -p bigimage-core --example quality_probe -- soak 300 +use std::path::Path; +use std::time::{Duration, Instant}; + +use bigimage_core::{ + convert_file_to, open_image, EncodeOptions, Filter, Format, OverwritePolicy, PreviewOp, + PreviewSession, ResizeMode, +}; + +fn metric(field: &str) -> u64 { + std::fs::read_to_string("/proc/self/status") + .ok() + .and_then(|s| { + s.lines().find_map(|l| { + l.strip_prefix(field) + .and_then(|v| v.split_whitespace().next()) + .and_then(|v| v.parse().ok()) + }) + }) + .unwrap_or(0) +} +fn descriptors() -> usize { + std::fs::read_dir("/proc/self/fd").map(|entries| entries.count()).unwrap_or(0) +} +fn cycle(source: &Path, output: &Path) -> Result<(), Box> { + let image = open_image(source)?; + let resized = + bigimage_core::resize::apply_to(&image, ResizeMode::MaxEdge(1080), Filter::Lanczos3)?; + std::hint::black_box(resized); + let session = PreviewSession::open(source)?; + std::hint::black_box(session.apply(&PreviewOp::Identity)?); + convert_file_to( + source, + Some(output), + Format::Jpeg, + &EncodeOptions::default(), + OverwritePolicy::Replace, + )?; + Ok(()) +} +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args().collect(); + let mode = args.get(1).map(String::as_str).unwrap_or("benchmark"); + let count: u64 = args.get(2).map(|s| s.parse()).transpose()?.unwrap_or(10); + if count == 0 + || count > if mode == "soak" { 86400 } else { 3600 } + || !["benchmark", "stress", "soak"].contains(&mode) + { + return Err("use benchmark , stress ou soak , opcionalmente WIDTHxHEIGHT".into()); + } + let size = args.get(3).map(String::as_str).unwrap_or("1920x1080"); + let (width, height) = size.split_once('x').ok_or("size must be WIDTHxHEIGHT")?; + let (width, height): (u32, u32) = (width.parse()?, height.parse()?); + if width == 0 || height == 0 || width > 4096 || height > 4096 { + return Err("probe dimensions must be within 1..4096".into()); + } + let dir = tempfile::tempdir()?; + let source = dir.path().join("synthetic.png"); + let output = dir.path().join("out"); + let image = image::RgbImage::from_fn(width, height, |x, y| { + image::Rgb([(x.wrapping_mul(13) ^ y.wrapping_mul(7)) as u8, (x / 8) as u8, (y / 4) as u8]) + }); + image.save(&source)?; + drop(image); + for _ in 0..5 { + cycle(&source, &output)?; + } + if mode == "benchmark" { + let mut records = Vec::new(); + for name in [ + "decode_png", + "resize_1080", + "preview_800", + "encode_png", + "encode_jpeg", + "encode_webp", + "encode_avif", + ] { + let mut samples = Vec::new(); + for _ in 0..count { + let start = Instant::now(); + match name { + "decode_png" => { + std::hint::black_box(open_image(&source)?); + } + "resize_1080" => { + let img = open_image(&source)?; + std::hint::black_box(bigimage_core::resize::apply_to( + &img, + ResizeMode::MaxEdge(1080), + Filter::Lanczos3, + )?); + } + "preview_800" => { + std::hint::black_box(PreviewSession::open(&source)?); + } + _ => { + let format = match name { + "encode_png" => Format::Png, + "encode_jpeg" => Format::Jpeg, + "encode_webp" => Format::WebP, + _ => Format::Avif, + }; + convert_file_to( + &source, + Some(&output), + format, + &EncodeOptions::default(), + OverwritePolicy::Replace, + )?; + } + } + samples.push(start.elapsed().as_secs_f64() * 1000.0); + } + samples.sort_by(f64::total_cmp); + let p95 = + samples[((samples.len() as f64 * 0.95).ceil() as usize - 1).min(samples.len() - 1)]; + records.push(format!("{{\"operation\":\"{name}\",\"samples\":{count},\"median_ms\":{:.3},\"p95_ms\":{p95:.3}}}", samples[samples.len()/2])); + } + println!("{{\"mode\":\"benchmark\",\"width\":{width},\"height\":{height},\"warm_cache\":true,\"rss_kib\":{},\"hwm_kib\":{},\"operations\":[{}]}}", metric("VmRSS:"), metric("VmHWM:"), records.join(",")); + } else if mode == "stress" { + let inputs = vec![source.clone(); count as usize]; + let workers = bigimage_core::resources::batch_worker_count(&inputs); + let next = std::sync::atomic::AtomicUsize::new(0); + let start = Instant::now(); + std::thread::scope(|scope| -> Result<(), String> { + let handles: Vec<_> = (0..workers) + .map(|_| { + scope.spawn(|| -> Result<(), String> { + loop { + let idx = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if idx >= count as usize { + break; + } + let result = convert_file_to( + &source, + Some(&output), + Format::Png, + &EncodeOptions::default(), + OverwritePolicy::Increment, + ) + .map_err(|e| e.to_string())?; + let decoded = open_image(result.path()).map_err(|e| e.to_string())?; + if (decoded.width(), decoded.height()) != (width, height) { + return Err("corrupt output".into()); + } + } + Ok(()) + }) + }) + .collect(); + for handle in handles { + handle.join().map_err(|_| "worker panic")??; + } + Ok(()) + })?; + // Warmup produced one JPEG; stress adds exactly `count` PNG files. + let written = std::fs::read_dir(&output)?.count(); + if written != count as usize + 1 { + return Err("lost or partial output".into()); + } + println!("{{\"mode\":\"stress\",\"jobs\":{count},\"workers\":{workers},\"seconds\":{:.3},\"hwm_kib\":{},\"passed\":true}}", start.elapsed().as_secs_f64(), metric("VmHWM:")); + } else { + let baseline = metric("VmRSS:"); + let fd_start = descriptors(); + let threads_start = metric("Threads:"); + let start = Instant::now(); + let mut cycles = 0; + let mut samples = vec![baseline]; + while start.elapsed() < Duration::from_secs(count) { + cycle(&source, &output)?; + cycles += 1; + if cycles % 25 == 0 { + samples.push(metric("VmRSS:")); + } + } + let final_rss = metric("VmRSS:"); + samples.push(final_rss); + let fd_end = descriptors(); + let threads_end = metric("Threads:"); + let growth = final_rss.saturating_sub(baseline); + let passed = growth <= 64 * 1024 && fd_end == fd_start && threads_end == threads_start; + println!("{{\"mode\":\"soak\",\"seconds\":{:.3},\"cycles\":{cycles},\"baseline_rss_kib\":{baseline},\"final_rss_kib\":{final_rss},\"growth_kib\":{growth},\"hwm_kib\":{},\"fds_before\":{fd_start},\"fds_after\":{fd_end},\"threads_before\":{threads_start},\"threads_after\":{threads_end},\"rss_samples_kib\":{samples:?},\"passed\":{passed}}}", start.elapsed().as_secs_f64(), metric("VmHWM:")); + if !passed { + return Err( + "crescimento de recursos acima do limite do probe; investigar com profiler".into(), + ); + } + } + Ok(()) +} diff --git a/crates/bigimage-core/src/adjust.rs b/crates/bigimage-core/src/adjust.rs index 728225d..4e751cd 100644 --- a/crates/bigimage-core/src/adjust.rs +++ b/crates/bigimage-core/src/adjust.rs @@ -125,9 +125,7 @@ pub fn adjust_file( tracing::debug!(?input, ?output, ?ops, "adjust: applying"); let tweaked = if ops.is_identity() { src } else { apply_to(src, ops) }; - let output = - pipeline::encode_and_cleanup(tweaked, output, target, &crate::EncodeOptions::default())?; - Ok(ConvertOutcome::Written { output }) + pipeline::encode_and_cleanup(tweaked, output, target, &crate::EncodeOptions::default(), policy) } /// Pure in-memory variant of [`adjust_file`] — applies `ops` to an already- diff --git a/crates/bigimage-core/src/animate.rs b/crates/bigimage-core/src/animate.rs index 4b3b80d..f2bf950 100644 --- a/crates/bigimage-core/src/animate.rs +++ b/crates/bigimage-core/src/animate.rs @@ -1,12 +1,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later //! Build animated GIFs (and, later, WebP/APNG) from a stack of still frames. //! -//! Frames come in as file paths, so this module owns both the decode and -//! the encode passes — no middle hop through our standard pipeline since -//! every frame has to live in memory as RGBA before `GifEncoder` can -//! emit it. For the typical "10 photos → looping GIF" workflow that's -//! fine; proper memory-constant streaming is the job of `libvips` in a -//! future iteration. +//! Frames are decoded and encoded one at a time. A unique temporary sibling +//! protects both existing outputs and input frames when encoding fails. use std::path::{Path, PathBuf}; @@ -67,14 +63,9 @@ pub fn make_gif(frames: &[PathBuf], output: &Path, opts: AnimateOptions) -> Resu return Err(BigImageError::InvalidInput("nenhum quadro informado".to_string())); } - // Prepare output file + encoder. - if let Some(parent) = output.parent() { - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent).map_err(BigImageError::Io)?; - } - } - let file = std::fs::File::create(output).map_err(BigImageError::Io)?; - let mut encoder = GifEncoder::new_with_speed(file, opts.speed.clamp(1, 30)); + let mut temp = pipeline::output_temp(output)?; + let mut writer = std::io::BufWriter::new(temp.as_file_mut()); + let mut encoder = GifEncoder::new_with_speed(&mut writer, opts.speed.clamp(1, 30)); encoder .set_repeat(opts.loop_mode.to_gif_repeat()) .map_err(|e| BigImageError::Encode { path: output.to_path_buf(), source: e })?; @@ -98,7 +89,12 @@ pub fn make_gif(frames: &[PathBuf], output: &Path, opts: AnimateOptions) -> Resu push_frame(img)?; } - Ok(output.to_path_buf()) + drop(encoder); + std::io::Write::flush(&mut writer).map_err(BigImageError::Io)?; + drop(writer); + let outcome = + pipeline::publish_temp(temp, output.to_path_buf(), crate::OverwritePolicy::Replace)?; + Ok(outcome.path().to_path_buf()) } /// Bring an arbitrarily-sized `img` to the canonical `w × h` canvas used diff --git a/crates/bigimage-core/src/backend.rs b/crates/bigimage-core/src/backend.rs new file mode 100644 index 0000000..1201e1f --- /dev/null +++ b/crates/bigimage-core/src/backend.rs @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Optional process boundary supplied by the executable. Library users may +//! retain local decoding; BigIris installs a sandboxed backend before any UI. +use crate::Format; +use image::{DynamicImage, ImageResult}; +use std::path::Path; +use std::sync::OnceLock; + +/// Decoded, upright pixels normalized to sRGB when an ICC profile is present. +pub type Decoded = (DynamicImage, Option); +/// Process backend callbacks. No image codec runs in the calling process. +pub struct Backend { + /// Decode complete pixels through the process boundary. + pub decode: fn(&Path) -> ImageResult, + /// Inspect dimensions through the process boundary. + pub dimensions: fn(&Path) -> ImageResult<(u32, u32)>, + /// A bounded thumbnail and its natural dimensions. + pub preview: fn(&Path) -> crate::Result, + /// Refuse static editing of animations and multipage containers. + pub ensure_static: fn(&Path) -> crate::Result<()>, + /// Inspect EXIF in the confined process. + pub metadata: fn(&Path) -> crate::Result, +} +static BACKEND: OnceLock = OnceLock::new(); +/// Configure the backend once at executable startup, before processing files. +pub fn configure(backend: Backend) -> bool { + BACKEND.set(backend).is_ok() +} +pub(crate) fn configured() -> Option<&'static Backend> { + BACKEND.get() +} +/// Local decoding entry point reserved for the isolated codec process and +/// standalone library consumers. Never calls the configured backend. +pub fn decode_in_process(path: &Path) -> ImageResult { + crate::pipeline::decode_local(path) +} +/// Header inspection inside the isolated codec process. +pub fn dimensions_in_process(path: &Path) -> ImageResult<(u32, u32)> { + crate::pipeline::bounded_dimensions(path) +} diff --git a/crates/bigimage-core/src/color.rs b/crates/bigimage-core/src/color.rs new file mode 100644 index 0000000..2b512e6 --- /dev/null +++ b/crates/bigimage-core/src/color.rs @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! ICC normalization without retaining identifying profile metadata. +use image::{DynamicImage, ImageError, ImageResult}; +use moxcms::{ColorProfile, DataColorSpace, Layout, ParsingOptions, TransformOptions}; + +fn error(message: impl std::fmt::Display) -> ImageError { + ImageError::IoError(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("perfil ICC: {message}"), + )) +} +/// Convert RGB ICC pixels into canonical sRGB, preserving sample precision +/// and alpha. Unsupported or malformed profiles fail explicitly. +pub fn normalize_srgb(image: &mut DynamicImage, icc: &[u8]) -> ImageResult<()> { + let profile = ColorProfile::new_from_slice_with_options( + icc, + ParsingOptions { + max_profile_size: 4 * 1024 * 1024, + max_allowed_clut_size: 1024 * 1024, + max_allowed_trc_size: 16384, + }, + ) + .map_err(error)?; + if profile.color_space != DataColorSpace::Rgb { + return Err(error( + "perfil não RGB não suportado; conversão interrompida para preservar a fidelidade", + )); + } + let target = ColorProfile::new_srgb(); + // Our canonical profile needs no transform: preserve exact lossless samples. + if same_canonical_profile(icc, &target.encode().map_err(error)?) { + image.set_color_space(image::metadata::Cicp::SRGB)?; + return Ok(()); + } + let options = TransformOptions::default(); + match image { + DynamicImage::ImageRgb8(buffer) => profile + .create_in_place_transform_8bit(Layout::Rgb, &target, options) + .map_err(error)? + .transform(buffer.as_mut()) + .map_err(error)?, + DynamicImage::ImageRgba8(buffer) => profile + .create_in_place_transform_8bit(Layout::Rgba, &target, options) + .map_err(error)? + .transform(buffer.as_mut()) + .map_err(error)?, + DynamicImage::ImageRgb16(buffer) => profile + .create_in_place_transform_16bit(Layout::Rgb, &target, options) + .map_err(error)? + .transform(buffer.as_mut()) + .map_err(error)?, + DynamicImage::ImageRgba16(buffer) => profile + .create_in_place_transform_16bit(Layout::Rgba, &target, options) + .map_err(error)? + .transform(buffer.as_mut()) + .map_err(error)?, + DynamicImage::ImageRgb32F(buffer) => profile + .create_in_place_transform_f32(Layout::Rgb, &target, options) + .map_err(error)? + .transform(buffer.as_mut()) + .map_err(error)?, + DynamicImage::ImageRgba32F(buffer) => profile + .create_in_place_transform_f32(Layout::Rgba, &target, options) + .map_err(error)? + .transform(buffer.as_mut()) + .map_err(error)?, + _ => return Err(error("layout de pixels incompatível com o perfil RGB")), + } + image.set_color_space(image::metadata::Cicp::SRGB)?; + Ok(()) +} +// ICC creation timestamp and profile ID do not change colorimetry. +fn same_canonical_profile(a: &[u8], b: &[u8]) -> bool { + a.len() >= 128 + && a.len() == b.len() + && a[..24] == b[..24] + && a[36..84] == b[36..84] + && a[100..] == b[100..] +} + +/// A generated sRGB profile contains no source camera, GPS or author fields. +pub(crate) fn srgb_profile() -> ImageResult> { + ColorProfile::new_srgb().encode().map_err(error) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn display_p3_changes_pixels_and_preserves_alpha_and_precision() { + let profile = ColorProfile::new_display_p3().encode().unwrap(); + let mut image = DynamicImage::ImageRgba16(image::ImageBuffer::from_pixel( + 2, + 1, + image::Rgba([40000, 20000, 10000, 12345]), + )); + normalize_srgb(&mut image, &profile).unwrap(); + let pixel = image.as_rgba16().unwrap().get_pixel(0, 0).0; + assert_ne!(&pixel[..3], &[40000, 20000, 10000]); + assert_eq!(pixel[3], 12345); + } + #[test] + fn generated_srgb_preserves_exact_samples_across_creation_dates() { + let mut icc = srgb_profile().unwrap(); + icc[24..36].copy_from_slice(&[0x07, 0xe8, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0]); + icc[84..100].fill(42); + let mut image = DynamicImage::ImageRgb16(image::ImageBuffer::from_pixel( + 2, + 1, + image::Rgb([12345, 23456, 34567]), + )); + normalize_srgb(&mut image, &icc).unwrap(); + assert_eq!(image.as_rgb16().unwrap().get_pixel(0, 0).0, [12345, 23456, 34567]); + } + + #[test] + fn malformed_profile_does_not_silently_relabel_pixels() { + let mut image = DynamicImage::new_rgb8(1, 1); + assert!(normalize_srgb(&mut image, b"invalid ICC").is_err()); + } +} diff --git a/crates/bigimage-core/src/container_bounds.rs b/crates/bigimage-core/src/container_bounds.rs new file mode 100644 index 0000000..83989f8 --- /dev/null +++ b/crates/bigimage-core/src/container_bounds.rs @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Validate BMFF box boundaries before codecs allocate from declared sizes. +use std::io::{Read, Seek, SeekFrom}; +fn invalid() -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "AVIF inválido: caixa fora do contêiner ou complexidade excessiva", + ) +} +pub(crate) fn inspect(reader: &mut (impl Read + Seek)) -> std::io::Result<()> { + let end = reader.seek(SeekFrom::End(0))?; + let mut count = 0; + boxes(reader, 0, end, 0, &mut count) +} +fn boxes( + reader: &mut (impl Read + Seek), + mut position: u64, + end: u64, + depth: u8, + count: &mut u32, +) -> std::io::Result<()> { + if depth > 16 { + return Err(invalid()); + } + while position < end { + *count += 1; + if *count > 100_000 || end - position < 8 { + return Err(invalid()); + } + reader.seek(SeekFrom::Start(position))?; + let mut header = [0; 8]; + reader.read_exact(&mut header)?; + let short = u32::from_be_bytes(header[..4].try_into().unwrap()); + let (size, header_size) = match short { + 0 => (end - position, 8u64), + 1 => { + if end - position < 16 { + return Err(invalid()); + } + let mut size = [0; 8]; + reader.read_exact(&mut size)?; + (u64::from_be_bytes(size), 16) + } + size => (u64::from(size), 8), + }; + let next = position.checked_add(size).filter(|next| *next <= end).ok_or_else(invalid)?; + if size < header_size { + return Err(invalid()); + } + let content = position + header_size; + match &header[4..8] { + b"meta" | b"iref" => { + if size < header_size + 4 { + return Err(invalid()); + } + boxes(reader, content + 4, next, depth + 1, count)?; + } + b"iprp" | b"ipco" | b"moov" | b"trak" | b"mdia" | b"minf" | b"stbl" | b"edts" + | b"dinf" => { + boxes(reader, content, next, depth + 1, count)?; + } + b"iinf" | b"ipro" => { + if size < header_size + 6 { + return Err(invalid()); + } + let mut full = [0; 4]; + reader.read_exact(&mut full)?; + let entries = if &header[4..8] == b"iinf" && full[0] != 0 { 4 } else { 2 }; + if size < header_size + 4 + entries { + return Err(invalid()); + } + boxes(reader, content + 4 + entries, next, depth + 1, count)?; + } + _ => {} + } + position = next; + } + Ok(()) +} diff --git a/crates/bigimage-core/src/content.rs b/crates/bigimage-core/src/content.rs new file mode 100644 index 0000000..977f7b1 --- /dev/null +++ b/crates/bigimage-core/src/content.rs @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Prevent implicit frame/page loss during static transformations. +use crate::{BigImageError, Result}; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; + +/// Static transformations never silently discard animation or TIFF pages. +pub fn ensure_static(path: &Path) -> Result<()> { + if let Some(backend) = crate::backend::configured() { + return (backend.ensure_static)(path); + } + ensure_static_local(path) +} +/// Local inspection entry point for the confined worker. +pub fn ensure_static_local(path: &Path) -> Result<()> { + // Validate size and regular-file status before any container parser. + crate::pipeline::bounded_dimensions(path) + .map_err(|source| BigImageError::Decode { path: path.into(), source })?; + let reader = crate::pipeline::bounded_reader(path) + .map_err(|source| BigImageError::Decode { path: path.into(), source })?; + let format = reader.format(); + let mut file = reader.into_inner(); + let result: image::ImageResult = (|| { + Ok(match format { + Some(image::ImageFormat::Gif) => inspect_gif(&mut file)?, + Some(image::ImageFormat::Png) => { + image::codecs::png::PngDecoder::new(file)?.is_apng()? + } + Some(image::ImageFormat::WebP) => { + image::codecs::webp::WebPDecoder::new(file)?.has_animation() + } + Some(image::ImageFormat::Tiff) => { + let mut header = [0; 16]; + file.read_exact(&mut header[..8])?; + let little = &header[..2] == b"II"; + let word = |bytes: [u8; 2]| { + if little { + u16::from_le_bytes(bytes) + } else { + u16::from_be_bytes(bytes) + } + }; + let long = |bytes: [u8; 4]| { + if little { + u32::from_le_bytes(bytes) + } else { + u32::from_be_bytes(bytes) + } + }; + let wide = |bytes: [u8; 8]| { + if little { + u64::from_le_bytes(bytes) + } else { + u64::from_be_bytes(bytes) + } + }; + let (first, big) = if word(header[2..4].try_into().unwrap()) == 43 { + file.read_exact(&mut header[8..16])?; + (wide(header[8..16].try_into().unwrap()), true) + } else { + (u64::from(long(header[4..8].try_into().unwrap())), false) + }; + file.seek(SeekFrom::Start(first))?; + let mut bytes = [0; 8]; + let (count, entry, base) = if big { + file.read_exact(&mut bytes)?; + (wide(bytes), 20u64, 8u64) + } else { + file.read_exact(&mut bytes[..2])?; + (u64::from(word(bytes[..2].try_into().unwrap())), 12, 2) + }; + let next = count + .checked_mul(entry) + .and_then(|n| first.checked_add(base)?.checked_add(n)) + .ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid TIFF IFD") + })?; + file.seek(SeekFrom::Start(next))?; + if big { + file.read_exact(&mut bytes)?; + wide(bytes) != 0 + } else { + file.read_exact(&mut bytes[..4])?; + long(bytes[..4].try_into().unwrap()) != 0 + } + } + _ => false, + }) + })(); + if result.map_err(|source| BigImageError::Decode { path: path.into(), source })? { + return Err(BigImageError::InvalidInput("imagem animada ou com múltiplas páginas: edição estática interrompida para evitar perda de conteúdo".into())); + } + Ok(()) +} + +/// Count frames without allocating pixels, and reject frames outside the canvas. +pub(crate) fn inspect_gif(reader: &mut (impl Read + Seek)) -> image::ImageResult { + fn malformed() -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "GIF inválido: bloco ou quadro fora da tela", + ) + } + fn byte(reader: &mut impl Read) -> std::io::Result { + let mut value = [0]; + reader.read_exact(&mut value)?; + Ok(value[0]) + } + fn skip(reader: &mut (impl Read + Seek), count: u64, end: u64) -> std::io::Result<()> { + let next = reader + .stream_position()? + .checked_add(count) + .filter(|next| *next <= end) + .ok_or_else(malformed)?; + reader.seek(SeekFrom::Start(next))?; + Ok(()) + } + fn blocks(reader: &mut (impl Read + Seek), end: u64) -> std::io::Result<()> { + loop { + let count = byte(reader)?; + if count == 0 { + return Ok(()); + } + skip(reader, u64::from(count), end)?; + } + } + let end = reader.seek(SeekFrom::End(0))?; + reader.seek(SeekFrom::Start(0))?; + let mut header = [0; 13]; + reader.read_exact(&mut header)?; + if &header[..6] != b"GIF87a" && &header[..6] != b"GIF89a" { + return Err(malformed().into()); + } + let width = u16::from_le_bytes(header[6..8].try_into().unwrap()); + let height = u16::from_le_bytes(header[8..10].try_into().unwrap()); + if width == 0 || height == 0 { + return Err(malformed().into()); + } + if header[10] & 0x80 != 0 { + skip(reader, 3 * (1u64 << (1 + (header[10] & 7))), end)?; + } + let mut frames = 0; + loop { + match byte(reader)? { + 0x3b => return Ok(false), + 0x21 => { + byte(reader)?; + blocks(reader, end)?; + } + 0x2c => { + let mut descriptor = [0; 9]; + reader.read_exact(&mut descriptor)?; + let word = |offset| { + u16::from_le_bytes(descriptor[offset..offset + 2].try_into().unwrap()) as u32 + }; + if word(4) == 0 + || word(6) == 0 + || word(0) + word(4) > u32::from(width) + || word(2) + word(6) > u32::from(height) + { + return Err(malformed().into()); + } + frames += 1; + if frames > 1 { + return Ok(true); + } + if descriptor[8] & 0x80 != 0 { + skip(reader, 3 * (1u64 << (1 + (descriptor[8] & 7))), end)?; + } + byte(reader)?; + blocks(reader, end)?; + } + _ => return Err(malformed().into()), + } + } +} diff --git a/crates/bigimage-core/src/convert.rs b/crates/bigimage-core/src/convert.rs index dd1e1bd..ef3b751 100644 --- a/crates/bigimage-core/src/convert.rs +++ b/crates/bigimage-core/src/convert.rs @@ -86,8 +86,7 @@ pub fn convert_file_to( tracing::debug!(?input, ?output, ?target, ?opts, "convert: decoding"); let (img, _src_format) = pipeline::decode_with_source_format(input)?; - let output = pipeline::encode_and_cleanup(img, output, target, opts)?; - Ok(ConvertOutcome::Written { output }) + pipeline::encode_and_cleanup(img, output, target, opts, policy) } /// Coerce a `DynamicImage` into a colour/bit-depth that the target encoder is @@ -98,10 +97,56 @@ pub(crate) fn prepare_for_target(img: DynamicImage, target: Format) -> DynamicIm match target { Format::OpenExr => DynamicImage::ImageRgba32F(img.into_rgba32f()), Format::Hdr => DynamicImage::ImageRgb32F(img.into_rgb32f()), + Format::Ico => { + let img = + if img.width() > 256 || img.height() > 256 { img.thumbnail(256, 256) } else { img }; + DynamicImage::ImageRgba8(img.into_rgba8()) + } + Format::WebP | Format::Gif | Format::Bmp | Format::Tga | Format::Qoi => { + DynamicImage::ImageRgba8(img.into_rgba8()) + } + Format::Png | Format::Tiff + if matches!(img.color(), image::ColorType::Rgb32F | image::ColorType::Rgba32F) => + { + DynamicImage::ImageRgba16(img.into_rgba16()) + } + // JPEG has no alpha channel. The encoder path does a bare + // `to_rgb8()`, which *discards* alpha rather than compositing — so a + // transparent or semi-transparent source (e.g. a letterbox built + // with a transparent background, or any PNG with transparency) would + // emit whatever RGB sat *under* the alpha=0 pixels (usually black or + // garbage) instead of a clean flattened colour. Composite over white + // here so the JPEG shows the conventional flattened result. + Format::Jpeg | Format::Pnm => flatten_over_white(img), _ => img, } } +/// Composite any alpha channel over an opaque white background, returning an +/// `Rgb8` image. Inputs without alpha are returned unchanged (no needless +/// re-allocation). Fully-opaque pixels (alpha=255) are bit-for-bit +/// preserved; only transparent/semi-transparent pixels change. +fn flatten_over_white(img: DynamicImage) -> DynamicImage { + use image::{Rgb, RgbImage}; + + if !img.color().has_alpha() { + return DynamicImage::ImageRgb8(img.into_rgb8()); + } + let rgba = img.to_rgba8(); + let (w, h) = rgba.dimensions(); + let mut out = RgbImage::new(w, h); + for (x, y, px) in rgba.enumerate_pixels() { + let a = u32::from(px[3]); + // out = src*a + white*(255-a), rounded. + let blend = |c: u8| { + let v = u32::from(c) * a + 255 * (255 - a); + ((v + 127) / 255) as u8 + }; + out.put_pixel(x, y, Rgb([blend(px[0]), blend(px[1]), blend(px[2])])); + } + DynamicImage::ImageRgb8(out) +} + #[cfg(test)] mod tests { use super::*; @@ -282,6 +327,67 @@ mod tests { assert!(matches!(back, ConvertOutcome::Written { .. })); } + #[test] + fn replace_in_place_failure_keeps_source() { + // Regression: Replace with output == input must never destroy the + // original on an encode failure. We force a failure by feeding an + // EXR target a path whose parent we make unwritable is brittle, so + // instead assert the happy in-place replace leaves a valid image + // (the atomic temp+rename path) — the destructive bug deleted it. + let dir = TempDir::new().unwrap(); + let src = write_fixture_png(dir.path(), "inplace.png"); + let before = std::fs::read(&src).unwrap(); + assert!(!before.is_empty()); + + let outcome = convert_file( + &src, + Format::Png, + &crate::EncodeOptions::default(), + OverwritePolicy::Replace, + ) + .unwrap(); + assert!(matches!(outcome, ConvertOutcome::Written { .. })); + // Source path still exists and is a decodable PNG (rename swapped a + // fully-written file in; no partial, no deletion). + assert!(src.exists()); + image::open(&src).unwrap(); + // No leftover temp file in the directory. + let leftovers: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().contains(".part-")) + .collect(); + assert!(leftovers.is_empty(), "temp file leaked: {leftovers:?}"); + } + + #[test] + fn rgba_to_jpeg_flattens_alpha_over_white_not_black() { + use image::{Rgba, RgbaImage}; + // Fully-transparent source: dropping alpha would yield black; the + // flatten step must composite over white instead. + let dir = TempDir::new().unwrap(); + let src = dir.path().join("trans.png"); + let img = RgbaImage::from_pixel(4, 4, Rgba([0, 0, 0, 0])); + img.save(&src).unwrap(); + + let out = match convert_file( + &src, + Format::Jpeg, + &crate::EncodeOptions::default(), + OverwritePolicy::Replace, + ) + .unwrap() + { + ConvertOutcome::Written { output } => output, + _ => panic!("expected write"), + }; + let decoded = image::open(&out).unwrap().to_rgb8(); + let px = decoded.get_pixel(2, 2).0; + // JPEG is lossy, so allow a margin, but it must be near-white, never + // near-black (the old bug). + assert!(px[0] > 240 && px[1] > 240 && px[2] > 240, "expected white-ish, got {px:?}"); + } + #[test] fn png_to_avif_roundtrip() { let dir = TempDir::new().unwrap(); diff --git a/crates/bigimage-core/src/crop.rs b/crates/bigimage-core/src/crop.rs index b296180..07ca0e2 100644 --- a/crates/bigimage-core/src/crop.rs +++ b/crates/bigimage-core/src/crop.rs @@ -83,9 +83,7 @@ pub fn crop_file( tracing::debug!(?input, ?output, ?target, ?rect, "crop: computing"); let cropped = apply_to(&src, rect)?; - let output = - pipeline::encode_and_cleanup(cropped, output, target, &crate::EncodeOptions::default())?; - Ok(ConvertOutcome::Written { output }) + pipeline::encode_and_cleanup(cropped, output, target, &crate::EncodeOptions::default(), policy) } /// Pure in-memory crop — validates `rect` against the image then returns a diff --git a/crates/bigimage-core/src/encode.rs b/crates/bigimage-core/src/encode.rs index e3fdf12..efdae39 100644 --- a/crates/bigimage-core/src/encode.rs +++ b/crates/bigimage-core/src/encode.rs @@ -14,7 +14,7 @@ /// new fields don't silently regress. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct EncodeOptions { - /// Quality `1..=100` for lossy formats (JPEG/WebP lossy). `None` + /// Quality `1..=100` for JPEG/AVIF. WebP is always lossless. `None` /// falls back to the encoder's default (JPEG uses 75). pub quality: Option, /// Emit progressive JPEG (multi-pass). Accepted for now but the pure- @@ -34,12 +34,10 @@ pub struct EncodeOptions { /// to fail if the default ever silently flips to `false`. pub strip_metadata: bool, /// AVIF encoder speed `1..=10` (1 = slowest / smallest file, 10 = - /// fastest / larger file). The `image` crate defaults to 4 - /// (balanced — sits ~5–10× slower than 8 on PNG/JPG screenshots). - /// We default to **8** so a hundred-file batch finishes in seconds - /// instead of half an hour. The `optimize` flag clamps the - /// effective speed at 4 to recover the better compression when the - /// user opts in. + /// fastest / larger file). We default to 8, favoring throughput. + /// Actual time depends on image content, dimensions and hardware. + /// The `optimize` flag clamps the effective speed at 4 to favor + /// stronger compression when the user opts in. pub avif_speed: u8, } @@ -70,8 +68,8 @@ impl EncodeOptions { impl EncodeOptions { /// Convenience: a preset tailored for web uploads (WhatsApp, general - /// email). JPEG quality 85 with progressive + optimize. Metadata - /// stripped (default). + /// email). JPEG quality 85. Progressive/optimize are compatibility + /// flags with no JPEG effect in the current encoder. Metadata stripped. pub fn web_preset() -> Self { Self { quality: Some(85), progressive: true, optimize: true, ..Self::default() } } diff --git a/crates/bigimage-core/src/extent.rs b/crates/bigimage-core/src/extent.rs new file mode 100644 index 0000000..c8f18f1 --- /dev/null +++ b/crates/bigimage-core/src/extent.rs @@ -0,0 +1,415 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Canvas extension pipeline — wraps the source in a larger canvas with +//! configurable background colour and gravity ("letterbox"). The dual of +//! `crop`: never removes pixels, only adds padding so the result fits a +//! target aspect ratio or explicit size. +//! +//! Driving use case is social-media posts (YouTube community 1:1, Instagram +//! feed 4:5, Stories 9:16) where the platform thumbnail forces a fixed +//! ratio. Cropping loses the corners; this lets the user keep everything +//! inside coloured bars. + +use std::path::Path; + +use image::{imageops, DynamicImage, ImageBuffer, Rgba}; + +use crate::convert::{ConvertOutcome, OverwritePolicy}; +use crate::{pipeline, BigImageError, EncodeOptions, Format, Result}; + +/// Target size, either explicit or derived from an aspect ratio applied +/// to the source dimensions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExtentSize { + /// Exact destination dimensions in pixels. + Exact { + /// Destination width. + width: u32, + /// Destination height. + height: u32, + }, + /// Aspect ratio numerator:denominator. The destination is the smallest + /// canvas of that ratio that contains the source — i.e. *extend only*, + /// never crop. + Aspect { + /// Numerator (width side of the ratio). + w: u32, + /// Denominator (height side of the ratio). + h: u32, + }, +} + +impl ExtentSize { + /// Resolve to concrete `(width, height)` against a `(src_w, src_h)`. + /// Validates that the resolved canvas can contain the source — extent + /// is an extend-only operation; smaller targets are rejected so users + /// don't silently lose pixels. + pub fn resolve(self, src_w: u32, src_h: u32) -> Result<(u32, u32)> { + if src_w == 0 || src_h == 0 { + return Err(BigImageError::InvalidInput(format!( + "extent: imagem origem com dimensão zero ({src_w}x{src_h})" + ))); + } + match self { + ExtentSize::Exact { width, height } => { + if width == 0 || height == 0 { + return Err(BigImageError::InvalidInput(format!( + "extent: tamanho destino com dimensão zero ({width}x{height})" + ))); + } + if width < src_w || height < src_h { + return Err(BigImageError::InvalidInput(format!( + "extent: destino ({width}x{height}) menor que origem ({src_w}x{src_h}); \ + a operação só estende a tela, use `crop` para recortar" + ))); + } + Ok((width, height)) + } + ExtentSize::Aspect { w, h } => { + if w == 0 || h == 0 { + return Err(BigImageError::InvalidInput(format!( + "extent: razão com componente zero ({w}:{h})" + ))); + } + // Compare src_w/src_h against w/h via cross product to stay + // in integer space (avoids float rounding for clean ratios). + let lhs = u64::from(src_w) * u64::from(h); + let rhs = u64::from(src_h) * u64::from(w); + if lhs >= rhs { + // Source is at least as wide as the target ratio: keep + // width, grow height. lhs/w gives src_w*h/w; cast OK + // because the quotient is bounded by src_h * (lhs/rhs) + // which fits u32 for any sane image. + let new_h_u64 = lhs / u64::from(w); + let new_h = u32::try_from(new_h_u64).map_err(|_| { + BigImageError::InvalidInput(format!( + "extent: altura calculada excede u32 ({new_h_u64})" + )) + })?; + Ok((src_w, new_h.max(src_h))) + } else { + let new_w_u64 = rhs / u64::from(h); + let new_w = u32::try_from(new_w_u64).map_err(|_| { + BigImageError::InvalidInput(format!( + "extent: largura calculada excede u32 ({new_w_u64})" + )) + })?; + Ok((new_w.max(src_w), src_h)) + } + } + } + } +} + +/// Where the source sits on the extended canvas. Same nine positions +/// ImageMagick exposes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Gravity { + /// Top-left corner. + NorthWest, + /// Centered horizontally, top edge. + North, + /// Top-right corner. + NorthEast, + /// Left edge, centered vertically. + West, + /// Centered both ways. + #[default] + Center, + /// Right edge, centered vertically. + East, + /// Bottom-left corner. + SouthWest, + /// Centered horizontally, bottom edge. + South, + /// Bottom-right corner. + SouthEast, +} + +impl Gravity { + /// Top-left offset of the source `(src_w x src_h)` on a destination + /// `(dst_w x dst_h)` canvas. Assumes dst >= src on both axes (caller + /// must validate via [`ExtentSize::resolve`]). + fn place(self, src_w: u32, src_h: u32, dst_w: u32, dst_h: u32) -> (u32, u32) { + let dx_center = dst_w.saturating_sub(src_w) / 2; + let dy_center = dst_h.saturating_sub(src_h) / 2; + let dx_right = dst_w.saturating_sub(src_w); + let dy_bottom = dst_h.saturating_sub(src_h); + match self { + Gravity::NorthWest => (0, 0), + Gravity::North => (dx_center, 0), + Gravity::NorthEast => (dx_right, 0), + Gravity::West => (0, dy_center), + Gravity::Center => (dx_center, dy_center), + Gravity::East => (dx_right, dy_center), + Gravity::SouthWest => (0, dy_bottom), + Gravity::South => (dx_center, dy_bottom), + Gravity::SouthEast => (dx_right, dy_bottom), + } + } +} + +/// Full set of options for an extent operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ExtentOptions { + /// How destination dimensions are derived. + pub size: ExtentSize, + /// Background colour as RGBA. Alpha respected on formats that support + /// it (PNG/WebP/AVIF); flattened on opaque formats (JPG). + pub background: [u8; 4], + /// Where the source sits on the extended canvas. + pub gravity: Gravity, +} + +impl ExtentOptions { + /// Convenience constructor with sensible defaults (center gravity, + /// solid black background). + pub fn new(size: ExtentSize) -> Self { + Self { size, background: [0, 0, 0, 0xFF], gravity: Gravity::Center } + } + + /// Builder: change background colour. + #[must_use] + pub fn with_background(mut self, rgba: [u8; 4]) -> Self { + self.background = rgba; + self + } + + /// Builder: change gravity. + #[must_use] + pub fn with_gravity(mut self, gravity: Gravity) -> Self { + self.gravity = gravity; + self + } +} + +/// Parse a hex colour string (`#RRGGBB` or `#RRGGBBAA`, with or without +/// leading `#`) into RGBA bytes. Returns a user-friendly error string for +/// CLI/GUI surfacing. +pub fn parse_hex_color(s: &str) -> std::result::Result<[u8; 4], String> { + let stripped = s.strip_prefix('#').unwrap_or(s); + let bytes: Vec = match stripped.len() { + 6 | 8 => stripped + .as_bytes() + .chunks(2) + .map(|c| { + let pair = std::str::from_utf8(c).map_err(|_| ())?; + u8::from_str_radix(pair, 16).map_err(|_| ()) + }) + .collect::, ()>>() + .map_err(|_| format!("cor hex inválida: {s:?}; use dígitos 0-9 a-f"))?, + _ => { + return Err(format!( + "cor hex inválida: {s:?}; use #RRGGBB ou #RRGGBBAA (6 ou 8 dígitos)" + )); + } + }; + let alpha = if bytes.len() == 4 { bytes[3] } else { 0xFF }; + Ok([bytes[0], bytes[1], bytes[2], alpha]) +} + +/// Extend the canvas of a file and write it next to the source. +pub fn extent_file( + input: impl AsRef, + opts: ExtentOptions, + target: Option, + policy: OverwritePolicy, +) -> Result { + let input = input.as_ref(); + + let (src, src_format) = pipeline::decode_with_source_format(input)?; + let target = target.unwrap_or(src_format); + + let (nw, nh) = opts.size.resolve(src.width(), src.height())?; + let suffix = filename_suffix(nw, nh); + let output = pipeline::resolve_output(input, Some(&suffix), target, policy)?; + + if matches!(policy, OverwritePolicy::Skip) && output.exists() { + tracing::debug!(?output, "extent: skipping existing output"); + return Ok(ConvertOutcome::Skipped { output }); + } + + tracing::debug!(?input, ?output, ?target, ?opts, "extent: computing"); + let extended = apply_to(&src, &opts)?; + pipeline::encode_and_cleanup(extended, output, target, &EncodeOptions::default(), policy) +} + +/// Pure in-memory extent — shared with the preview pipeline. +pub fn apply_to(src: &DynamicImage, opts: &ExtentOptions) -> Result { + let (sw, sh) = (src.width(), src.height()); + let (nw, nh) = opts.size.resolve(sw, sh)?; + + pipeline::check_dimensions(nw, nh, 4)?; + let (dx, dy) = opts.gravity.place(sw, sh, nw, nh); + + let bg = Rgba(opts.background); + let mut canvas: ImageBuffer, Vec> = ImageBuffer::from_pixel(nw, nh, bg); + + // Normalise source to RGBA so the overlay step always agrees on + // pixel format. Costs a copy but keeps the placement code trivial + // and correct for paletted/luminance inputs. + let src_rgba = src.to_rgba8(); + imageops::overlay(&mut canvas, &src_rgba, i64::from(dx), i64::from(dy)); + + Ok(DynamicImage::ImageRgba8(canvas)) +} + +fn filename_suffix(width: u32, height: u32) -> String { + format!("_extent-{width}x{height}") +} + +#[cfg(test)] +mod tests { + use super::*; + use image::{Rgb, RgbImage}; + use std::path::{Path, PathBuf}; + use tempfile::TempDir; + + fn write_fixture(dir: &Path, name: &str, w: u32, h: u32, fill: [u8; 3]) -> PathBuf { + let path = dir.join(name); + let img = RgbImage::from_pixel(w, h, Rgb(fill)); + img.save(&path).unwrap(); + path + } + + #[test] + fn parse_hex_accepts_6_and_8_digit_forms() { + assert_eq!(parse_hex_color("#0d0e12").unwrap(), [0x0d, 0x0e, 0x12, 0xFF]); + assert_eq!(parse_hex_color("0d0e12").unwrap(), [0x0d, 0x0e, 0x12, 0xFF]); + assert_eq!(parse_hex_color("#00000080").unwrap(), [0, 0, 0, 0x80]); + } + + #[test] + fn parse_hex_rejects_bad_input() { + assert!(parse_hex_color("#zzz").is_err()); + assert!(parse_hex_color("#12345").is_err()); + assert!(parse_hex_color("notacolor").is_err()); + } + + #[test] + fn aspect_ratio_landscape_to_square_grows_height() { + // 1920x1080 (16:9) → 1:1 should produce 1920x1920. + let size = ExtentSize::Aspect { w: 1, h: 1 }; + assert_eq!(size.resolve(1920, 1080).unwrap(), (1920, 1920)); + } + + #[test] + fn aspect_ratio_landscape_to_portrait_grows_height_more() { + // 1920x1080 (16:9) → 9:16 should produce 1920 × (1920*16/9) = 3413. + let size = ExtentSize::Aspect { w: 9, h: 16 }; + let (w, h) = size.resolve(1920, 1080).unwrap(); + assert_eq!(w, 1920); + assert_eq!(h, 3413); + } + + #[test] + fn aspect_ratio_already_matches_returns_source_dims() { + // 1920x1080 is exactly 16:9. + let size = ExtentSize::Aspect { w: 16, h: 9 }; + assert_eq!(size.resolve(1920, 1080).unwrap(), (1920, 1080)); + } + + #[test] + fn aspect_ratio_portrait_to_square_grows_width() { + // 1080x1920 (9:16) → 1:1 → 1920x1920. + let size = ExtentSize::Aspect { w: 1, h: 1 }; + assert_eq!(size.resolve(1080, 1920).unwrap(), (1920, 1920)); + } + + #[test] + fn exact_size_smaller_than_source_is_rejected() { + let size = ExtentSize::Exact { width: 100, height: 100 }; + let err = size.resolve(200, 200).unwrap_err(); + assert!(matches!(err, BigImageError::InvalidInput(_))); + } + + #[test] + fn exact_size_equal_to_source_passes_through() { + let size = ExtentSize::Exact { width: 200, height: 200 }; + assert_eq!(size.resolve(200, 200).unwrap(), (200, 200)); + } + + #[test] + fn zero_dimensions_are_rejected() { + assert!(ExtentSize::Exact { width: 0, height: 100 }.resolve(50, 50).is_err()); + assert!(ExtentSize::Aspect { w: 0, h: 1 }.resolve(50, 50).is_err()); + assert!(ExtentSize::Exact { width: 100, height: 100 }.resolve(0, 50).is_err()); + } + + #[test] + fn gravity_center_centers_source_on_canvas() { + assert_eq!(Gravity::Center.place(100, 100, 300, 300), (100, 100)); + } + + #[test] + fn gravity_corners_pin_to_corners() { + assert_eq!(Gravity::NorthWest.place(100, 100, 300, 300), (0, 0)); + assert_eq!(Gravity::NorthEast.place(100, 100, 300, 300), (200, 0)); + assert_eq!(Gravity::SouthWest.place(100, 100, 300, 300), (0, 200)); + assert_eq!(Gravity::SouthEast.place(100, 100, 300, 300), (200, 200)); + } + + #[test] + fn gravity_cardinals_center_one_axis() { + assert_eq!(Gravity::North.place(100, 100, 300, 300), (100, 0)); + assert_eq!(Gravity::South.place(100, 100, 300, 300), (100, 200)); + assert_eq!(Gravity::East.place(100, 100, 300, 300), (200, 100)); + assert_eq!(Gravity::West.place(100, 100, 300, 300), (0, 100)); + } + + #[test] + fn apply_letterbox_keeps_source_pixels_and_fills_background() { + let dir = TempDir::new().unwrap(); + let src = write_fixture(dir.path(), "src.png", 4, 2, [200, 100, 50]); + let img = image::open(&src).unwrap(); + // Extend to 8x6 with red background, source centered → src lives + // at (2, 2)..(6, 4); rest is red. + let opts = ExtentOptions { + size: ExtentSize::Exact { width: 8, height: 6 }, + background: [255, 0, 0, 0xFF], + gravity: Gravity::Center, + }; + let out = apply_to(&img, &opts).unwrap(); + let rgba = out.to_rgba8(); + assert_eq!(rgba.dimensions(), (8, 6)); + // Corner pixel is bg. + assert_eq!(rgba.get_pixel(0, 0).0, [255, 0, 0, 0xFF]); + // Center pixel is from source. + assert_eq!(rgba.get_pixel(3, 3).0, [200, 100, 50, 0xFF]); + } + + #[test] + fn extent_file_writes_letterboxed_output() { + let dir = TempDir::new().unwrap(); + let src = write_fixture(dir.path(), "in.png", 10, 5, [128, 128, 128]); + let opts = ExtentOptions { + size: ExtentSize::Aspect { w: 1, h: 1 }, + background: [0, 0, 0, 0xFF], + gravity: Gravity::Center, + }; + let out = match extent_file(&src, opts, None, OverwritePolicy::Replace).unwrap() { + ConvertOutcome::Written { output } => output, + _ => panic!("expected write"), + }; + assert_eq!(out, dir.path().join("in_extent-10x10.png")); + let img = image::open(&out).unwrap(); + assert_eq!((img.width(), img.height()), (10, 10)); + } + + #[test] + fn aspect_with_alpha_background_preserves_alpha() { + let dir = TempDir::new().unwrap(); + let src = write_fixture(dir.path(), "a.png", 4, 4, [255, 255, 255]); + let img = image::open(&src).unwrap(); + let opts = ExtentOptions { + size: ExtentSize::Exact { width: 8, height: 8 }, + background: [0, 0, 0, 0], + gravity: Gravity::Center, + }; + let out = apply_to(&img, &opts).unwrap(); + let rgba = out.to_rgba8(); + // Top-left is transparent bg. + assert_eq!(rgba.get_pixel(0, 0).0[3], 0); + // Source pixel sits at (2, 2) and is opaque. + assert_eq!(rgba.get_pixel(3, 3).0[3], 0xFF); + } +} diff --git a/crates/bigimage-core/src/flip.rs b/crates/bigimage-core/src/flip.rs index becd7a2..de518d1 100644 --- a/crates/bigimage-core/src/flip.rs +++ b/crates/bigimage-core/src/flip.rs @@ -47,9 +47,7 @@ pub fn flip_file( tracing::debug!(?input, ?output, ?target, ?axis, "flip: computing"); let flipped = apply_to(&src, axis); - let output = - pipeline::encode_and_cleanup(flipped, output, target, &crate::EncodeOptions::default())?; - Ok(ConvertOutcome::Written { output }) + pipeline::encode_and_cleanup(flipped, output, target, &crate::EncodeOptions::default(), policy) } /// Pure in-memory flip — shared with preview pipeline. diff --git a/crates/bigimage-core/src/format.rs b/crates/bigimage-core/src/format.rs index e4cb3fc..d42027b 100644 --- a/crates/bigimage-core/src/format.rs +++ b/crates/bigimage-core/src/format.rs @@ -1,15 +1,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later //! Target encoding formats and their metadata. //! -//! Three tiers of support (see PLAN.md §3.2): -//! -//! - **Tier 1** — pure Rust + one system lib (libdav1d for AVIF decode). Always -//! on: PNG, JPEG, WebP, TIFF, BMP, GIF, ICO, PNM, TGA, QOI, HDR, OpenEXR, AVIF. -//! - **Tier 2** — opt-in behind Cargo features, requires C system libs or extra -//! tooling: HEIC/HEIF (`heic`), JPEG XL (`jxl`), camera RAW (`raw`). Feature -//! flags are reserved in `Cargo.toml`; decoders land in a follow-up iteration. -//! - **Tier 3** — exotic read-only formats (PSD, XCF, KRA, SVG vector, PDF -//! multi-page). Roadmap only. +//! The enum is the single source of truth for formats the current build can +//! actually decode and encode. Future formats are added only with a working +//! implementation, so feature discovery and desktop metadata stay honest. use image::ImageFormat; @@ -110,7 +104,7 @@ impl Format { /// Map an [`ImageFormat`] back to one of our variants. Returns `None` if /// the `image` crate surfaced a format we haven't promoted here yet. - pub(crate) fn from_image_format(fmt: ImageFormat) -> Option { + pub fn from_image_format(fmt: ImageFormat) -> Option { Some(match fmt { ImageFormat::Png => Format::Png, ImageFormat::Jpeg => Format::Jpeg, diff --git a/crates/bigimage-core/src/lib.rs b/crates/bigimage-core/src/lib.rs index c9be8fb..bda9c37 100644 --- a/crates/bigimage-core/src/lib.rs +++ b/crates/bigimage-core/src/lib.rs @@ -14,15 +14,21 @@ use thiserror::Error; pub mod adjust; pub mod animate; +pub mod backend; +pub mod color; +mod container_bounds; +pub mod content; mod convert; pub mod crop; mod encode; +pub mod extent; pub mod flip; mod format; pub mod metadata; mod pipeline; pub mod preview; pub mod resize; +pub mod resources; pub mod rotate; pub use adjust::{adjust_file, AdjustOps}; @@ -30,6 +36,7 @@ pub use animate::{make_gif, AnimateOptions, LoopMode}; pub use convert::{convert_file, convert_file_to, ConvertOutcome, OverwritePolicy}; pub use crop::{crop_file, CropRect}; pub use encode::EncodeOptions; +pub use extent::{extent_file, parse_hex_color, ExtentOptions, ExtentSize, Gravity}; pub use flip::{flip_file, FlipAxis}; pub use format::Format; pub use metadata::Metadata; @@ -52,13 +59,29 @@ pub fn version() -> &'static str { /// valid PNG/JPEG/etc. Sniffing the header avoids the false negative; on a /// well-named file it costs the same. pub fn open_image(path: impl AsRef) -> image::ImageResult { - image::ImageReader::open(path.as_ref())?.with_guessed_format()?.decode() + let path = path.as_ref(); + pipeline::decode_image(path).map(|(img, _)| img) } /// Read just the dimensions of an image, dispatching the decoder by content. /// See [`open_image`] for why this matters. pub fn image_dimensions(path: impl AsRef) -> image::ImageResult<(u32, u32)> { - image::ImageReader::open(path.as_ref())?.with_guessed_format()?.into_dimensions() + if let Some(backend) = backend::configured() { + return (backend.dimensions)(path.as_ref()); + } + pipeline::bounded_dimensions(path.as_ref()) +} + +/// Encode pixels atomically with shared color, format and privacy policy. +/// This is also used by the viewer and AI outputs instead of ad-hoc writes. +pub fn save_image( + image: image::DynamicImage, + output: impl AsRef, + target: Format, + options: &EncodeOptions, + policy: OverwritePolicy, +) -> Result { + pipeline::encode_and_cleanup(image, output.as_ref().to_path_buf(), target, options, policy) } /// Errors returned by core pipelines. diff --git a/crates/bigimage-core/src/metadata.rs b/crates/bigimage-core/src/metadata.rs index b2023e4..0d9be3c 100644 --- a/crates/bigimage-core/src/metadata.rs +++ b/crates/bigimage-core/src/metadata.rs @@ -15,6 +15,7 @@ use crate::{BigImageError, Result}; /// Everything we extracted from the image's EXIF segment. #[derive(Debug, Clone, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Metadata { /// Human-readable `(name, value)` pairs, ready to show in a sidebar. pub tags: Vec<(String, String)>, @@ -39,7 +40,19 @@ impl Metadata { /// non-camera sources) — the caller decides whether that's notable. pub fn read(path: impl AsRef) -> Result { let path = path.as_ref(); - let file = std::fs::File::open(path).map_err(BigImageError::Io)?; + if let Some(backend) = crate::backend::configured() { + return (backend.metadata)(path); + } + use std::os::unix::fs::OpenOptionsExt; + let file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(rustix::fs::OFlags::NONBLOCK.bits() as i32) + .open(path) + .map_err(BigImageError::Io)?; + let info = file.metadata().map_err(BigImageError::Io)?; + if !info.is_file() || info.len() > 1024 * 1024 * 1024 { + return Err(BigImageError::InvalidInput("entrada de metadados inválida".into())); + } let mut reader = std::io::BufReader::new(file); let exif_reader = exif::Reader::new(); diff --git a/crates/bigimage-core/src/pipeline.rs b/crates/bigimage-core/src/pipeline.rs index 8f6bf29..c218b00 100644 --- a/crates/bigimage-core/src/pipeline.rs +++ b/crates/bigimage-core/src/pipeline.rs @@ -12,90 +12,144 @@ use crate::convert::OverwritePolicy; use crate::encode::EncodeOptions; use crate::{BigImageError, Format, Result}; -/// Hard cap on input file size before we even open the decoder. Beyond this -/// no normal photo workflow makes sense, and a malicious file (decode bomb, -/// ZIP-style amplification) would only cost more memory. 1 GiB is generous -/// enough for RAW or large EXR while still bounding the worst case. +/// Resource bounds shared by the viewer and all transforms. Decoder scratch +/// allocations are best-effort; the decoded buffer is checked explicitly. const MAX_INPUT_BYTES: u64 = 1024 * 1024 * 1024; - -/// Hard cap on decoded pixel count. 256 megapixels covers any consumer -/// camera (16384×16384 ≈ 268 MP) and most stitched panoramas; beyond it, -/// expanding to RGBA8 would allocate >1 GiB and risk DoS. Refused before -/// `decode()` so the malicious input never gets decompressed at all. const MAX_PIXELS: u64 = 256 * 1024 * 1024; +pub(crate) const MAX_BUFFER_BYTES: u64 = 512 * 1024 * 1024; -/// Open `input`, identify its source format, and decode. Returns the decoded -/// image alongside the format tag so callers can preserve the source encoding -/// when the user didn't pick a target. -/// -/// Refuses inputs that exceed [`MAX_INPUT_BYTES`] (file size) or -/// [`MAX_PIXELS`] (decoded dimensions), turning what would otherwise be -/// silent OOMs / multi-GB allocations into a clean error early in the -/// pipeline. The dimension check uses [`ImageReader::into_dimensions`] -/// which only parses the format header — no decompression. -pub(crate) fn decode_with_source_format(input: &Path) -> Result<(DynamicImage, Format)> { - // Cheap first guard: file size from the FS metadata. Catches the - // "attacker streamed 50 GB of garbage" case without touching the - // decoder. - let bytes = std::fs::metadata(input).map_err(BigImageError::Io)?.len(); - if bytes > MAX_INPUT_BYTES { +fn limit_error() -> image::ImageError { + image::ImageError::Limits(image::error::LimitError::from_kind( + image::error::LimitErrorKind::InsufficientMemory, + )) +} + +/// Reject oversized transformed buffers before allocating them. +pub(crate) fn check_dimensions(w: u32, h: u32, bytes_per_pixel: u8) -> Result<()> { + let pixels = u64::from(w) * u64::from(h); + if w == 0 + || h == 0 + || pixels > MAX_PIXELS + || pixels.saturating_mul(u64::from(bytes_per_pixel)) > MAX_BUFFER_BYTES + { return Err(BigImageError::InvalidInput(format!( - "{} excede o limite de entrada ({} MB > {} MB)", - input.display(), - bytes / (1024 * 1024), - MAX_INPUT_BYTES / (1024 * 1024), + "dimensões {w}×{h} excedem os limites de processamento" ))); } + Ok(()) +} - // Peek dimensions before allocating a full decoded buffer. Refuses - // pixel bombs (e.g. a tiny PNG header claiming 100k × 100k) before the - // decoder ever expands them in memory. - let dims_reader = ImageReader::open(input) - .map_err(BigImageError::Io)? - .with_guessed_format() - .map_err(BigImageError::Io)?; - let (w, h) = dims_reader - .into_dimensions() - .map_err(|e| BigImageError::Decode { path: input.to_path_buf(), source: e })?; - let pixels = u64::from(w).saturating_mul(u64::from(h)); - if pixels > MAX_PIXELS { - return Err(BigImageError::InvalidInput(format!( - "{} excede o limite de pixels ({}×{} = {} MP > {} MP)", - input.display(), - w, - h, - pixels / (1024 * 1024), - MAX_PIXELS / (1024 * 1024), - ))); +pub(crate) fn bounded_reader( + input: &Path, +) -> image::ImageResult>> { + use std::io::BufReader; + // Refuse special files before open, so accidental FIFO input does not block. + let meta = std::fs::metadata(input)?; + if !meta.is_file() || meta.len() > MAX_INPUT_BYTES { + return Err(limit_error()); + } + use std::os::unix::fs::OpenOptionsExt; + let file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(rustix::fs::OFlags::NONBLOCK.bits() as i32) + .open(input)?; + let metadata = file.metadata()?; + if !metadata.is_file() || metadata.len() > MAX_INPUT_BYTES { + return Err(limit_error()); + } + let mut reader = ImageReader::new(BufReader::new(file)).with_guessed_format()?; + if reader.format().is_none() { + if let Some(format) = input.extension().and_then(image::ImageFormat::from_extension) { + reader.set_format(format); + } + } + if reader.format() == Some(image::ImageFormat::Avif) { + use std::io::{Seek, SeekFrom}; + let mut data = reader.into_inner(); + crate::container_bounds::inspect(&mut data)?; + data.seek(SeekFrom::Start(0))?; + reader = ImageReader::with_format(data, image::ImageFormat::Avif); + } + let mut limits = image::Limits::default(); + limits.max_alloc = Some(MAX_BUFFER_BYTES); + reader.limits(limits); + Ok(reader) +} + +pub(crate) fn bounded_dimensions(input: &Path) -> image::ImageResult<(u32, u32)> { + use image::ImageDecoder; + let decoder = bounded_reader(input)?.into_decoder()?; + let (w, h) = decoder.dimensions(); + if w == 0 + || h == 0 + || u64::from(w) * u64::from(h) > MAX_PIXELS + || decoder.total_bytes() > MAX_BUFFER_BYTES + { + return Err(limit_error()); } + Ok((w, h)) +} - // Re-open for the actual decode. Cheap because the file is already in - // the page cache from the dimension peek. - let reader = ImageReader::open(input) - .map_err(BigImageError::Io)? - .with_guessed_format() - .map_err(BigImageError::Io)?; +/// Decode using the same open descriptor for header and pixel data. +pub(crate) fn decode_image(input: &Path) -> image::ImageResult<(DynamicImage, Option)> { + if let Some(backend) = crate::backend::configured() { + return (backend.decode)(input); + } + decode_local(input) +} + +pub(crate) fn decode_local(input: &Path) -> image::ImageResult<(DynamicImage, Option)> { + use image::ImageDecoder; + let mut reader = bounded_reader(input)?; + let format = reader.format().and_then(Format::from_image_format); + let mut limits = image::Limits::default(); + limits.max_alloc = Some(MAX_BUFFER_BYTES); + if format == Some(Format::Gif) { + use std::io::{Seek, SeekFrom}; + let mut data = reader.into_inner(); + crate::content::inspect_gif(&mut data)?; + data.seek(SeekFrom::Start(0))?; + reader = ImageReader::with_format(data, image::ImageFormat::Gif); + reader.limits(limits.clone()); + } + let mut decoder = reader.into_decoder()?; + let (w, h) = decoder.dimensions(); + if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS { + return Err(limit_error()); + } + limits.reserve(decoder.total_bytes())?; + decoder.set_limits(limits)?; + let orientation = decoder.orientation().unwrap_or(image::metadata::Orientation::NoTransforms); + let icc = decoder.icc_profile()?; + let mut image = DynamicImage::from_decoder(decoder)?; + image.apply_orientation(orientation); + if let Some(icc) = icc.filter(|profile| !profile.is_empty()) { + crate::color::normalize_srgb(&mut image, &icc)?; + } + Ok((image, format)) +} - let src_format = reader.format().and_then(Format::from_image_format).ok_or_else(|| { +/// Open and decode an upright image with shared resource bounds. +pub(crate) fn decode_with_source_format(input: &Path) -> Result<(DynamicImage, Format)> { + crate::content::ensure_static(input)?; + let (img, format) = decode_image(input) + .map_err(|source| BigImageError::Decode { path: input.to_path_buf(), source })?; + let format = format.ok_or_else(|| { BigImageError::UnsupportedFormat(format!( "entrada sem formato reconhecido: {}", input.display() )) })?; - - let img = reader - .decode() - .map_err(|e| BigImageError::Decode { path: input.to_path_buf(), source: e })?; - - Ok((img, src_format)) + Ok((img, format)) } /// Compute the output path. /// /// * `suffix`: optional tag appended to the stem (e.g. `"_1080"`, `"_rot90"`). /// `None` means "same stem" (convert's behaviour). -/// * `policy`: applied only when it's `Increment` and the naive path is taken; -/// caller still decides whether to re-check `.exists()` for `Skip`. +/// +/// The overwrite policy is enforced atomically at publication. Callers may +/// still short-circuit `Skip` before decoding an existing destination. pub(crate) fn resolve_output( input: &Path, suffix: Option<&str>, @@ -130,127 +184,151 @@ pub(crate) fn resolve_output_to( parent.join(name) }; - if !matches!(policy, OverwritePolicy::Increment) || !naive.exists() { - return Ok(naive); - } + let _ = policy; + Ok(naive) +} - for n in 1u32..=9_999 { - let mut name = std::ffi::OsString::from(stem); - name.push(suffix); - name.push(format!("_{n}.")); - name.push(ext); - let candidate = parent.join(name); - if !candidate.exists() { - return Ok(candidate); +/// A unique sibling temporary file, cleaned up on every error path. +pub(crate) fn output_temp(output: &Path) -> Result { + let parent = output.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); + std::fs::create_dir_all(parent).map_err(BigImageError::Io)?; + tempfile::Builder::new() + .prefix(".bigiris-") + .suffix(".part") + .tempfile_in(parent) + .map_err(BigImageError::Io) +} + +/// Publish a complete file atomically. Skip/Increment never overwrite an +/// existing destination, even when other threads or processes race us. +pub(crate) fn publish_temp( + temp: tempfile::NamedTempFile, + output: PathBuf, + policy: OverwritePolicy, +) -> Result { + temp.as_file().sync_all().map_err(BigImageError::Io)?; + if matches!(policy, OverwritePolicy::Replace) { + if let Ok(meta) = std::fs::symlink_metadata(&output) { + if meta.is_file() { + temp.as_file().set_permissions(meta.permissions()).map_err(BigImageError::Io)?; + } } + temp.persist(&output).map_err(|e| BigImageError::Io(e.error))?; + return Ok(crate::ConvertOutcome::Written { output }); } - Err(BigImageError::Other(format!("too many existing siblings of {naive:?}"))) + let mut temp = temp; + let mut candidate = output.clone(); + for n in 0..=9_999u32 { + if n > 0 { + let mut name = output.file_stem().unwrap_or_default().to_os_string(); + name.push(format!("_{n}")); + if let Some(ext) = output.extension() { + name.push("."); + name.push(ext); + } + candidate = output.with_file_name(name); + } + match temp.persist_noclobber(&candidate) { + Ok(_) => return Ok(crate::ConvertOutcome::Written { output: candidate }), + Err(e) if e.error.kind() == std::io::ErrorKind::AlreadyExists => { + if matches!(policy, OverwritePolicy::Skip) { + return Ok(crate::ConvertOutcome::Skipped { output }); + } + temp = e.file; + } + Err(e) => return Err(BigImageError::Io(e.error)), + } + } + Err(BigImageError::Other(format!("muitos arquivos com o mesmo nome: {}", output.display()))) } -/// Encode `img` to `output` using `target`, coercing colour depth if the -/// encoder is picky (see [`prepare_for_target`]) and removing any half-written -/// file on failure. -/// -/// `opts` controls quality and optimise knobs for formats that honour them -/// (currently JPEG quality and PNG compression level). Formats with no -/// tunable parameters ignore `opts` entirely. -/// -/// [`prepare_for_target`]: crate::convert::prepare_for_target +/// Encode into a unique sibling and publish only after encode, flush and sync. pub(crate) fn encode_and_cleanup( img: DynamicImage, output: PathBuf, target: Format, opts: &EncodeOptions, -) -> Result { + policy: OverwritePolicy, +) -> Result { + use std::io::Write; + let destination_bpp = match target { + Format::OpenExr => 16, + Format::Hdr => 12, + _ => img.color().bytes_per_pixel().max(4), + }; + check_dimensions(img.width(), img.height(), destination_bpp)?; let prepared = crate::convert::prepare_for_target(img, target); - - if let Some(parent) = output.parent() { - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent).map_err(BigImageError::Io)?; - } - } - - let result = encode_to_path(&prepared, &output, target, opts); - if let Err(e) = result { - let _ = std::fs::remove_file(&output); - return Err(BigImageError::Encode { path: output, source: e }); + let mut temp = output_temp(&output)?; + { + let mut writer = std::io::BufWriter::new(temp.as_file_mut()); + encode_to_writer(&prepared, &mut writer, target, opts) + .map_err(|source| BigImageError::Encode { path: output.clone(), source })?; + writer.flush().map_err(BigImageError::Io)?; } - - Ok(output) + publish_temp(temp, output, policy) } -/// Do the actual write, picking a format-specific encoder when we need to -/// honour `opts`, and falling back to `save_with_format` for formats where -/// `image` gives us no extra knobs. -fn encode_to_path( +fn encode_to_writer( img: &DynamicImage, - output: &Path, + writer: &mut (impl std::io::Write + std::io::Seek), target: Format, opts: &EncodeOptions, -) -> std::result::Result<(), image::ImageError> { +) -> image::ImageResult<()> { + use image::ImageEncoder; match target { - Format::Jpeg if opts.quality.is_some() => { - // Custom quality path — bypass save_with_format's default 75 - // and emit via a manual encoder call instead. - use image::codecs::jpeg::JpegEncoder; - use image::ImageEncoder; - let file = std::fs::File::create(output)?; - let mut writer = std::io::BufWriter::new(file); - let quality = opts.jpeg_quality(); - let encoder = JpegEncoder::new_with_quality(&mut writer, quality); + Format::Jpeg => { + let mut encoder = + image::codecs::jpeg::JpegEncoder::new_with_quality(writer, opts.jpeg_quality()); + encoder + .set_icc_profile(crate::color::srgb_profile()?) + .map_err(image::ImageError::Unsupported)?; let rgb = img.to_rgb8(); encoder.write_image( rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8, - )?; - Ok(()) + ) } Format::Avif => { - // `image::save_with_format(Avif, …)` calls - // `AvifEncoder::new(writer)` which defaults to speed=4. On - // screenshots that's ~5–10× slower than speed=8, turning a - // 100-file batch into half an hour of waiting. Drive the - // encoder directly so the EncodeOptions::avif_speed knob - // (default 8, or 4 when `optimize=true`) actually reaches - // ravif. AVIF is encoded from RGBA to preserve the alpha - // channel screenshots routinely have. - use image::codecs::avif::AvifEncoder; - use image::ImageEncoder; - let file = std::fs::File::create(output)?; - let mut writer = std::io::BufWriter::new(file); - let speed = opts.effective_avif_speed(); - let quality = opts.quality.unwrap_or(80); - let encoder = AvifEncoder::new_with_speed_quality(&mut writer, speed, quality); + let encoder = image::codecs::avif::AvifEncoder::new_with_speed_quality( + writer, + opts.effective_avif_speed(), + opts.quality.unwrap_or(80), + ) + .with_num_threads(Some( + std::thread::available_parallelism() + .map(|n| n.get().saturating_sub(1).clamp(1, 2)) + .unwrap_or(1), + )); let rgba = img.to_rgba8(); encoder.write_image( rgba.as_raw(), rgba.width(), rgba.height(), image::ExtendedColorType::Rgba8, - )?; - Ok(()) + ) } - Format::Png if opts.optimize => { + Format::Png => { use image::codecs::png::{CompressionType, FilterType, PngEncoder}; - use image::ImageEncoder; - let file = std::fs::File::create(output)?; - let mut writer = std::io::BufWriter::new(file); - let encoder = PngEncoder::new_with_quality( - &mut writer, - CompressionType::Best, - FilterType::Adaptive, - ); - let rgba = img.to_rgba8(); - encoder.write_image( - rgba.as_raw(), - rgba.width(), - rgba.height(), - image::ExtendedColorType::Rgba8, - )?; - Ok(()) + let mut encoder = if opts.optimize { + PngEncoder::new_with_quality(writer, CompressionType::Best, FilterType::Adaptive) + } else { + PngEncoder::new(writer) + }; + if matches!( + img.color(), + image::ColorType::Rgb8 + | image::ColorType::Rgba8 + | image::ColorType::Rgb16 + | image::ColorType::Rgba16 + ) { + encoder + .set_icc_profile(crate::color::srgb_profile()?) + .map_err(image::ImageError::Unsupported)?; + } + encoder.write_image(img.as_bytes(), img.width(), img.height(), img.color().into()) } - _ => img.save_with_format(output, target.to_image_format()), + _ => img.write_to(writer, target.to_image_format()), } } diff --git a/crates/bigimage-core/src/preview.rs b/crates/bigimage-core/src/preview.rs index 4896724..aa54627 100644 --- a/crates/bigimage-core/src/preview.rs +++ b/crates/bigimage-core/src/preview.rs @@ -18,8 +18,8 @@ use fast_image_resize::{ResizeAlg, ResizeOptions, Resizer}; use image::DynamicImage; use crate::{ - adjust, crop, flip, pipeline, resize, rotate, AdjustOps, CropRect, Filter, FlipAxis, Format, - ResizeMode, Result, Rotation, + adjust, crop, extent, flip, pipeline, resize, rotate, AdjustOps, CropRect, ExtentOptions, + ExtentSize, Filter, FlipAxis, Format, ResizeMode, Result, Rotation, }; /// Max edge for the cached thumbnail. 800px keeps the whole preview image @@ -51,6 +51,10 @@ pub enum PreviewOp { /// Rectangular crop — validated against the source's *natural* size, /// not the thumbnail (see [`PreviewSession::apply`] for the scaling). Crop(CropRect), + /// Canvas extension with letterbox — destination size is derived + /// against the source's *natural* dimensions and then scaled into the + /// thumbnail space inside [`PreviewSession::apply`]. + Extent(ExtentOptions), /// No-op; useful when the GUI wants to show the original thumbnail. Identity, } @@ -73,6 +77,7 @@ pub struct PreviewSession { /// crop rectangles from the caller's "source pixel" coordinates into /// the thumbnail coordinates before applying the crop. thumbnail_scale: f64, + has_gps: bool, } impl PreviewSession { @@ -80,6 +85,9 @@ impl PreviewSession { /// cache everything in memory. pub fn open(input: impl AsRef) -> Result { let input = input.as_ref(); + if let Some(backend) = crate::backend::configured() { + return (backend.preview)(input); + } let (src, source_format) = pipeline::decode_with_source_format(input)?; let natural_size = (src.width(), src.height()); @@ -92,6 +100,34 @@ impl PreviewSession { source_format, thumbnail, thumbnail_scale, + has_gps: crate::metadata::has_gps(input), + }) + } + + /// Reconstruct a validated preview returned by an isolated decoder. + pub fn from_cached( + input: std::path::PathBuf, + natural_size: (u32, u32), + source_format: Format, + thumbnail: DynamicImage, + has_gps: bool, + ) -> Result { + if natural_size.0 == 0 + || natural_size.1 == 0 + || thumbnail.width() == 0 + || thumbnail.height() == 0 + || thumbnail.width() > PREVIEW_MAX_EDGE + || thumbnail.height() > PREVIEW_MAX_EDGE + { + return Err(crate::BigImageError::InvalidInput("preview inválido".into())); + } + Ok(Self { + source_path: input, + natural_size, + source_format, + thumbnail_scale: thumbnail.width() as f64 / natural_size.0 as f64, + thumbnail, + has_gps, }) } @@ -121,7 +157,9 @@ impl PreviewSession { PreviewOp::Resize { mode, filter } => { // Resize targets scale with the thumbnail so a // "50% of source" preview genuinely looks like 50%. - resize::apply_to(&self.thumbnail, *mode, *filter) + let (w, h) = mode.target_dims(self.thumbnail.width(), self.thumbnail.height())?; + let (width, height) = bounded_size(w, h); + resize::apply_to(&self.thumbnail, ResizeMode::Exact { width, height }, *filter) } PreviewOp::Rotate(r) => Ok(rotate::apply_to(&self.thumbnail, *r)), PreviewOp::Flip(a) => Ok(flip::apply_to(&self.thumbnail, *a)), @@ -129,6 +167,43 @@ impl PreviewSession { let scaled = scale_rect(*rect, self.thumbnail_scale); crop::apply_to(&self.thumbnail, scaled) } + PreviewOp::Extent(opts) => { + // The thumbnail preserves the source aspect ratio, so an + // `Aspect` extent resolved against the thumbnail picks the + // same grow-axis and yields a visually identical letterbox — + // and being extend-only it can never come out smaller than + // the thumbnail. For an `Exact` target we scale it into + // thumbnail space but clamp to the thumbnail's own size so + // the canvas always contains it (independent rounding of the + // canvas vs. the thumbnail used to drift a pixel below it and + // make `resolve` reject the preview). + let (tw, th) = (self.thumbnail.width(), self.thumbnail.height()); + let preview_opts = match opts.size { + ExtentSize::Aspect { .. } => *opts, + ExtentSize::Exact { width, height } => { + let pw = ((width as f64) * self.thumbnail_scale).round().max(f64::from(tw)) + as u32; + let ph = ((height as f64) * self.thumbnail_scale).round().max(f64::from(th)) + as u32; + ExtentOptions { size: ExtentSize::Exact { width: pw, height: ph }, ..*opts } + } + }; + let (w, h) = preview_opts.size.resolve(tw, th)?; + let (width, height) = bounded_size(w, h); + let scale = f64::from(width) / f64::from(w); + let src = resize::apply_to( + &self.thumbnail, + ResizeMode::Exact { + width: ((f64::from(tw) * scale).round() as u32).clamp(1, width), + height: ((f64::from(th) * scale).round() as u32).clamp(1, height), + }, + Filter::Lanczos3, + )?; + extent::apply_to( + &src, + &ExtentOptions { size: ExtentSize::Exact { width, height }, ..preview_opts }, + ) + } } } @@ -161,14 +236,17 @@ impl PreviewSession { /// Whether the source file has GPS coordinates in its EXIF. Used by /// the smart-prompt banner ("Esta imagem contém sua localização…") - /// in the convert / resize dialogs. Not cached, so the first call - /// opens the file a second time; subsequent ones are cheap because - /// the OS has the header in page cache. + /// in the convert / resize dialogs. Cached during background preparation. pub fn source_has_gps(&self) -> bool { - crate::metadata::has_gps(&self.source_path) + self.has_gps } } +fn bounded_size(w: u32, h: u32) -> (u32, u32) { + let scale = (f64::from(PREVIEW_MAX_EDGE) / f64::from(w.max(h))).min(1.0); + (((f64::from(w) * scale).round() as u32).max(1), ((f64::from(h) * scale).round() as u32).max(1)) +} + /// Resize `img` so the longest edge is ≤ `max_edge`; returns a clone when /// the image already fits. Uses `fast_image_resize` with Lanczos3 so the /// thumbnail is a faithful miniature, not a blocky nearest-neighbour @@ -308,6 +386,25 @@ mod tests { assert_eq!((halved.width(), halved.height()), (200, 200)); } + #[test] + fn extent_aspect_grows_preview_canvas() { + // Source 800x400 (2:1, fits the cap so thumbnail == source). + // Aspect 1:1 → final 800x800 → thumbnail-space preview 800x800. + let dir = TempDir::new().unwrap(); + let src = write_fixture(dir.path(), "e.png", 800, 400); + let sess = PreviewSession::open(&src).unwrap(); + let opts = ExtentOptions { + size: ExtentSize::Aspect { w: 1, h: 1 }, + background: [255, 0, 0, 0xFF], + gravity: crate::Gravity::Center, + }; + let out = sess.apply(&PreviewOp::Extent(opts)).unwrap(); + assert_eq!((out.width(), out.height()), (800, 800)); + // Top-row corner is bg (red), source sits centered vertically. + let rgba = out.to_rgba8(); + assert_eq!(rgba.get_pixel(0, 0).0, [255, 0, 0, 0xFF]); + } + #[test] fn crop_in_source_coords_scales_into_thumbnail() { // Source is 2000x1000 → thumbnail becomes 800x400 (scale 0.4). diff --git a/crates/bigimage-core/src/resize.rs b/crates/bigimage-core/src/resize.rs index c758ce5..374e3bb 100644 --- a/crates/bigimage-core/src/resize.rs +++ b/crates/bigimage-core/src/resize.rs @@ -48,7 +48,7 @@ impl ResizeMode { } } - fn target_dims(&self, src_w: u32, src_h: u32) -> Result<(u32, u32)> { + pub(crate) fn target_dims(&self, src_w: u32, src_h: u32) -> Result<(u32, u32)> { if src_w == 0 || src_h == 0 { return Err(BigImageError::InvalidInput(format!( "origem com dimensão zero: {src_w}x{src_h}" @@ -165,8 +165,7 @@ pub fn resize_file( tracing::debug!(?input, ?output, ?target, ?mode, ?filter, "resize: computing"); let resized = apply_to(&src, mode, filter)?; - let output = pipeline::encode_and_cleanup(resized, output, target, opts)?; - Ok(ConvertOutcome::Written { output }) + pipeline::encode_and_cleanup(resized, output, target, opts, policy) } /// Pure in-memory variant — resizes an already-decoded image and returns @@ -174,6 +173,7 @@ pub fn resize_file( /// `crate::preview`. pub fn apply_to(src: &DynamicImage, mode: ResizeMode, filter: Filter) -> Result { let (new_w, new_h) = mode.target_dims(src.width(), src.height())?; + pipeline::check_dimensions(new_w, new_h, src.color().bytes_per_pixel())?; if (new_w, new_h) == (src.width(), src.height()) { return Ok(src.clone()); } diff --git a/crates/bigimage-core/src/resources.rs b/crates/bigimage-core/src/resources.rs new file mode 100644 index 0000000..e457ad3 --- /dev/null +++ b/crates/bigimage-core/src/resources.rs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Conservative concurrency budget shared by the CLI and Prisma. +use std::path::PathBuf; + +/// Choose batch concurrency from CPUs, image dimensions and available memory. +/// Leaves half of available RAM to GNOME and other applications, uses at most +/// four jobs, and estimates 32 bytes/pixel for decoded/converted/scratch buffers. +/// This is a scheduling heuristic, not a hard process memory limit. +pub fn batch_worker_count(files: &[PathBuf]) -> usize { + let cpus = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); + let working_set = files + .iter() + .filter_map(|p| crate::image_dimensions(p).ok()) + .map(|(w, h)| (u64::from(w) * u64::from(h)).saturating_mul(32)) + .max() + .unwrap_or(512 * 1024 * 1024) + .max(64 * 1024 * 1024); + let available = std::fs::read_to_string("/proc/meminfo") + .ok() + .and_then(|s| { + s.lines().find_map(|line| { + line.strip_prefix("MemAvailable:") + .and_then(|rest| rest.split_whitespace().next()) + .and_then(|number| number.parse::().ok()) + .map(|kb| kb.saturating_mul(1024)) + }) + }) + .unwrap_or(1024 * 1024 * 1024); + budget(cpus, files.len(), available, working_set) +} + +fn budget(cpus: usize, jobs: usize, available: u64, working_set: u64) -> usize { + let memory_jobs = (available / 2 / working_set.max(1)).clamp(1, 4) as usize; + cpus.saturating_sub(1).max(1).min(jobs.max(1)).min(memory_jobs) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn budget_reserves_cpu_and_ram_and_handles_low_memory() { + assert_eq!(budget(16, 100, 4096, 1024), 2); + assert_eq!(budget(16, 100, 16384, 1024), 4); + assert_eq!(budget(2, 100, 16384, 1024), 1); + assert_eq!(budget(16, 1, 16384, 1024), 1); + assert_eq!(budget(1, 0, 0, 1024), 1); + } +} diff --git a/crates/bigimage-core/src/rotate.rs b/crates/bigimage-core/src/rotate.rs index a3f678c..85ca16a 100644 --- a/crates/bigimage-core/src/rotate.rs +++ b/crates/bigimage-core/src/rotate.rs @@ -56,9 +56,7 @@ pub fn rotate_file( tracing::debug!(?input, ?output, ?target, ?rotation, "rotate: computing"); let rotated = apply_to(&src, rotation); - let output = - pipeline::encode_and_cleanup(rotated, output, target, &crate::EncodeOptions::default())?; - Ok(ConvertOutcome::Written { output }) + pipeline::encode_and_cleanup(rotated, output, target, &crate::EncodeOptions::default(), policy) } /// Pure in-memory rotation — shared with preview pipeline. @@ -99,13 +97,14 @@ pub fn apply_exif_orientation(img: image::DynamicImage, orientation: u16) -> ima } } -/// Auto-rotate `input` according to its EXIF orientation. Reads the tag, -/// applies the right transform in memory, then uses the standard encode -/// path (which strips EXIF along the way, so the result is upright *and* -/// carries no stale orientation tag). +/// "Bake" the EXIF orientation of `input` into the pixels and write the +/// upright result with an `_auto` suffix. /// -/// Images without EXIF or with orientation `1` still get re-encoded and -/// an `_auto` suffix — harmless pass-through, keeps the return type +/// The central decode path ([`crate::pipeline::decode_with_source_format`]) +/// already applies the orientation tag, so this command no longer rotates a +/// second time — it simply materialises that upright image to disk (the +/// encode step strips the now-redundant tag). Images without EXIF or with +/// orientation `1` are a harmless re-encode, keeping the return type /// consistent for callers counting "written vs skipped". pub fn rotate_file_auto( input: impl AsRef, @@ -113,8 +112,9 @@ pub fn rotate_file_auto( policy: OverwritePolicy, ) -> Result { let input = input.as_ref(); - let orientation = crate::metadata::read(input).map(|m| m.orientation).unwrap_or(None); + // `src` arrives already upright — decode_with_source_format honours the + // orientation tag for every op. Re-applying it here would double-rotate. let (src, src_format) = crate::pipeline::decode_with_source_format(input)?; let target = target.unwrap_or(src_format); @@ -123,19 +123,8 @@ pub fn rotate_file_auto( return Ok(ConvertOutcome::Skipped { output }); } - tracing::debug!(?input, ?output, ?orientation, "rotate: auto via EXIF"); - let rotated = match orientation { - Some(n) if n != 1 => apply_exif_orientation(src, n), - _ => src, - }; - - let output = crate::pipeline::encode_and_cleanup( - rotated, - output, - target, - &crate::EncodeOptions::default(), - )?; - Ok(ConvertOutcome::Written { output }) + tracing::debug!(?input, ?output, "rotate: bake EXIF orientation"); + pipeline::encode_and_cleanup(src, output, target, &crate::EncodeOptions::default(), policy) } #[cfg(test)] @@ -228,6 +217,45 @@ mod tests { assert_eq!(dims(&out), (40, 20)); } + #[test] + fn auto_orient_bakes_exif_orientation_on_central_decode() { + // Craft a JPEG carrying EXIF Orientation=6 (90° CW) by splicing a + // minimal APP1 segment right after SOI, then confirm the central + // decode returns the *upright* image (landscape 40×20 → 20×40) — and + // that rotate_file_auto does NOT rotate a second time. + let dir = TempDir::new().unwrap(); + let base = write_fixture(dir.path(), "o.jpg", 40, 20); + let bytes = std::fs::read(&base).unwrap(); + // EXIF APP1: little-endian TIFF, single IFD0 entry Orientation=6. + let app1: &[u8] = &[ + 0xFF, 0xE1, 0x00, 0x22, // APP1 marker + length(34) + b'E', b'x', b'i', b'f', 0x00, 0x00, // "Exif\0\0" + b'I', b'I', 0x2A, 0x00, 0x08, 0x00, 0x00, 0x00, // TIFF header, IFD0 @ 8 + 0x01, 0x00, // entry count = 1 + 0x12, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, + 0x00, // tag=Orientation type=SHORT count=1 value=6 + 0x00, 0x00, 0x00, 0x00, // next IFD = none + ]; + let mut spliced = Vec::with_capacity(bytes.len() + app1.len()); + spliced.extend_from_slice(&bytes[..2]); // SOI + spliced.extend_from_slice(app1); + spliced.extend_from_slice(&bytes[2..]); + let p = dir.path().join("oriented.jpg"); + std::fs::write(&p, &spliced).unwrap(); + + // The tag is readable… + assert_eq!(crate::metadata::read(&p).unwrap().orientation, Some(6)); + // …and the central decode returns it upright (dims swapped once). + let (img, _) = crate::pipeline::decode_with_source_format(&p).unwrap(); + assert_eq!((img.width(), img.height()), (20, 40), "expected single 90° upright"); + // rotate_file_auto must not double-rotate: output stays 20×40. + let out = match rotate_file_auto(&p, Some(Format::Png), OverwritePolicy::Replace).unwrap() { + ConvertOutcome::Written { output } => output, + _ => panic!("expected write"), + }; + assert_eq!(dims(&out), (20, 40), "double-rotation regression"); + } + #[test] fn rotate_can_change_format() { let dir = TempDir::new().unwrap(); diff --git a/crates/bigimage-core/tests/fidelity.rs b/crates/bigimage-core/tests/fidelity.rs new file mode 100644 index 0000000..588b881 --- /dev/null +++ b/crates/bigimage-core/tests/fidelity.rs @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +use bigimage_core::{convert_file_to, open_image, EncodeOptions, Format, OverwritePolicy}; +use image::{DynamicImage, ImageEncoder}; + +#[test] +fn display_p3_is_normalized_and_output_srgb_does_not_round_again() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("p3.png"); + let output = dir.path().join("normalized"); + let original = DynamicImage::ImageRgba16(image::ImageBuffer::from_pixel( + 8, + 8, + image::Rgba([40000, 20000, 10000, 12345]), + )); + let mut encoder = image::codecs::png::PngEncoder::new(std::fs::File::create(&input).unwrap()); + encoder.set_icc_profile(moxcms::ColorProfile::new_display_p3().encode().unwrap()).unwrap(); + encoder.write_image(original.as_bytes(), 8, 8, original.color().into()).unwrap(); + let normalized = open_image(&input).unwrap(); + assert_ne!(&normalized.as_rgba16().unwrap().get_pixel(0, 0).0[..3], &[40000, 20000, 10000]); + assert_eq!(normalized.as_rgba16().unwrap().get_pixel(0, 0).0[3], 12345); + let written = convert_file_to( + &input, + Some(&output), + Format::Png, + &EncodeOptions::default(), + OverwritePolicy::Replace, + ) + .unwrap(); + assert_eq!(open_image(written.path()).unwrap().as_rgba16(), normalized.as_rgba16()); +} + +#[test] +fn all_eight_embedded_exif_orientations_are_applied_once() { + let dir = tempfile::tempdir().unwrap(); + let image = DynamicImage::ImageRgb8(image::RgbImage::from_fn(3, 2, |x, y| { + let v = (40 + x * 25 + y * 100) as u8; + image::Rgb([v, v, v]) + })); + let mut base = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut base, 100) + .encode_image(&image) + .unwrap(); + let raw = image::load_from_memory(&base).unwrap().to_rgb8(); + for orientation in 1..=8u8 { + let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec(); + exif.extend([orientation, 0, 0, 0, 0, 0, 0, 0]); + let mut bytes = base[..2].to_vec(); + bytes.extend([0xff, 0xe1]); + bytes.extend(((exif.len() + 2) as u16).to_be_bytes()); + bytes.extend(exif); + bytes.extend(&base[2..]); + let source = dir.path().join(format!("orientation-{orientation}.jpg")); + std::fs::write(&source, bytes).unwrap(); + let upright = open_image(&source).unwrap().to_rgb8(); + assert_eq!(upright.dimensions(), if orientation >= 5 { (2, 3) } else { (3, 2) }); + for y in 0..2 { + for x in 0..3 { + let (dx, dy) = match orientation { + 1 => (x, y), + 2 => (2 - x, y), + 3 => (2 - x, 1 - y), + 4 => (x, 1 - y), + 5 => (y, x), + 6 => (1 - y, x), + 7 => (1 - y, 2 - x), + 8 => (y, 2 - x), + _ => unreachable!(), + }; + assert_eq!( + upright.get_pixel(dx, dy), + raw.get_pixel(x, y), + "orientation {orientation}" + ); + } + } + let output = dir.path().join(format!("normalized-{orientation}.png")); + let written = convert_file_to( + &source, + Some(&output), + Format::Png, + &EncodeOptions::default(), + OverwritePolicy::Replace, + ) + .unwrap(); + assert_eq!(open_image(written.path()).unwrap().to_rgb8(), upright); + } +} + +#[test] +fn static_edits_refuse_animated_gif_and_preserve_destination() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("animated.gif"); + let output = dir.path().join("keep.png"); + let mut encoder = image::codecs::gif::GifEncoder::new(std::fs::File::create(&input).unwrap()); + for v in [50, 150] { + encoder + .encode_frame(image::Frame::new(image::RgbaImage::from_pixel( + 2, + 2, + image::Rgba([v, 0, 0, 255]), + ))) + .unwrap(); + } + drop(encoder); + assert!(open_image(&input).is_ok(), "viewer can still inspect the first frame"); + std::fs::write(&output, b"previous file").unwrap(); + assert!(convert_file_to( + &input, + Some(&output), + Format::Png, + &EncodeOptions::default(), + OverwritePolicy::Replace + ) + .is_err()); + assert_eq!(std::fs::read(output).unwrap(), b"previous file"); +} + +#[test] +fn preview_huge_resize_and_canvas_remain_bounded() { + use bigimage_core::{ExtentOptions, ExtentSize, Filter, PreviewOp, PreviewSession, ResizeMode}; + let source = DynamicImage::new_rgb8(800, 600); + let session = + PreviewSession::from_cached("source.png".into(), (800, 600), Format::Png, source, false) + .unwrap(); + for op in [ + PreviewOp::Resize { + mode: ResizeMode::Exact { width: u32::MAX, height: u32::MAX }, + filter: Filter::Lanczos3, + }, + PreviewOp::Extent(ExtentOptions { + size: ExtentSize::Exact { width: u32::MAX, height: u32::MAX }, + ..ExtentOptions::new(ExtentSize::Exact { width: 800, height: 600 }) + }), + ] { + let result = session.apply(&op).unwrap(); + assert!(result.width() <= 800 && result.height() <= 800); + } +} + +#[test] +fn oversized_gif_frame_is_rejected_without_allocating_its_pixels() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("malformed.gif"); + let bytes = [ + 71, 73, 70, 56, 57, 97, 1, 0, 1, 0, 128, 0, 0, 0, 0, 0, 255, 255, 255, 33, 249, 4, 1, 0, 0, + 0, 0, 44, 0, 0, 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 16, + 0, 7, 0, 0, 0, 0, 0, 68, 1, 0, + ]; + std::fs::write(&path, bytes).unwrap(); + assert!(open_image(&path).is_err()); + assert!(bigimage_core::content::ensure_static_local(&path).is_err()); +} + +#[test] +fn avif_declared_box_beyond_file_is_rejected_before_allocation() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("malformed.avif"); + std::fs::write(&path, include_bytes!("fixtures/avif-box-overrun.avif")).unwrap(); + assert!(open_image(&path).is_err()); + assert!(bigimage_core::image_dimensions(&path).is_err()); +} diff --git a/crates/bigimage-core/tests/fixtures/avif-box-overrun.avif b/crates/bigimage-core/tests/fixtures/avif-box-overrun.avif new file mode 100644 index 0000000..031bda6 Binary files /dev/null and b/crates/bigimage-core/tests/fixtures/avif-box-overrun.avif differ diff --git a/crates/bigimage-core/tests/robustness.rs b/crates/bigimage-core/tests/robustness.rs new file mode 100644 index 0000000..29b96c0 --- /dev/null +++ b/crates/bigimage-core/tests/robustness.rs @@ -0,0 +1,316 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Regression tests for untrusted inputs, concurrent output and data preservation. +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Barrier}; + +use bigimage_core::{ + convert_file_to, make_gif, open_image, AnimateOptions, ConvertOutcome, EncodeOptions, + ExtentOptions, ExtentSize, Filter, Format, Gravity, OverwritePolicy, ResizeMode, +}; +use image::{DynamicImage, GenericImageView, Rgb, RgbImage}; + +fn fixture(dir: &Path, name: &str, color: u8) -> PathBuf { + std::fs::create_dir_all(dir).unwrap(); + let path = dir.join(name); + RgbImage::from_pixel(32, 24, Rgb([color, 20, 30])).save(&path).unwrap(); + path +} + +fn assert_no_partials(dir: &Path) { + for entry in std::fs::read_dir(dir).unwrap() { + assert!(!entry.unwrap().file_name().to_string_lossy().starts_with(".bigiris-")); + } +} + +#[test] +fn every_advertised_format_encodes_and_decodes() { + let dir = tempfile::tempdir().unwrap(); + let src = fixture(dir.path(), "source.png", 80); + for format in [ + Format::Png, + Format::Jpeg, + Format::WebP, + Format::Avif, + Format::Tiff, + Format::Bmp, + Format::Gif, + Format::Ico, + Format::Pnm, + Format::Tga, + Format::Qoi, + Format::Hdr, + Format::OpenExr, + ] { + let result = convert_file_to( + &src, + Some(&dir.path().join("out")), + format, + &EncodeOptions::default(), + OverwritePolicy::Replace, + ) + .unwrap(); + assert_eq!(open_image(result.path()).unwrap().dimensions(), (32, 24), "{format:?}"); + } +} + +#[test] +fn concurrent_increment_preserves_every_distinct_image() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("out"); + let barrier = Arc::new(Barrier::new(24)); + let handles: Vec<_> = (0..24) + .map(|i| { + let src = fixture(&dir.path().join(format!("in{i}")), "same.png", i); + let output = output.clone(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + barrier.wait(); + let result = convert_file_to( + src, + Some(&output), + Format::Png, + &EncodeOptions::default(), + OverwritePolicy::Increment, + ) + .unwrap(); + assert!(matches!(result, ConvertOutcome::Written { .. })); + (result.path().to_path_buf(), i) + }) + }) + .collect(); + let mut paths = std::collections::HashSet::new(); + for handle in handles { + let (path, color) = handle.join().unwrap(); + assert!(paths.insert(path.clone())); + assert_eq!(open_image(path).unwrap().to_rgb8().get_pixel(0, 0)[0], color); + } + assert_eq!(std::fs::read_dir(&output).unwrap().count(), 24); + assert_no_partials(&output); +} + +#[test] +fn concurrent_skip_publishes_exactly_one_complete_file() { + let dir = tempfile::tempdir().unwrap(); + let src = fixture(dir.path(), "same.png", 80); + let output = dir.path().join("out"); + let barrier = Arc::new(Barrier::new(16)); + let handles: Vec<_> = (0..16) + .map(|_| { + let src = src.clone(); + let output = output.clone(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + barrier.wait(); + convert_file_to( + src, + Some(&output), + Format::Png, + &EncodeOptions::default(), + OverwritePolicy::Skip, + ) + .unwrap() + }) + }) + .collect(); + let written = handles + .into_iter() + .map(|h| h.join().unwrap()) + .filter(|out| matches!(out, ConvertOutcome::Written { .. })) + .count(); + assert_eq!(written, 1); + assert_eq!(open_image(output.join("same.png")).unwrap().dimensions(), (32, 24)); + assert_no_partials(&output); +} + +#[test] +fn failed_gif_keeps_existing_destination_and_cleans_partial() { + let dir = tempfile::tempdir().unwrap(); + let frame = fixture(dir.path(), "valid.png", 80); + let output = dir.path().join("animation.gif"); + std::fs::write(&output, b"existing user data").unwrap(); + let missing = dir.path().join("missing.png"); + assert!(make_gif(&[frame, missing], &output, AnimateOptions::default()).is_err()); + assert_eq!(std::fs::read(output).unwrap(), b"existing user data"); + assert_no_partials(dir.path()); +} + +#[test] +fn gif_destination_can_also_be_an_input_without_truncation() { + let dir = tempfile::tempdir().unwrap(); + let frame = fixture(dir.path(), "valid.png", 80); + let output = dir.path().join("animation.gif"); + make_gif(&[frame], &output, AnimateOptions::default()).unwrap(); + make_gif(&[output.clone(), output.clone()], &output, AnimateOptions::default()).unwrap(); + use image::AnimationDecoder; + let decoder = image::codecs::gif::GifDecoder::new(std::io::BufReader::new( + std::fs::File::open(output).unwrap(), + )) + .unwrap(); + assert_eq!(decoder.into_frames().collect_frames().unwrap().len(), 2); +} + +#[test] +fn oversized_file_and_pixel_headers_are_rejected_by_viewer() { + let dir = tempfile::tempdir().unwrap(); + let sparse = dir.path().join("huge.png"); + std::fs::File::create(&sparse).unwrap().set_len(1024 * 1024 * 1024 + 1).unwrap(); + assert!(matches!(open_image(&sparse), Err(image::ImageError::Limits(_)))); + let pixels = dir.path().join("pixels.ppm"); + std::fs::write(&pixels, b"P6\n100000 100000\n255\n").unwrap(); + assert!(matches!(open_image(&pixels), Err(image::ImageError::Limits(_)))); + // Below the pixel limit, but RGB16 requires >512 MiB. + std::fs::write(&pixels, b"P6\n16000 12000\n65535\n").unwrap(); + assert!(matches!(open_image(&pixels), Err(image::ImageError::Limits(_)))); +} + +#[test] +fn enormous_resize_and_extent_return_errors_before_allocation() { + let img = DynamicImage::ImageRgb8(RgbImage::new(2, 2)); + for mode in [ + ResizeMode::Exact { width: u32::MAX, height: u32::MAX }, + ResizeMode::Fit { width: u32::MAX, height: u32::MAX }, + ] { + assert!(bigimage_core::resize::apply_to(&img, mode, Filter::Nearest).is_err()); + } + let opts = ExtentOptions { + size: ExtentSize::Exact { width: u32::MAX, height: 2 }, + background: [0; 4], + gravity: Gravity::Center, + }; + assert!(bigimage_core::extent::apply_to(&img, &opts).is_err()); +} + +#[test] +fn truncated_images_fail_without_panicking_or_publishing() { + let dir = tempfile::tempdir().unwrap(); + let src = fixture(dir.path(), "good.png", 80); + let bytes = std::fs::read(src).unwrap(); + let bad = dir.path().join("bad.png"); + let out = dir.path().join("out"); + for len in [0, 1, 8, 16, bytes.len() / 2] { + std::fs::write(&bad, &bytes[..len]).unwrap(); + assert!(open_image(&bad).is_err()); + assert!(convert_file_to( + &bad, + Some(&out), + Format::Jpeg, + &EncodeOptions::default(), + OverwritePolicy::Replace + ) + .is_err()); + assert!(!out.join("bad.jpg").exists()); + } +} + +#[cfg(unix)] +#[test] +fn skip_and_increment_do_not_follow_dangling_destination_symlinks() { + let dir = tempfile::tempdir().unwrap(); + let src = fixture(dir.path(), "same.png", 80); + let out = dir.path().join("out"); + std::fs::create_dir(&out).unwrap(); + let victim = dir.path().join("victim"); + std::os::unix::fs::symlink(&victim, out.join("same.png")).unwrap(); + let skipped = convert_file_to( + &src, + Some(&out), + Format::Png, + &EncodeOptions::default(), + OverwritePolicy::Skip, + ) + .unwrap(); + assert!(matches!(skipped, ConvertOutcome::Skipped { .. })); + let incremented = convert_file_to( + &src, + Some(&out), + Format::Png, + &EncodeOptions::default(), + OverwritePolicy::Increment, + ) + .unwrap(); + assert_eq!(incremented.path(), out.join("same_1.png")); + assert!(!victim.exists()); + assert!(out.join("same.png").is_symlink()); +} + +#[test] +fn rgb16_and_float_sources_convert_to_every_advertised_format() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("rgb16.png"); + image::ImageBuffer::from_pixel(8, 8, image::Rgb([12345u16, 23456, 34567])) + .save(&source) + .unwrap(); + let formats = [ + Format::Png, + Format::Jpeg, + Format::WebP, + Format::Avif, + Format::Tiff, + Format::Bmp, + Format::Gif, + Format::Ico, + Format::Pnm, + Format::Tga, + Format::Qoi, + Format::Hdr, + Format::OpenExr, + ]; + for format in formats { + let outcome = convert_file_to( + &source, + Some(&dir.path().join("out")), + format, + &EncodeOptions::default(), + OverwritePolicy::Replace, + ) + .unwrap(); + assert_eq!(open_image(outcome.path()).unwrap().dimensions(), (8, 8), "{format:?}"); + } + let float = dir.path().join("out/rgb16.exr"); + for format in formats { + let outcome = convert_file_to( + &float, + Some(&dir.path().join("float")), + format, + &EncodeOptions::default(), + OverwritePolicy::Replace, + ) + .unwrap(); + assert_eq!(open_image(outcome.path()).unwrap().dimensions(), (8, 8), "float to {format:?}"); + } +} + +#[test] +fn png_optimization_preserves_sixteen_bit_pixels() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("rgb16.png"); + let image = image::ImageBuffer::from_pixel(8, 8, image::Rgb([12345u16, 23456, 34567])); + image.save(&source).unwrap(); + let outcome = convert_file_to( + &source, + Some(&dir.path().join("out")), + Format::Png, + &EncodeOptions { optimize: true, ..EncodeOptions::default() }, + OverwritePolicy::Replace, + ) + .unwrap(); + let decoded = open_image(outcome.path()).unwrap(); + assert_eq!(decoded.color(), image::ColorType::Rgb16); + assert_eq!(decoded.to_rgb16(), image); +} + +#[test] +fn large_ico_is_scaled_to_supported_dimensions() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("large.png"); + RgbImage::new(512, 256).save(&src).unwrap(); + let out = convert_file_to( + &src, + None, + Format::Ico, + &EncodeOptions::default(), + OverwritePolicy::Replace, + ) + .unwrap(); + assert_eq!(open_image(out.path()).unwrap().dimensions(), (256, 128)); +} diff --git a/crates/bigimage-integrations/src/action.rs b/crates/bigimage-integrations/src/action.rs index 7e6b0aa..13b2706 100644 --- a/crates/bigimage-integrations/src/action.rs +++ b/crates/bigimage-integrations/src/action.rs @@ -43,6 +43,7 @@ pub const TOP_LEVEL_LABEL: &str = "BigIris"; /// Mime filter reused for every action (all image formats). const IMAGE_MIME: &[&str] = &["image/*"]; +const DIRECTORY_MIME: &[&str] = &["inode/directory"]; /// Mime filter para documentos que o LibreOffice converte em PDF. Escolha /// dos formatos segue a matriz que `soffice --convert-to pdf` atende bem @@ -131,6 +132,14 @@ pub const ACTIONS: &[Action] = &[ icon: "com.biglinux.Iris", mime_types: IMAGE_MIME, }, + Action { + id: "batch-folder", + submenu: None, + label: "Processar esta pasta…", + command: "bigiris --dialog=batch %F", + icon: "folder-pictures-symbolic", + mime_types: DIRECTORY_MIME, + }, // Convert ▸ Action { id: "convert-png", @@ -407,6 +416,42 @@ pub const ACTIONS: &[Action] = &[ icon: "applications-internet-symbolic", mime_types: IMAGE_MIME, }, + // Letterbox presets — "Para web ▸" também cobre quando a rede social + // força razão fixa e cortaria conteúdo importante da imagem. Estende a + // tela em volta da imagem em vez de cortar; 0d0e12 (preto suave) é o + // default que casa com o tema escuro do YouTube/Instagram. + Action { + id: "web-letterbox-yt", + submenu: Some(SUB_WEB), + label: "Letterbox YouTube post (1:1)", + command: "bigiris extent --aspect 1:1 --bg 0d0e12 %F", + icon: "applications-internet-symbolic", + mime_types: IMAGE_MIME, + }, + Action { + id: "web-letterbox-ig", + submenu: Some(SUB_WEB), + label: "Letterbox Instagram feed (4:5)", + command: "bigiris extent --aspect 4:5 --bg 0d0e12 %F", + icon: "applications-internet-symbolic", + mime_types: IMAGE_MIME, + }, + Action { + id: "web-letterbox-stories", + submenu: Some(SUB_WEB), + label: "Letterbox Stories / Shorts (9:16)", + command: "bigiris extent --aspect 9:16 --bg 0d0e12 %F", + icon: "applications-internet-symbolic", + mime_types: IMAGE_MIME, + }, + Action { + id: "web-letterbox-custom", + submenu: Some(SUB_WEB), + label: "Letterbox personalizado…", + command: "bigiris --dialog=extent %F", + icon: "applications-internet-symbolic", + mime_types: IMAGE_MIME, + }, // Metadados ▸ Action { id: "metadata-view", diff --git a/crates/bigimage-integrations/src/dolphin.rs b/crates/bigimage-integrations/src/dolphin.rs index ffe43ff..0fb7893 100644 --- a/crates/bigimage-integrations/src/dolphin.rs +++ b/crates/bigimage-integrations/src/dolphin.rs @@ -7,7 +7,7 @@ use std::io; use std::path::PathBuf; use crate::action::{Action, ACTIONS, TOP_LEVEL_LABEL}; -use crate::safe_fs::safe_write; +use crate::safe_fs::safe_write_with_mode; use crate::scope::{remove_matching, ScopePaths}; /// Install one `.desktop` per [`Action`] into `~/.local/share/kio/servicemenus/`. @@ -17,7 +17,10 @@ pub fn install(paths: &ScopePaths) -> io::Result> { let mut written = Vec::new(); for action in ACTIONS { let file = dir.join(file_name(action)); - safe_write(&file, render(action))?; + // Dolphin treats executable permission as the trust marker for + // user-owned ServiceMenus and silently rejects a regular `0644` + // desktop file. Re-applying the mode also repairs older installs. + safe_write_with_mode(&file, render(action), 0o755)?; written.push(file); } Ok(written) @@ -87,6 +90,9 @@ mod tests { #[test] fn install_writes_one_file_per_action() { + #[cfg(target_os = "linux")] + use std::os::unix::fs::PermissionsExt; + let tmp = TempDir::new().unwrap(); let paths = ScopePaths::rooted_at(tmp.path()); let written = install(&paths).unwrap(); @@ -96,6 +102,12 @@ mod tests { let name = path.file_name().unwrap().to_string_lossy(); assert!(name.starts_with("bigiris-")); assert!(name.ends_with(".desktop")); + #[cfg(target_os = "linux")] + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o755, + "Dolphin rejects non-executable user ServiceMenus" + ); } } diff --git a/crates/bigimage-integrations/src/install.rs b/crates/bigimage-integrations/src/install.rs index f2ec4ab..4254fe0 100644 --- a/crates/bigimage-integrations/src/install.rs +++ b/crates/bigimage-integrations/src/install.rs @@ -63,7 +63,11 @@ pub enum InstallError { /// PKGBUILD is expected to own those files, so we don't touch them. pub fn install(scope: Scope) -> Result { let paths = ScopePaths::resolve(scope)?; - let mut report = run_all(&paths, InstallOp::Install); + // A per-user install only writes integration files for file managers + // actually present. Distribution packages still stage every supported + // integration so users can install a manager later without reinstalling. + let detected_only = matches!(scope, Scope::User); + let mut report = run_all(&paths, InstallOp::Install, detected_only); if matches!(scope, Scope::User) { report.app_files = install_app_files(&paths); refresh_xdg_caches(&paths); @@ -74,7 +78,7 @@ pub fn install(scope: Scope) -> Result { /// Remove every BigIris service-menu file across every supported manager. pub fn uninstall(scope: Scope) -> Result { let paths = ScopePaths::resolve(scope)?; - let mut report = run_all(&paths, InstallOp::Uninstall); + let mut report = run_all(&paths, InstallOp::Uninstall, false); if matches!(scope, Scope::User) { report.app_files = uninstall_app_files(&paths); } @@ -86,18 +90,18 @@ pub fn uninstall(scope: Scope) -> Result { /// touching the live `/usr/share` tree. pub fn install_to_destdir(destdir: &std::path::Path) -> Report { let paths = ScopePaths::system_with_destdir(destdir); - run_all(&paths, InstallOp::Install) + run_all(&paths, InstallOp::Install, false) } /// Same as [`install`] / [`uninstall`] but with pre-resolved paths — /// used by tests to target a tempdir. pub fn install_with_paths(paths: &ScopePaths) -> Report { - run_all(paths, InstallOp::Install) + run_all(paths, InstallOp::Install, false) } /// Same as [`install_with_paths`] but for the uninstall flow. pub fn uninstall_with_paths(paths: &ScopePaths) -> Report { - run_all(paths, InstallOp::Uninstall) + run_all(paths, InstallOp::Uninstall, false) } #[derive(Clone, Copy)] @@ -106,13 +110,14 @@ enum InstallOp { Uninstall, } -fn run_all(paths: &ScopePaths, op: InstallOp) -> Report { +fn run_all(paths: &ScopePaths, op: InstallOp, detected_only: bool) -> Report { let mut report = Report::default(); report.outcomes.push(run_one( FileManager::Dolphin, "dolphin", paths, op, + detected_only, dolphin::install, dolphin::uninstall, )); @@ -121,6 +126,7 @@ fn run_all(paths: &ScopePaths, op: InstallOp) -> Report { "nautilus", paths, op, + detected_only, nautilus::install, nautilus::uninstall, )); @@ -129,6 +135,7 @@ fn run_all(paths: &ScopePaths, op: InstallOp) -> Report { "nemo", paths, op, + detected_only, nemo::install, nemo::uninstall, )); @@ -137,6 +144,7 @@ fn run_all(paths: &ScopePaths, op: InstallOp) -> Report { "thunar", paths, op, + detected_only, thunar::install, thunar::uninstall, )); @@ -145,6 +153,7 @@ fn run_all(paths: &ScopePaths, op: InstallOp) -> Report { "pcmanfm-qt", paths, op, + detected_only, pcmanfm_qt::install, pcmanfm_qt::uninstall, )); @@ -153,6 +162,7 @@ fn run_all(paths: &ScopePaths, op: InstallOp) -> Report { "io.elementary.files", paths, op, + detected_only, pantheon::install, pantheon::uninstall, )); @@ -164,10 +174,14 @@ fn run_one( detection_bin: &str, paths: &ScopePaths, op: InstallOp, + detected_only: bool, install_fn: fn(&ScopePaths) -> io::Result>, uninstall_fn: fn(&ScopePaths) -> io::Result>, ) -> FmOutcome { let detected = is_on_path(detection_bin); + if matches!(op, InstallOp::Install) && detected_only && !detected { + return FmOutcome { fm, detected, files: Vec::new(), error: None }; + } let result = match op { InstallOp::Install => install_fn(paths), InstallOp::Uninstall => uninstall_fn(paths), @@ -229,15 +243,29 @@ fn refresh_xdg_caches(paths: &ScopePaths) { .status(); if let Some(hicolor) = hicolor { for bin in ["gtk4-update-icon-cache", "gtk-update-icon-cache"] { + // `--ignore-theme-index` é obrigatório no escopo user: esta + // função só roda para `Scope::User` (ver `install`), e + // `~/.local/share/icons/hicolor` não tem `index.theme` próprio + // (só o tema system em `/usr/share` tem). Sem essa flag o + // comando recusa com "No theme index file" e — como o stderr é + // descartado — o cache nunca é gerado, deixando o ícone fora da + // busca do GNOME até um logout/login. let _ = Command::new(bin) .arg("-q") .arg("-f") + .arg("--ignore-theme-index") .arg(hicolor) .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); } } + // Plasma/Dolphin discovers ServiceMenus through the KService cache. + let _ = Command::new("kbuildsycoca6") + .arg("--noincremental") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); } /// Remove os arquivos instalados por [`install_app_files`]. Não remove @@ -322,5 +350,6 @@ mod tests { assert!(tmp.path().join("usr/share/kio/servicemenus/bigiris-convert-png.desktop").exists()); // Thunar's uca.xml goes under DESTDIR/etc/xdg/Thunar/. assert!(tmp.path().join("etc/xdg/Thunar/uca.xml").exists()); + assert!(tmp.path().join("usr/share/nautilus-python/extensions/bigiris-menu.py").exists()); } } diff --git a/crates/bigimage-integrations/src/nautilus.rs b/crates/bigimage-integrations/src/nautilus.rs index 3d16cb4..840ab3c 100644 --- a/crates/bigimage-integrations/src/nautilus.rs +++ b/crates/bigimage-integrations/src/nautilus.rs @@ -20,7 +20,7 @@ use std::io; use std::path::{Path, PathBuf}; -use crate::scope::ScopePaths; +use crate::scope::{Scope, ScopePaths}; const STALE_TREE_ROOT: &str = "BigIris"; const PYTHON_EXTENSION_FILENAME: &str = "bigiris-menu.py"; @@ -70,9 +70,8 @@ pub(crate) fn install_with_system_path( let ext_dir = paths.nautilus_python_extensions(); // User-scope sobre um system-wide já presente = menu duplicado. - // Detecta pelo prefixo `home` do ScopePaths: tudo que não é - // user-scope (Scope::System / DESTDIR) tem `home == /` ou =destdir. - let is_user_scope = ext_dir.starts_with(&paths.home); + // DESTDIR may share the same prefix as home; use the explicit scope. + let is_user_scope = paths.scope == Scope::User; if is_user_scope && system_ext.exists() { // Limpa qualquer resíduo user-scope (.py, .pyc/__pycache__) que // possa ressuscitar a duplicação. Ignora erros — a próxima @@ -162,6 +161,33 @@ mod tests { assert!(ext.exists(), "extensão python não instalada"); } + #[test] + fn packaging_keeps_extension_even_when_host_already_has_it() { + let tmp = TempDir::new().unwrap(); + let host = tmp.path().join("host-extension.py"); + std::fs::write(&host, "host stays unchanged").unwrap(); + let paths = ScopePaths::system_with_destdir(tmp.path().join("package")); + let written = install_with_system_path(&paths, &host).unwrap(); + assert_eq!(written.len(), 1); + assert_eq!(std::fs::read_to_string(&written[0]).unwrap(), PYTHON_EXTENSION); + assert_eq!(std::fs::read_to_string(&host).unwrap(), "host stays unchanged"); + assert_eq!(paths.scope, Scope::System); + } + + #[test] + fn user_install_avoids_duplicate_and_preserves_system_extension() { + let tmp = TempDir::new().unwrap(); + let host = tmp.path().join("host-extension.py"); + std::fs::write(&host, "host stays unchanged").unwrap(); + let paths = ScopePaths::rooted_at(tmp.path().join("user")); + let ext = paths.nautilus_python_extensions().join(PYTHON_EXTENSION_FILENAME); + std::fs::create_dir_all(ext.parent().unwrap()).unwrap(); + std::fs::write(&ext, "obsolete personal extension").unwrap(); + assert!(install_with_system_path(&paths, &host).unwrap().is_empty()); + assert!(!ext.exists()); + assert_eq!(std::fs::read_to_string(&host).unwrap(), "host stays unchanged"); + } + #[test] fn install_cleans_stale_scripts_tree() { let tmp = TempDir::new().unwrap(); diff --git a/crates/bigimage-integrations/src/safe_fs.rs b/crates/bigimage-integrations/src/safe_fs.rs index 5514d95..09e32e2 100644 --- a/crates/bigimage-integrations/src/safe_fs.rs +++ b/crates/bigimage-integrations/src/safe_fs.rs @@ -19,27 +19,46 @@ use std::path::Path; #[cfg(target_os = "linux")] const O_NOFOLLOW: i32 = 0o400000; -/// Write `content` to `path`, creating or truncating, but refusing to -/// follow a symlink at the target. On non-Linux falls back to plain -/// `std::fs::write` (the project is Linux-only; this shim just keeps -/// `cargo check` happy on macOS dev boxes). -#[cfg(target_os = "linux")] pub(crate) fn safe_write(path: &Path, content: impl AsRef<[u8]>) -> io::Result<()> { - use std::os::unix::fs::OpenOptionsExt; + safe_write_with_mode(path, content, 0o644) +} + +/// Write `content` with an explicit Unix permission mode, creating or +/// truncating the target without following a symlink at the final path. +/// +/// Most integration metadata uses `0644`. User-owned KDE ServiceMenus are +/// the exception: Dolphin deliberately ignores them unless they carry an +/// executable bit, so their installer requests `0755`. +#[cfg(target_os = "linux")] +pub(crate) fn safe_write_with_mode( + path: &Path, + content: impl AsRef<[u8]>, + mode: u32, +) -> io::Result<()> { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; let mut f = std::fs::OpenOptions::new() .write(true) .create(true) .truncate(true) + .mode(mode) .custom_flags(O_NOFOLLOW) .open(path)?; f.write_all(content.as_ref())?; f.flush()?; + // Creation mode is still filtered by the caller's umask, and an existing + // file keeps its old mode. Normalize both cases through the already-open + // descriptor so integration metadata is never group/world writable. + f.set_permissions(std::fs::Permissions::from_mode(mode))?; Ok(()) } #[cfg(not(target_os = "linux"))] -pub(crate) fn safe_write(path: &Path, content: impl AsRef<[u8]>) -> io::Result<()> { +pub(crate) fn safe_write_with_mode( + path: &Path, + content: impl AsRef<[u8]>, + _mode: u32, +) -> io::Result<()> { std::fs::write(path, content) } @@ -50,10 +69,12 @@ mod tests { #[test] fn writes_to_fresh_path() { + use std::os::unix::fs::PermissionsExt; let tmp = TempDir::new().unwrap(); let p = tmp.path().join("a.txt"); safe_write(&p, "hello").unwrap(); assert_eq!(std::fs::read_to_string(&p).unwrap(), "hello"); + assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o644); } #[test] @@ -65,6 +86,20 @@ mod tests { assert_eq!(std::fs::read_to_string(&p).unwrap(), "shorter"); } + #[cfg(target_os = "linux")] + #[test] + fn explicit_mode_normalizes_existing_file() { + use std::os::unix::fs::PermissionsExt; + + let tmp = TempDir::new().unwrap(); + let p = tmp.path().join("action.desktop"); + std::fs::write(&p, "old").unwrap(); + safe_write_with_mode(&p, "new", 0o755).unwrap(); + + assert_eq!(std::fs::read_to_string(&p).unwrap(), "new"); + assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o755); + } + #[cfg(target_os = "linux")] #[test] fn refuses_to_follow_symlink_at_target() { diff --git a/crates/bigimage-integrations/src/scope.rs b/crates/bigimage-integrations/src/scope.rs index 4a4670f..c73731f 100644 --- a/crates/bigimage-integrations/src/scope.rs +++ b/crates/bigimage-integrations/src/scope.rs @@ -32,7 +32,9 @@ pub enum Scope { /// XDG dirs (which can vary between tests, CI, and a real desktop session). #[derive(Debug, Clone)] pub struct ScopePaths { - /// Base `$HOME` (or pseudo-home in tests). Only populated for `Scope::User`. + /// Explicit scope; DESTDIR retains System even when paths share a prefix. + pub scope: Scope, + /// Base `$HOME` (or pseudo-home in tests). System installations use `/` or the staging root. pub home: PathBuf, /// `~/.local/share` / `/usr/share`. pub data: PathBuf, @@ -47,12 +49,14 @@ impl ScopePaths { Scope::User => { let base = BaseDirs::new().ok_or(ScopeError::NoHome)?; Ok(Self { + scope, home: base.home_dir().to_path_buf(), data: base.data_dir().to_path_buf(), config: base.config_dir().to_path_buf(), }) } Scope::System => Ok(Self { + scope, home: PathBuf::from("/"), data: PathBuf::from("/usr/share"), config: PathBuf::from("/etc/xdg"), @@ -66,7 +70,7 @@ impl ScopePaths { let home: PathBuf = home.into(); let data = home.join(".local/share"); let config = home.join(".config"); - Self { home, data, config } + Self { scope: Scope::User, home, data, config } } /// System-wide scope prefixed with a `DESTDIR` for packaging. Ensures @@ -75,6 +79,7 @@ impl ScopePaths { pub fn system_with_destdir(destdir: impl Into) -> Self { let destdir: PathBuf = destdir.into(); Self { + scope: Scope::System, home: destdir.clone(), data: destdir.join("usr/share"), config: destdir.join("etc/xdg"), diff --git a/crates/bigiris/Cargo.toml b/crates/bigiris/Cargo.toml index 5f80314..4a96d7b 100644 --- a/crates/bigiris/Cargo.toml +++ b/crates/bigiris/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bigiris" -description = "Modern image viewer and converter for Linux (BigLinux family)" +description = "Fast image viewer and batch converter for Linux" version.workspace = true edition.workspace = true rust-version.workspace = true @@ -14,7 +14,7 @@ name = "bigiris" path = "src/main.rs" [dependencies] -bigimage-core = { path = "../bigimage-core" } +bigimage-core = { path = "../bigimage-core", features = ["serde"] } bigimage-ai = { path = "../bigimage-ai" } bigimage-integrations = { path = "../bigimage-integrations" } clap.workspace = true @@ -27,6 +27,11 @@ libadwaita = { workspace = true, optional = true } # and the GIF animate path needs it too. Compile cost is shared anyway # because `bigimage-core` already pulls it in. image.workspace = true +serde.workspace = true +toml.workspace = true +serde_json = "1" +tempfile = "3" +rustix = { version = "1", features = ["fs"] } # i18n: libintl wrapper. Always-on (no extra system deps — libintl is in # glibc on Linux). Translations live under data/po; .mo files are # compiled by the PKGBUILD and loaded from /usr/share/locale. @@ -38,9 +43,6 @@ gettext-rs = { workspace = true } # runtime dep — the allocator is statically linked. mimalloc = { version = "0.1", default-features = false } -[dev-dependencies] -tempfile = "3" - [features] # Off by default so CI and headless builds stay lean (no GTK/libadwaita # system libs required). The PKGBUILD and README install instructions diff --git a/crates/bigiris/src/codec.rs b/crates/bigiris/src/codec.rs new file mode 100644 index 0000000..465f7de --- /dev/null +++ b/crates/bigiris/src/codec.rs @@ -0,0 +1,483 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Confine untrusted codecs to a short-lived, resource-limited process. +//! The parent accepts only a validated, bounded raw-pixel protocol. +use bigimage_core::{backend, BigImageError, Format, Metadata, PreviewSession}; +use image::{ColorType, DynamicImage, GenericImageView, ImageBuffer, ImageError, ImageResult}; +use std::cell::RefCell; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::os::unix::fs::OpenOptionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; +use std::time::{Duration, Instant}; + +const MAGIC: &[u8; 8] = b"BIGIRIS2"; +const HEADER: usize = 36; +const MAX_BYTES: u64 = 512 * 1024 * 1024; +const MAX_INPUT: u64 = 1024 * 1024 * 1024; +const MAX_METADATA: u64 = 1024 * 1024; +thread_local! { static CANCEL: RefCell>> = const { RefCell::new(None) }; } + +pub fn configure() { + assert!(backend::configure(backend::Backend { + decode, + dimensions, + preview, + metadata, + ensure_static + })); +} + +/// Exercise the real process boundary and raw protocol. +pub fn self_test() -> color_eyre::Result<()> { + let input = tempfile::Builder::new().suffix(".png").tempfile()?; + image::RgbImage::from_pixel(2, 3, image::Rgb([30, 70, 120])).save(input.path())?; + let image = decode(input.path())?.0; + if image.dimensions() != (2, 3) { + return Err(color_eyre::eyre::eyre!("invalid sandbox decode")); + } + let output = execute(input.path(), "attest")?; + if output.metadata()?.len() != 0 { + return Err(color_eyre::eyre::eyre!("invalid sandbox attestation")); + } + Ok(()) +} + +/// Scoped cancellation also restores the context during panic unwinding. +#[cfg(any(feature = "gui", test))] +pub fn with_cancel(cancel: Arc, work: impl FnOnce() -> T) -> T { + struct Restore(Option>); + impl Drop for Restore { + fn drop(&mut self) { + CANCEL.with(|slot| *slot.borrow_mut() = self.0.take()); + } + } + let _restore = Restore(CANCEL.with(|slot| slot.replace(Some(cancel)))); + work() +} +fn invalid(message: impl Into) -> ImageError { + ImageError::IoError(std::io::Error::new(std::io::ErrorKind::InvalidData, message.into())) +} +fn cancelled() -> bool { + CANCEL.with(|slot| slot.borrow().as_ref().is_some_and(|flag| flag.load(Ordering::Acquire))) +} +struct ChildGuard(Child); +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn execute(path: &Path, mode: &str) -> ImageResult { + if cancelled() { + return Err(invalid("operação cancelada")); + } + let input = std::fs::OpenOptions::new() + .read(true) + .custom_flags(rustix::fs::OFlags::NONBLOCK.bits() as i32) + .open(path)?; + let info = input.metadata()?; + if !info.is_file() || info.len() > MAX_INPUT { + return Err(invalid("entrada não regular ou acima de 1 GiB")); + } + let extension = path + .extension() + .and_then(|e| e.to_str()) + .and_then(|e| Format::parse(e).ok()) + .map_or("img", |format| format.extension()); + let sandbox_input = format!("/input.{extension}"); + let executable = std::env::current_exe()?; + let output = tempfile::tempfile()?; + let diagnostic = tempfile::tempfile()?; + let mut command = Command::new("/usr/bin/prlimit"); + command + .env_clear() + .args(["--as=2147483648", "--cpu=30", "--nofile=64", "--core=0"]) + .arg(format!("--fsize={}", MAX_BYTES + HEADER as u64)) + .args([ + "--", + "/usr/bin/bwrap", + "--unshare-all", + "--die-with-parent", + "--new-session", + "--clearenv", + "--cap-drop", + "ALL", + "--ro-bind", + "/usr", + "/usr", + "--symlink", + "usr/lib", + "/lib", + "--symlink", + "usr/lib64", + "/lib64", + "--symlink", + "usr/bin", + "/bin", + "--proc", + "/proc", + "--dev", + "/dev", + "--remount-ro", + "/dev", + "--size", + "67108864", + "--tmpfs", + "/tmp", + "--dir", + "/app", + "--ro-bind", + ]) + .arg(executable) + .arg("/app/bigiris") + .args(["--ro-bind", "/proc/self/fd/0"]) + .arg(&sandbox_input) + .args([ + "--chdir", + "/", + "--setenv", + "LC_ALL", + "C.UTF-8", + "--setenv", + "RAYON_NUM_THREADS", + "2", + "/app/bigiris", + "--codec-worker", + ]) + .arg(mode) + .arg(&sandbox_input) + .stdin(Stdio::from(input)) + .stdout(Stdio::from(output.try_clone()?)) + .stderr(Stdio::from(diagnostic.try_clone()?)); + let child = command.spawn().map_err(|error| { + invalid(format!("sandbox indisponível: instale bubblewrap e util-linux; {error}")) + })?; + let mut child = ChildGuard(child); + let deadline = Instant::now() + Duration::from_secs(60); + loop { + if cancelled() { + return Err(invalid("operação cancelada")); + } + if Instant::now() >= deadline { + return Err(invalid("codec excedeu o limite de 60 segundos")); + } + if let Some(status) = child.0.try_wait()? { + if !status.success() { + let mut details = String::new(); + let mut diagnostic = diagnostic; + diagnostic.seek(SeekFrom::Start(0))?; + diagnostic.take(4096).read_to_string(&mut details).ok(); + return Err(invalid(format!( + "codec isolado recusou a imagem ({status}): {}", + details.trim() + ))); + } + let mut output = output; + output.seek(SeekFrom::Start(0))?; + return Ok(output); + } + std::thread::sleep(Duration::from_millis(5)); + } +} + +struct Pixels { + image: DynamicImage, + format: Option, + natural: (u32, u32), + gps: bool, +} +fn receive(mut output: std::fs::File) -> ImageResult { + let mut header = [0u8; HEADER]; + output.read_exact(&mut header)?; + if &header[..8] != MAGIC || header[10] > 1 || header[11] != 0 { + return Err(invalid("protocolo de codec inválido")); + } + let word = |offset| u32::from_le_bytes(header[offset..offset + 4].try_into().unwrap()); + let (w, h) = (word(12), word(16)); + let natural = (word(20), word(24)); + let length = u64::from_le_bytes(header[28..36].try_into().unwrap()); + let color = match header[8] { + 1 => ColorType::L8, + 2 => ColorType::La8, + 3 => ColorType::Rgb8, + 4 => ColorType::Rgba8, + 5 => ColorType::L16, + 6 => ColorType::La16, + 7 => ColorType::Rgb16, + 8 => ColorType::Rgba16, + 9 => ColorType::Rgb32F, + 10 => ColorType::Rgba32F, + _ => return Err(invalid("tipo de pixels inválido")), + }; + if w == 0 + || h == 0 + || natural.0 == 0 + || natural.1 == 0 + || u64::from(natural.0) * u64::from(natural.1) > 256 * 1024 * 1024 + || u64::from(w) * u64::from(h) > 256 * 1024 * 1024 + || length > MAX_BYTES + || length != u64::from(w) * u64::from(h) * u64::from(color.bytes_per_pixel()) + || output.metadata()?.len() != HEADER as u64 + length + { + return Err(invalid("dimensões ou tamanho do protocolo inválidos")); + } + let format = if header[9] == 255 { + None + } else { + Some(*Format::all().get(header[9] as usize).ok_or_else(|| invalid("formato inválido"))?) + }; + let mut raw = vec![0; length as usize]; + output.read_exact(&mut raw)?; + let shorts = || { + raw.as_chunks::<2>().0.iter().map(|bytes| u16::from_ne_bytes(*bytes)).collect::>() + }; + let floats = || { + raw.as_chunks::<4>().0.iter().map(|bytes| f32::from_ne_bytes(*bytes)).collect::>() + }; + macro_rules! buffer { + ($data:expr, $variant:ident) => { + DynamicImage::$variant( + ImageBuffer::from_raw(w, h, $data).ok_or_else(|| invalid("buffer inválido"))?, + ) + }; + } + let image = match color { + ColorType::L8 => buffer!(raw, ImageLuma8), + ColorType::La8 => buffer!(raw, ImageLumaA8), + ColorType::Rgb8 => buffer!(raw, ImageRgb8), + ColorType::Rgba8 => buffer!(raw, ImageRgba8), + ColorType::L16 => buffer!(shorts(), ImageLuma16), + ColorType::La16 => buffer!(shorts(), ImageLumaA16), + ColorType::Rgb16 => buffer!(shorts(), ImageRgb16), + ColorType::Rgba16 => buffer!(shorts(), ImageRgba16), + ColorType::Rgb32F => buffer!(floats(), ImageRgb32F), + ColorType::Rgba32F => buffer!(floats(), ImageRgba32F), + _ => return Err(invalid("tipo de pixels inválido")), + }; + Ok(Pixels { image, format, natural, gps: header[10] == 1 }) +} +fn decode(path: &Path) -> ImageResult { + let pixels = receive(execute(path, "pixels")?)?; + Ok((pixels.image, pixels.format)) +} +fn dimensions(path: &Path) -> ImageResult<(u32, u32)> { + let mut output = execute(path, "dimensions")?; + if output.metadata()?.len() != 8 { + return Err(invalid("dimensões inválidas")); + } + let mut bytes = [0; 8]; + output.read_exact(&mut bytes)?; + let size = ( + u32::from_le_bytes(bytes[..4].try_into().unwrap()), + u32::from_le_bytes(bytes[4..].try_into().unwrap()), + ); + if size.0 == 0 || size.1 == 0 || u64::from(size.0) * u64::from(size.1) > 256 * 1024 * 1024 { + return Err(invalid("dimensões inválidas")); + } + Ok(size) +} +fn preview(path: &Path) -> bigimage_core::Result { + let pixels = receive( + execute(path, "preview") + .map_err(|source| BigImageError::Decode { path: path.into(), source })?, + ) + .map_err(|source| BigImageError::Decode { path: path.into(), source })?; + PreviewSession::from_cached( + path.into(), + pixels.natural, + pixels + .format + .ok_or_else(|| BigImageError::UnsupportedFormat("preview sem formato".into()))?, + pixels.image, + pixels.gps, + ) +} +fn ensure_static(path: &Path) -> bigimage_core::Result<()> { + execute(path, "static") + .map(|_| ()) + .map_err(|source| BigImageError::Decode { path: path.into(), source }) +} +fn metadata(path: &Path) -> bigimage_core::Result { + let output = execute(path, "metadata") + .map_err(|source| BigImageError::Decode { path: path.into(), source })?; + if output.metadata().map_err(BigImageError::Io)?.len() > MAX_METADATA { + return Err(BigImageError::Other("metadados excessivos".into())); + } + serde_json::from_reader(output).map_err(|error| BigImageError::Other(error.to_string())) +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct Information { + pub dimensions: Option<(u32, u32)>, + pub color: Option, + pub format: Option, + pub metadata: Option, +} +fn information_local(path: &Path) -> Information { + let dimensions = backend::dimensions_in_process(path).ok(); + let reader = image::ImageReader::open(path).ok().and_then(|r| r.with_guessed_format().ok()); + let format = reader.as_ref().and_then(|r| r.format()).map(|f| format!("{f:?}")); + let color = + reader.and_then(|r| r.into_decoder().ok()).map( + |dec| match image::ImageDecoder::color_type(&dec) { + ColorType::L8 => 1, + ColorType::La8 => 2, + ColorType::Rgb8 => 3, + ColorType::Rgba8 => 4, + ColorType::L16 => 5, + ColorType::La16 => 6, + ColorType::Rgb16 => 7, + ColorType::Rgba16 => 8, + ColorType::Rgb32F => 9, + ColorType::Rgba32F => 10, + _ => 0, + }, + ); + let metadata = bigimage_core::metadata::read(path).ok().map(trim_metadata); + Information { dimensions, color, format, metadata } +} +fn trim_metadata(mut meta: Metadata) -> Metadata { + meta.tags.truncate(128); + for (_, value) in &mut meta.tags { + *value = value.chars().take(4096).collect(); + } + meta +} +#[cfg(feature = "gui")] +pub fn information(path: &Path) -> ImageResult { + // Unit tests do not execute main/configure; still run their I/O off GTK. + #[cfg(test)] + return Ok(information_local(path)); + #[cfg(not(test))] + { + let output = execute(path, "information")?; + if output.metadata()?.len() > MAX_METADATA { + return Err(invalid("metadados excessivos")); + } + serde_json::from_reader(output).map_err(|e| invalid(e.to_string())) + } +} + +fn send( + image: &DynamicImage, + format: Option, + natural: (u32, u32), + gps: bool, +) -> ImageResult<()> { + let color = match image.color() { + ColorType::L8 => 1, + ColorType::La8 => 2, + ColorType::Rgb8 => 3, + ColorType::Rgba8 => 4, + ColorType::L16 => 5, + ColorType::La16 => 6, + ColorType::Rgb16 => 7, + ColorType::Rgba16 => 8, + ColorType::Rgb32F => 9, + ColorType::Rgba32F => 10, + _ => return Err(invalid("tipo de pixels inválido")), + }; + let mut header = Vec::with_capacity(HEADER); + header.extend(MAGIC); + header.extend([ + color, + format.map_or(255, |f| Format::all().iter().position(|x| *x == f).unwrap() as u8), + u8::from(gps), + 0, + ]); + for value in [image.width(), image.height(), natural.0, natural.1] { + header.extend(value.to_le_bytes()); + } + header.extend((image.as_bytes().len() as u64).to_le_bytes()); + let mut output = std::io::stdout().lock(); + output.write_all(&header)?; + output.write_all(image.as_bytes())?; + output.flush()?; + Ok(()) +} +pub fn worker() -> color_eyre::Result<()> { + let args: Vec<_> = std::env::args_os().collect(); + if args.len() != 4 { + return Err(color_eyre::eyre::eyre!("invalid worker arguments")); + } + let mode = args[2].to_str().unwrap_or(""); + let path = PathBuf::from(&args[3]); + match mode { + "pixels" => { + let (image, format) = backend::decode_in_process(&path)?; + send(&image, format, image.dimensions(), false)?; + } + "preview" => { + let session = PreviewSession::open(&path)?; + send( + session.thumbnail(), + Some(session.source_format()), + session.natural_size(), + session.source_has_gps(), + )?; + } + "dimensions" => { + let (w, h) = backend::dimensions_in_process(&path)?; + let mut out = std::io::stdout().lock(); + out.write_all(&w.to_le_bytes())?; + out.write_all(&h.to_le_bytes())?; + } + "metadata" => { + let meta = trim_metadata(bigimage_core::metadata::read(&path)?); + serde_json::to_writer(std::io::stdout().lock(), &meta)?; + } + "attest" => { + for location in ["/home", "/root", "/run", "/etc"] { + if std::path::Path::new(location).exists() { + return Err(color_eyre::eyre::eyre!("host directory visible: {location}")); + } + } + let network = std::fs::read_to_string("/proc/net/dev")?; + if network.lines().skip(2).any(|line| !line.trim_start().starts_with("lo:")) { + return Err(color_eyre::eyre::eyre!("host network visible")); + } + } + "static" => { + bigimage_core::content::ensure_static_local(&path)?; + } + "information" => { + serde_json::to_writer(std::io::stdout().lock(), &information_local(&path))?; + } + _ => return Err(color_eyre::eyre::eyre!("invalid worker mode")), + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn cancellation_context_restores_after_panic() { + let flag = Arc::new(AtomicBool::new(true)); + assert!(!cancelled()); + let _ = std::panic::catch_unwind(|| { + with_cancel(flag, || { + assert!(cancelled()); + panic!("test") + }) + }); + assert!(!cancelled()); + } + #[test] + fn invalid_protocol_cannot_allocate_claimed_pixels() { + let mut file = tempfile::tempfile().unwrap(); + let mut header = [0u8; HEADER]; + header[..8].copy_from_slice(MAGIC); + header[8] = 4; + header[12..16].copy_from_slice(&u32::MAX.to_le_bytes()); + header[16..20].copy_from_slice(&u32::MAX.to_le_bytes()); + file.write_all(&header).unwrap(); + file.rewind().unwrap(); + assert!(receive(file).is_err()); + } +} diff --git a/crates/bigiris/src/gui.rs b/crates/bigiris/src/gui.rs index 022fd31..931a17d 100644 --- a/crates/bigiris/src/gui.rs +++ b/crates/bigiris/src/gui.rs @@ -1,17 +1,20 @@ // SPDX-License-Identifier: GPL-3.0-or-later -//! Viewer "Íris" — GTK4 + libadwaita. +//! Viewer "Íris" — GTK4 + libadwaita, tuned for Plasma and other Linux +//! desktops. //! //! MVP+zoom/pan: `gtk::Picture` wrapped in `gtk::ScrolledWindow`. `Fit` mode //! uses `ContentFit::Contain` and lets the picture ride the viewport; the //! moment the user zooms, we switch to `Fill` with an explicit size request //! so the scroll view can give actual scrollbars and pan behaviour. //! -//! Gesture-zoom (pinch), cursor-centred zoom, film strip, EXIF sidebar and -//! the glycin decoder swap are planned for the M2 milestone. +//! The viewing surface stays intentionally quiet; advanced editing and +//! batch tools live behind the menu and the folder workflow. use std::cell::{Cell, RefCell}; use std::path::{Path, PathBuf}; use std::rc::Rc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc, Condvar, Mutex}; use adw::prelude::*; use gettextrs::gettext; @@ -19,6 +22,16 @@ use gtk::{gdk, gio, glib, graphene}; use gtk4 as gtk; use libadwaita as adw; +mod previews; +mod zoom; +use previews::{ + build_preview_column, dynamic_image_to_texture, prepare_dialog, refresh_preview, + PreviewDebounce, +}; +#[cfg(test)] +use zoom::anchor_axis; +use zoom::{adjust_zoom, apply_zoom, set_zoom, zoom_in, zoom_out, PendingZoom}; + const APP_ID: &str = "com.biglinux.Iris"; const ZOOM_STEP: f64 = 1.25; const MIN_ZOOM: f64 = 0.05; @@ -29,24 +42,20 @@ const TOP_EDGE_PX: f64 = 60.0; /// taller than the strip itself (96 px) so the user can "catch" it. const BOTTOM_EDGE_PX: f64 = 110.0; /// Hide delay for header / strip after the cursor leaves the edge zone. -const EDGE_HIDE_MS: u64 = 900; +const DEFAULT_EDGE_HIDE_MS: u64 = 1500; /// OSD pill visible duration after a nav/zoom pulse, mpv-style. const OSD_PULSE_MS: u64 = 1200; /// Idle time before the cursor itself blinks out (mouse still, no key). const CURSOR_HIDE_MS: u64 = 2000; /// Slideshow step between auto-advances (Space toggles). -const SLIDESHOW_INTERVAL_S: u32 = 5; +const DEFAULT_SLIDESHOW_INTERVAL_S: u32 = 5; /// CSS applied app-wide. Theater is on by default (see `build_window`); /// `.theater-off` is the escape hatch that restores Adwaita chrome when /// the user toggles theater off. const THEATER_CSS: &str = " .theater, .theater scrolledwindow, .theater scrolledwindow > viewport { - background-color: #000; -} -.theater headerbar { - background-color: rgba(0, 0, 0, 0.55); - box-shadow: none; + background-color: #101216; } .film-strip { padding: 4px 8px; } .film-strip button { padding: 2px; min-height: 0; min-width: 0; } @@ -54,6 +63,10 @@ const THEATER_CSS: &str = " outline: 2px solid @accent_color; outline-offset: -2px; } +.viewer-loading { padding: 12px; border-radius: 8px; background: @window_bg_color; color: @window_fg_color; } +.plasma headerbar button { border-radius: 6px; } +.plasma .suggested-action { border-radius: 6px; } +.plasma .viewer-osd { border-radius: 8px; } "; /// Pixel size of each thumbnail in the film strip (longest edge). @@ -61,6 +74,10 @@ const THUMB_EDGE: u32 = 80; /// Start the viewer event loop. Returns the exit code GTK produced. pub fn run_viewer(files: Vec) -> i32 { + // Um clique no gerenciador de arquivos entrega um caminho só. Sem isto, + // `files.len() == 1` desliga setas, film strip e slideshow, e o + // visualizador vira uma janela de imagem única. + // Directory enumeration must never block the GTK thread. // NON_UNIQUE: sem isso o GApplication tenta single-instance via D-Bus // e, se uma janela do BigIris tá se fechando no momento em que outra // tenta abrir (ex.: dashboard spawna novo processo e fecha), a nova @@ -74,7 +91,43 @@ pub fn run_viewer(files: Vec) -> i32 { let files = Rc::new(files); app.connect_activate(move |app| { install_theater_css(); - build_window(app, files.clone()).present(); + let waiting = adw::ApplicationWindow::builder() + .application(app) + .title("BigIris — abrindo imagens") + .default_width(640) + .default_height(480) + .build(); + let spinner = gtk::Spinner::new(); + spinner.start(); + waiting.set_content(Some(&spinner)); + waiting.present(); + let input = files.as_ref().clone(); + let cancel = Arc::new(AtomicBool::new(false)); + let closing = cancel.clone(); + waiting.connect_unrealize(move |_| closing.store(true, Ordering::Release)); + let (tx, rx) = mpsc::sync_channel(1); + std::thread::spawn(move || { + let _ = tx.send(expand_to_folder_cancellable(input, &cancel)); + }); + let weak = waiting.downgrade(); + let weak_app = app.downgrade(); + glib::timeout_add_local(Duration::from_millis(16), move || { + let Some(waiting) = weak.upgrade() else { return glib::ControlFlow::Break }; + if !waiting.is_visible() { + return glib::ControlFlow::Break; + } + match rx.try_recv() { + Ok((files, index)) => { + if let Some(app) = weak_app.upgrade() { + build_window(&app, Rc::new(files), index).present(); + } + waiting.close(); + glib::ControlFlow::Break + } + Err(mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, + Err(mpsc::TryRecvError::Disconnected) => glib::ControlFlow::Break, + } + }); }); // Empty argv: clap already consumed the interesting bits and we don't want @@ -83,6 +136,157 @@ pub fn run_viewer(files: Vec) -> i32 { i32::from(u8::from(code)) } +/// Quando o visualizador recebe um arquivo só, varre a pasta dele e +/// devolve todas as imagens irmãs em ordem natural, junto do índice do +/// arquivo pedido. É o que faz o clique no gerenciador de arquivos abrir +/// a coleção inteira em vez de uma imagem sozinha: setas, `Home`/`End`, +/// film strip e slideshow já operam sobre `files`, então basta entregar a +/// lista certa. +/// +/// Com dois ou mais arquivos na entrada nada muda: quem passou uma seleção +/// explícita na linha de comando quer exatamente aquela seleção. +/// +/// Qualquer tropeço na varredura (pasta ilegível, arquivo pedido fora da +/// lista de formatos) cai de volta na entrada original. Abrir a imagem que +/// o usuário pediu importa mais do que oferecer navegação. +#[cfg(test)] +fn expand_to_folder(files: Vec) -> (Vec, usize) { + expand_to_folder_cancellable(files, &AtomicBool::new(false)) +} +fn expand_to_folder_cancellable(files: Vec, cancel: &AtomicBool) -> (Vec, usize) { + if files.len() != 1 { + return (files, 0); + } + + let target = &files[0]; + if !target.is_file() { + return (files, 0); + } + + // `parent()` de um caminho sem diretório ("foto.png") devolve "", que + // não é legível por read_dir; nesse caso a pasta é a atual. + let dir = match target.parent() { + Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(), + Some(_) => PathBuf::from("."), + None => return (files, 0), + }; + + let Ok(entries) = std::fs::read_dir(&dir) else { + return (files, 0); + }; + + let mut siblings = Vec::new(); + for entry in entries { + if cancel.load(Ordering::Acquire) { + return (files, 0); + } + let Ok(entry) = entry else { continue }; + let path = entry.path(); + if is_viewable(&path) && path.is_file() { + siblings.push(path); + } + if siblings.len() > 50_000 { + tracing::warn!("pasta excedeu 50 mil imagens; abrindo somente a imagem solicitada"); + return (files, 0); + } + } + + if siblings.len() < 2 { + return (files, 0); + } + + siblings.sort_by(|a, b| { + let an = a.file_name().unwrap_or_default().to_string_lossy().into_owned(); + let bn = b.file_name().unwrap_or_default().to_string_lossy().into_owned(); + // Desempate pelo nome cru para a ordem não depender da ordem de + // entrega do read_dir quando dois nomes só diferem em maiúsculas. + natural_cmp(&an, &bn).then_with(|| an.cmp(&bn)) + }); + + // O gerenciador de arquivos pode entregar symlink ou caminho relativo + // diferente do que o read_dir devolve, então o caminho canônico decide. + let canonical_target = target.canonicalize().ok(); + let found = siblings.iter().position(|path| { + path == target + || (canonical_target.as_ref().is_some_and(|p| p.file_name() == path.file_name()) + && path.canonicalize().ok() == canonical_target) + }); + + match found { + Some(idx) => (siblings, idx), + None => (files, 0), + } +} + +/// `true` quando a extensão do arquivo é um formato que o BigIris abre. +/// Usa a mesma tabela do resto do programa ([`bigimage_core::Format`]), +/// para a varredura da pasta não divergir do que o visualizador aceita. +/// +/// A checagem é por extensão de propósito: farejar o conteúdo de cada +/// arquivo custaria uma leitura por item da pasta só para montar a lista. +/// Um arquivo com extensão mentirosa continua abrindo normalmente quando é +/// ele o alvo do clique, porque aí quem decodifica é +/// [`bigimage_core::open_image`], que fareja o conteúdo. +fn is_viewable(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| bigimage_core::Format::parse(ext).is_ok()) +} + +/// Compara nomes tratando sequências de dígitos como número, e não como +/// texto. É o que põe `2.png` antes de `10.png`; a ordem lexicográfica pura +/// devolveria `1, 10, 100, 11, …`, que é justamente o padrão das coleções +/// numeradas de wallpaper e das fotos saídas de câmera (`IMG_2.jpg`). +/// +/// Entre dois números de mesmo valor, vence o que tem menos zeros à +/// esquerda, então `7` vem antes de `007` e a ordem fica estável. +fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering { + use std::cmp::Ordering; + + let a: Vec = a.chars().collect(); + let b: Vec = b.chars().collect(); + let (mut i, mut j) = (0usize, 0usize); + + while i < a.len() && j < b.len() { + if a[i].is_ascii_digit() && b[j].is_ascii_digit() { + let start_a = i; + while i < a.len() && a[i].is_ascii_digit() { + i += 1; + } + let start_b = j; + while j < b.len() && b[j].is_ascii_digit() { + j += 1; + } + + let num_a: String = a[start_a..i].iter().collect(); + let num_b: String = b[start_b..j].iter().collect(); + let trim_a = num_a.trim_start_matches('0'); + let trim_b = num_b.trim_start_matches('0'); + + // Sem zeros à esquerda, mais dígitos significa número maior. + let ord = trim_a.len().cmp(&trim_b.len()).then_with(|| trim_a.cmp(trim_b)); + if ord != Ordering::Equal { + return ord; + } + let ord = num_a.len().cmp(&num_b.len()); + if ord != Ordering::Equal { + return ord; + } + } else { + let ca = a[i].to_lowercase().next().unwrap_or(a[i]); + let cb = b[j].to_lowercase().next().unwrap_or(b[j]); + let ord = ca.cmp(&cb); + if ord != Ordering::Equal { + return ord; + } + i += 1; + j += 1; + } + } + + (a.len() - i).cmp(&(b.len() - j)) +} + /// Mutable viewer state shared between the window and its controllers. /// /// The UI is edge-triggered: cursor near the top reveals the header, @@ -103,6 +307,10 @@ struct ViewerState { /// `Option<...>` semantics for graceful no-ops post-close. window_weak: glib::WeakRef, zoom: ZoomState, + texture: Option, + zoom_pointer: Option<(f64, f64)>, + pending_zoom: Option, + zoom_source: Option, /// Natural pixel dimensions of the currently-loaded image. natural: (u32, u32), /// Toolbar view holding the header bar; revealed/hidden by edge-reveal. @@ -118,8 +326,14 @@ struct ViewerState { film_strip_scroll: gtk::ScrolledWindow, /// One button per file so we can flip `.active-thumb` on navigation. film_strip_buttons: Vec, + film_strip_start: usize, + film_strip_box: gtk::Box, + thumbnail_cancel: Arc, /// When true (accessibility override), chrome stays pinned visible. ui_pinned: bool, + autohide_ui: bool, + autohide_delay_ms: u64, + slideshow_interval_s: u32, /// Theater mode darkens the chrome around the picture, no fullscreen. /// Enabled by default — the photo-viewer paradigm. theater_enabled: bool, @@ -136,16 +350,26 @@ struct ViewerState { /// Histograma RGB da imagem atual: `[0..256]=R, [256..512]=G, [512..768]=B`. /// Recalculado em cada `update_display`. histogram: [u32; 768], + /// Decode and edit rendering happen away from GTK's main loop. A + /// generation token makes rapid navigation discard stale results. + loader: ViewerLoader, + load_generation: u64, + loading_spinner: gtk::Spinner, /// Dialog de propriedades atualmente aberto, se houver. Evita que /// cliques repetidos no botão `i` empilhem janelas. props_dialog: Option, /// Edições pendentes (rotate/flip) aplicadas in-memory sobre a /// imagem decodificada. Vazio = arquivo "limpo". `Ctrl+S` grava e - /// limpa; navegar de arquivo também limpa. + /// limpa; a navegação é bloqueada enquanto houver alterações. pending_ops: Vec, + /// Operations removed by Undo, available to Redo until a new edit or + /// navigation invalidates the branch. + redo_ops: Vec, + /// True while an atomic encode is running off the GTK thread. + saving: bool, } -/// Edição in-memory no estilo Loupe: o user gira/espelha sem tocar o +/// Edição in-memory: o usuário gira/espelha sem tocar o /// arquivo; `•` aparece no título e só no `Ctrl+S` o disco é gravado. /// As ops são acumuladas em `ViewerState.pending_ops` e reaplicadas por /// cima do decode original a cada push. @@ -158,6 +382,95 @@ enum EditOp { FlipV, } +#[derive(Debug)] +struct LoadRequest { + generation: u64, + path: PathBuf, + ops: Vec, +} + +#[derive(Debug)] +struct DecodedFrame { + generation: u64, + path: PathBuf, + pixels: Result, +} + +/// One coalescing decode worker per viewer. When the user holds an arrow +/// key, only the latest queued request survives; a decode already in flight +/// may finish, but its generation is ignored by the UI. +struct ViewerLoader { + pending: Arc<(Mutex>, Condvar)>, + results: Arc>>, + stopped: Arc, +} + +impl ViewerLoader { + fn new() -> Self { + let pending = Arc::new((Mutex::new(None::), Condvar::new())); + let results = Arc::new(Mutex::new(None)); + let stopped = Arc::new(AtomicBool::new(false)); + let worker_pending = pending.clone(); + let worker_results = results.clone(); + let worker_stopped = stopped.clone(); + std::thread::Builder::new() + .name("bigiris-viewer-decode".to_string()) + .spawn(move || loop { + let request = { + let (lock, ready) = &*worker_pending; + let mut slot = lock.lock().expect("viewer decode queue poisoned"); + while slot.is_none() && !worker_stopped.load(Ordering::Relaxed) { + slot = ready.wait(slot).expect("viewer decode queue poisoned"); + } + if worker_stopped.load(Ordering::Relaxed) { + break; + } + slot.take().expect("request checked above") + }; + let pixels = crate::codec::with_cancel(worker_stopped.clone(), || { + decode_frame(&request.path, &request.ops) + }) + .map_err(|e| e.to_string()); + if worker_stopped.load(Ordering::Relaxed) { + break; + } + // Replace stale results instead of retaining unbounded RGBA buffers. + *worker_results.lock().expect("viewer result slot poisoned") = Some(DecodedFrame { + generation: request.generation, + path: request.path, + pixels, + }); + }) + .expect("could not start viewer decoder"); + Self { pending, results, stopped } + } + + fn request(&self, request: LoadRequest) { + let (lock, ready) = &*self.pending; + *lock.lock().expect("viewer decode queue poisoned") = Some(request); + ready.notify_one(); + } + + fn take_result(&self) -> Option { + self.results.lock().expect("viewer result slot poisoned").take() + } + + fn stop(&self) { + // Hold the condition-variable mutex to avoid a lost wakeup during shutdown. + let (lock, ready) = &*self.pending; + let mut slot = lock.lock().expect("viewer decode queue poisoned"); + self.stopped.store(true, Ordering::Relaxed); + *slot = None; + ready.notify_one(); + } +} + +impl Drop for ViewerLoader { + fn drop(&mut self) { + self.stop(); + } +} + #[derive(Debug, Clone, Copy)] enum ZoomState { /// Fit inside viewport, preserve aspect, never enlarge past 1:1. @@ -179,12 +492,37 @@ impl ViewerState { } } -fn build_window(app: &adw::Application, files: Rc>) -> adw::ApplicationWindow { +fn build_window( + app: &adw::Application, + files: Rc>, + start_idx: usize, +) -> adw::ApplicationWindow { + build_window_tracked(app, files, start_idx).0 +} + +fn build_window_tracked( + app: &adw::Application, + files: Rc>, + start_idx: usize, +) -> (adw::ApplicationWindow, std::rc::Weak>) { + let settings = app_settings(); + let window_width = settings.as_ref().map_or(1280, |s| s.int("window-width")).max(480); + let window_height = settings.as_ref().map_or(800, |s| s.int("window-height")).max(360); + let start_maximized = settings.as_ref().is_some_and(|s| s.boolean("window-maximized")); + let autohide_ui = settings.as_ref().is_none_or(|s| s.boolean("autohide-ui")); + let autohide_delay_ms = + settings.as_ref().map_or(DEFAULT_EDGE_HIDE_MS, |s| u64::from(s.uint("autohide-delay-ms"))); + let filmstrip_visible = settings.as_ref().is_none_or(|s| s.boolean("filmstrip-visible")); + let slideshow_interval_s = + settings.as_ref().map_or(DEFAULT_SLIDESHOW_INTERVAL_S, |s| s.uint("slideshow-interval-s")); let window = adw::ApplicationWindow::builder() .application(app) - .default_width(1024) - .default_height(720) + .default_width(window_width) + .default_height(window_height) .build(); + if start_maximized { + window.maximize(); + } let title = adw::WindowTitle::new("BigIris", ""); let header = adw::HeaderBar::new(); @@ -256,16 +594,7 @@ fn build_window(app: &adw::Application, files: Rc>) -> adw::Applica .height_request(96) .build(); - let mut film_strip_buttons: Vec = Vec::with_capacity(files.len()); - for _ in files.iter() { - let picture = gtk::Picture::new(); - picture.set_content_fit(gtk::ContentFit::Contain); - picture.set_size_request(THUMB_EDGE as i32, THUMB_EDGE as i32); - let btn = gtk::Button::builder().child(&picture).build(); - btn.add_css_class("flat"); - film_strip_box.append(&btn); - film_strip_buttons.push(btn); - } + let film_strip_buttons = Vec::new(); let strip_revealer = gtk::Revealer::builder() .transition_type(gtk::RevealerTransitionType::SlideUp) @@ -277,7 +606,7 @@ fn build_window(app: &adw::Application, files: Rc>) -> adw::Applica .build(); // If there are fewer than two files the strip is meaningless — keep // it collapsed regardless of edge reveals. - if files.len() < 2 { + if files.len() < 2 || !filmstrip_visible { strip_revealer.set_can_target(false); strip_revealer.set_visible(false); } @@ -296,31 +625,48 @@ fn build_window(app: &adw::Application, files: Rc>) -> adw::Applica .build(); histogram_area.add_css_class("histogram-overlay"); + let loading_spinner = gtk::Spinner::builder() + .halign(gtk::Align::Center) + .valign(gtk::Align::Center) + .visible(false) + .build(); + loading_spinner.add_css_class("viewer-loading"); + let overlay = gtk::Overlay::new(); overlay.set_child(Some(&stack)); overlay.add_overlay(&osd_revealer); overlay.add_overlay(&strip_revealer); overlay.add_overlay(&histogram_area); + overlay.add_overlay(&loading_spinner); let toolbar_view = adw::ToolbarView::new(); toolbar_view.add_top_bar(&header); toolbar_view.set_content(Some(&overlay)); - // Start with the header hidden — edge-triggered reveal handles the rest. - toolbar_view.set_reveal_top_bars(false); + // The first contact exposes the controls. It auto-hides shortly after + // the image appears, while edge reveal keeps the canvas uncluttered. + toolbar_view.set_reveal_top_bars(true); window.set_content(Some(&toolbar_view)); // Theater mode on by default: photos look best on black, and it's what // Apple Photos / Lightroom / mpv converge on for viewing surfaces. window.add_css_class("theater"); + let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default().to_ascii_lowercase(); + if desktop.contains("kde") || std::env::var_os("KDE_FULL_SESSION").is_some() { + window.add_css_class("plasma"); + } let state = Rc::new(RefCell::new(ViewerState { files: files.clone(), - idx: 0, + idx: start_idx.min(files.len().saturating_sub(1)), picture: picture.clone(), scroller: scroller.clone(), title: title.clone(), window_weak: gtk::glib::object::ObjectExt::downgrade(&window), zoom: ZoomState::Fit, + texture: None, + zoom_pointer: None, + pending_zoom: None, + zoom_source: None, natural: (0, 0), toolbar_view: toolbar_view.clone(), osd_revealer: osd_revealer.clone(), @@ -328,7 +674,13 @@ fn build_window(app: &adw::Application, files: Rc>) -> adw::Applica strip_revealer: strip_revealer.clone(), film_strip_scroll: film_strip_scroll.clone(), film_strip_buttons: film_strip_buttons.clone(), + film_strip_start: 0, + film_strip_box: film_strip_box.clone(), + thumbnail_cancel: Arc::new(AtomicBool::new(false)), ui_pinned: false, + autohide_ui, + autohide_delay_ms, + slideshow_interval_s, theater_enabled: true, top_hide_source: None, bottom_hide_source: None, @@ -337,32 +689,176 @@ fn build_window(app: &adw::Application, files: Rc>) -> adw::Applica slideshow_source: None, histogram_area: histogram_area.clone(), histogram: [0u32; 768], + loader: ViewerLoader::new(), + load_generation: 0, + loading_spinner: loading_spinner.clone(), props_dialog: None, pending_ops: Vec::new(), + redo_ops: Vec::new(), + saving: false, })); - // draw callback lê o snapshot mais recente do histograma em state. + if let Some(settings) = settings { + let window = window.clone(); + window.connect_close_request(move |window| { + let maximized = window.is_maximized(); + let _ = settings.set_boolean("window-maximized", maximized); + if !maximized { + let _ = settings.set_int("window-width", window.width().max(480)); + let _ = settings.set_int("window-height", window.height().max(360)); + } + glib::Propagation::Proceed + }); + } + + // Closing is also a navigation boundary: never lose an edit because Esc + // or the title-bar close button was pressed accidentally. { let state = state.clone(); + window.connect_close_request(move |window| { + if state.borrow().saving { + state.borrow().osd_label.set_text(&gettext("Aguarde o salvamento terminar")); + pulse_osd(&state); + return glib::Propagation::Stop; + } + if state.borrow().pending_ops.is_empty() { + return glib::Propagation::Proceed; + } + if window.visible_dialog().is_some() { + return glib::Propagation::Stop; + } + let alert = adw::AlertDialog::builder() + .heading(gettext("Descartar alterações?")) + .body(gettext("A imagem tem edições que ainda não foram salvas.")) + .build(); + alert.add_response("cancel", &gettext("Continuar editando")); + alert.add_response("discard", &gettext("Descartar")); + alert.set_response_appearance("discard", adw::ResponseAppearance::Destructive); + alert.set_default_response(Some("cancel")); + alert.set_close_response("cancel"); + let state = state.clone(); + let closing_window = window.clone(); + alert.connect_response(Some("discard"), move |_, _| { + let mut s = state.borrow_mut(); + s.pending_ops.clear(); + s.redo_ops.clear(); + drop(s); + closing_window.close(); + }); + alert.present(Some(window)); + glib::Propagation::Stop + }); + } + + { + let state = Rc::downgrade(&state); + window.connect_destroy(move |_| { + if let Some(state) = state.upgrade() { + let mut s = state.borrow_mut(); + s.loader.stop(); + for source in [ + s.top_hide_source.take(), + s.bottom_hide_source.take(), + s.osd_hide_source.take(), + s.cursor_hide_source.take(), + s.slideshow_source.take(), + ] + .into_iter() + .flatten() + { + source.remove(); + } + zoom::cancel_pending(&mut s); + s.props_dialog.take(); + s.texture.take(); + s.pending_zoom = None; + s.picture.set_paintable(None::<&gdk::Paintable>); + } + }); + } + + // draw callback lê o snapshot mais recente do histograma em state. + { + let state = Rc::downgrade(&state); histogram_area.set_draw_func(move |_, cr, w, h| { - draw_histogram(&state, cr, w, h); + if let Some(state) = state.upgrade() { + draw_histogram(&state, cr, w, h); + } }); } // Quick-buttons do header (esquerda) e menu hamburger (direita). // Registrados aqui porque todos fecham sobre `state`. install_viewer_actions(app, &window, &state); + let open_btn = gtk::Button::from_icon_name("document-open-symbolic"); + open_btn.set_tooltip_text(Some(&gettext("Abrir imagens (Ctrl+O)"))); + open_btn.set_action_name(Some("win.open")); + header.pack_start(&open_btn); + + let previous_btn = gtk::Button::from_icon_name("go-previous-symbolic"); + previous_btn.set_tooltip_text(Some(&gettext("Imagem anterior"))); + previous_btn.set_action_name(Some("win.previous")); + previous_btn.set_visible(!files.is_empty()); + header.pack_start(&previous_btn); + + let next_btn = gtk::Button::from_icon_name("go-next-symbolic"); + next_btn.set_tooltip_text(Some(&gettext("Próxima imagem"))); + next_btn.set_action_name(Some("win.next")); + next_btn.set_visible(!files.is_empty()); + header.pack_start(&next_btn); + let info_btn = gtk::Button::from_icon_name("dialog-information-symbolic"); info_btn.set_tooltip_text(Some(&gettext("Propriedades da imagem (Ctrl+I)"))); info_btn.set_action_name(Some("win.info")); // Só faz sentido com imagem carregada — na dashboard fica escondido. // Fullscreen fica como F11 e double-click, sem botão dedicado no header - // (Loupe parity: libadwaita já dá minimize/maximize/close no lado direito). + // A barra já mantém os controles de janela no lado direito. let has_files = !state.borrow().files.is_empty(); info_btn.set_visible(has_files); header.pack_start(&info_btn); header.pack_end(&build_header_menu_button(&state)); + let fullscreen_btn = gtk::Button::from_icon_name("view-fullscreen-symbolic"); + fullscreen_btn.set_tooltip_text(Some(&gettext("Tela cheia (F11)"))); + fullscreen_btn.set_action_name(Some("win.fullscreen")); + fullscreen_btn.set_visible(has_files); + header.pack_end(&fullscreen_btn); + + let zoom_in_btn = gtk::Button::from_icon_name("zoom-in-symbolic"); + zoom_in_btn.set_tooltip_text(Some(&gettext("Aproximar"))); + zoom_in_btn.set_action_name(Some("win.zoom-in")); + zoom_in_btn.set_visible(has_files); + header.pack_end(&zoom_in_btn); + + let fit_btn = gtk::Button::from_icon_name("zoom-fit-best-symbolic"); + fit_btn.set_tooltip_text(Some(&gettext("Ajustar à janela"))); + fit_btn.set_action_name(Some("win.zoom-fit")); + fit_btn.set_visible(has_files); + header.pack_end(&fit_btn); + + let zoom_out_btn = gtk::Button::from_icon_name("zoom-out-symbolic"); + zoom_out_btn.set_tooltip_text(Some(&gettext("Afastar"))); + zoom_out_btn.set_action_name(Some("win.zoom-out")); + zoom_out_btn.set_visible(has_files); + header.pack_end(&zoom_out_btn); + + // Poll decoded frames without blocking GTK. At most the newest valid + // generation is painted, so fast directory navigation remains smooth. + { + let state = state.clone(); + glib::timeout_add_local(std::time::Duration::from_millis(16), move || { + if state.borrow().window_weak.upgrade().is_none_or(|window| !window.is_visible()) { + state.borrow().loader.stop(); + return glib::ControlFlow::Break; + } + let frame = state.borrow().loader.take_result(); + if let Some(frame) = frame { + apply_decoded_frame(&state, frame); + } + glib::ControlFlow::Continue + }); + } + if has_files { stack.set_visible_child_name("picture"); update_display(&state); @@ -374,9 +870,12 @@ fn build_window(app: &adw::Application, files: Rc>) -> adw::Applica // Keyboard: file nav, zoom presets, fullscreen, quit. let key_controller = gtk::EventControllerKey::new(); { - let state = state.clone(); - key_controller - .connect_key_pressed(move |_, key, _code, modifier| handle_key(&state, key, modifier)); + let state = Rc::downgrade(&state); + key_controller.connect_key_pressed(move |_, key, _code, modifier| { + state + .upgrade() + .map_or(glib::Propagation::Proceed, |state| handle_key(&state, key, modifier)) + }); } window.add_controller(key_controller); @@ -387,9 +886,15 @@ fn build_window(app: &adw::Application, files: Rc>) -> adw::Applica gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::VERTICAL); scroll_controller.set_propagation_phase(gtk::PropagationPhase::Capture); { - let state = state.clone(); - let scroller = scroller.clone(); + let state = Rc::downgrade(&state); + let scroller = scroller.downgrade(); scroll_controller.connect_scroll(move |controller, _dx, dy| { + let Some(state) = state.upgrade() else { + return glib::Propagation::Proceed; + }; + let Some(scroller) = scroller.upgrade() else { + return glib::Propagation::Proceed; + }; // The scroll controller lives on the window, so event.position() // is in window-root coords. Convert to scroller-local coords so // adjust_zoom can anchor the re-scroll on the pixel under the @@ -400,12 +905,9 @@ fn build_window(app: &adw::Application, files: Rc>) -> adw::Applica let pt = graphene::Point::new(wx as f32, wy as f32); root.compute_point(&scroller, &pt).map(|p| (f64::from(p.x()), f64::from(p.y()))) }); - if dy < -0.01 { - adjust_zoom(&state, ZOOM_STEP, cursor); - pulse_osd(&state); - glib::Propagation::Stop - } else if dy > 0.01 { - adjust_zoom(&state, 1.0 / ZOOM_STEP, cursor); + let surface = controller.unit() == gdk::ScrollUnit::Surface; + if let Some(factor) = zoom::scroll_factor(dy, surface) { + adjust_zoom(&state, factor, cursor); pulse_osd(&state); glib::Propagation::Stop } else { @@ -415,14 +917,34 @@ fn build_window(app: &adw::Application, files: Rc>) -> adw::Applica } window.add_controller(scroll_controller); + let zoom_motion = gtk::EventControllerMotion::new(); + zoom_motion.set_name(Some("bigiris-zoom-pointer")); + for entering in [true, false] { + let weak = Rc::downgrade(&state); + let record = move |_: >k::EventControllerMotion, x, y| { + if let Some(state) = weak.upgrade() { + state.borrow_mut().zoom_pointer = Some((x, y)); + } + }; + if entering { + zoom_motion.connect_enter(record); + } else { + zoom_motion.connect_motion(record); + } + } + scroller.add_controller(zoom_motion); + // Double-click anywhere on the image → toggle fullscreen (matches the // F11 shortcut). Uses GestureClick with n-press=2 so single clicks still // fall through to the drag-to-pan gesture. let double_click = gtk::GestureClick::builder().button(1).build(); double_click.set_propagation_phase(gtk::PropagationPhase::Bubble); { - let state = state.clone(); + let state = Rc::downgrade(&state); double_click.connect_pressed(move |_, n_press, _, _| { + let Some(state) = state.upgrade() else { + return; + }; if n_press == 2 { let win = state.borrow().window().clone(); win.set_fullscreened(!win.is_fullscreen()); @@ -438,16 +960,27 @@ fn build_window(app: &adw::Application, files: Rc>) -> adw::Applica drag.set_propagation_phase(gtk::PropagationPhase::Capture); let drag_anchor: Rc> = Rc::new(Cell::new((0.0, 0.0))); { - let scr = scroller.clone(); + let scr = scroller.downgrade(); let anchor = drag_anchor.clone(); + let weak = Rc::downgrade(&state); drag.connect_drag_begin(move |_, _, _| { + if let Some(state) = weak.upgrade() { + zoom::cancel_pending(&mut state.borrow_mut()); + refresh_title(&state); + } + let Some(scr) = scr.upgrade() else { + return; + }; anchor.set((scr.hadjustment().value(), scr.vadjustment().value())); }); } { - let scr = scroller.clone(); + let scr = scroller.downgrade(); let anchor = drag_anchor.clone(); drag.connect_drag_update(move |_, dx, dy| { + let Some(scr) = scr.upgrade() else { + return; + }; let (hx0, vy0) = anchor.get(); scr.hadjustment().set_value(hx0 - dx); scr.vadjustment().set_value(vy0 - dy); @@ -455,88 +988,150 @@ fn build_window(app: &adw::Application, files: Rc>) -> adw::Applica } scroller.add_controller(drag); - // Film strip: wire each thumb button to jump to its index. - for (i, btn) in film_strip_buttons.iter().enumerate() { - let state = state.clone(); - btn.connect_clicked(move |_| { - { - let mut s = state.borrow_mut(); - if s.idx == i { - return; - } - s.idx = i; - s.zoom = ZoomState::Fit; - } - update_display(&state); - pulse_osd(&state); - }); - } - - // Lazy-populate thumbnails via the GLib main loop: one file per idle - // tick keeps the UI responsive even for huge selections. Bail quietly - // if a file fails to decode; the slot stays empty rather than crashing. - { - let files = files.clone(); - let buttons = film_strip_buttons.clone(); - let mut queue: std::collections::VecDeque = (0..files.len()).collect(); - glib::idle_add_local(move || { - let Some(idx) = queue.pop_front() else { - return glib::ControlFlow::Break; - }; - if let Some(btn) = buttons.get(idx) { - if let Some(pic) = btn.child().and_downcast::() { - if let Ok(tex) = load_thumbnail(&files[idx]) { - pic.set_paintable(Some(&tex)); - } - } - } - glib::ControlFlow::Continue - }); - } + let weak_state = Rc::downgrade(&state); + window.connect_unrealize(move |_| { + if let Some(state) = weak_state.upgrade() { + state.borrow().thumbnail_cancel.store(true, Ordering::Release); + } + }); + rebuild_film_strip(&state); // Edge-triggered reveal: cursor near the top shows the header, near // the bottom shows the film strip, everywhere else only restores the // cursor. Each zone has its own hide timer. let motion = gtk::EventControllerMotion::new(); { - let state = state.clone(); - motion.connect_motion(move |_, _x, y| on_pointer_motion(&state, y)); + let state = Rc::downgrade(&state); + motion.connect_motion(move |_, _x, y| { + if let Some(state) = state.upgrade() { + on_pointer_motion(&state, y); + } + }); } window.add_controller(motion); - // Kick off: chrome hidden, OSD pulse so the user sees which file is - // loaded, cursor visible until the first idle tick. + // Kick off: controls are discoverable, then get out of the way. refresh_film_strip_active(&state); refresh_osd(&state); pulse_osd(&state); + reveal_top(&state); schedule_cursor_hide(&state); - window + (window, Rc::downgrade(&state)) } -/// Decode `path` and build a small GDK texture for the film strip. -/// Uses [`image::GenericImageView::thumbnail`] — fast, avoids loading the -/// full-res pixels into GPU memory. -fn load_thumbnail(path: &Path) -> Result> { +/// Decode a small film-strip frame off the GTK thread. The source codec may +/// still need to read the full image, but only the downscaled RGBA buffer is +/// handed to GDK/GPU memory. +fn decode_thumbnail(path: &Path) -> Result<(Vec, u32, u32), Box> { let img = bigimage_core::open_image(path)?; let thumb = img.thumbnail(THUMB_EDGE, THUMB_EDGE).to_rgba8(); let (w, h) = thumb.dimensions(); - let stride = (w as usize) * 4; - let bytes = glib::Bytes::from_owned(thumb.into_raw()); - let tex = - gdk::MemoryTexture::new(w as i32, h as i32, gdk::MemoryFormat::R8g8b8a8, &bytes, stride); - Ok(tex.upcast()) + Ok((thumb.into_raw(), w, h)) +} + +/// Only 41 widgets and thumbnail requests exist, independently of folder size. +fn rebuild_film_strip(state: &Rc>) { + const PAGE: usize = 41; + let (container, files, start, cancel, window) = { + let mut s = state.borrow_mut(); + s.thumbnail_cancel.store(true, Ordering::Release); + s.thumbnail_cancel = Arc::new(AtomicBool::new(false)); + s.film_strip_start = s.idx / PAGE * PAGE; + let container = s.film_strip_box.clone(); + s.film_strip_buttons.clear(); + ( + container, + s.files.clone(), + s.film_strip_start, + s.thumbnail_cancel.clone(), + s.window().downgrade(), + ) + }; + while let Some(child) = container.first_child() { + container.remove(&child); + } + let mut buttons = Vec::new(); + for (offset, path) in files.iter().skip(start).take(PAGE).enumerate() { + let picture = gtk::Picture::new(); + picture.set_content_fit(gtk::ContentFit::Contain); + picture.set_size_request(THUMB_EDGE as i32, THUMB_EDGE as i32); + let button = gtk::Button::builder().child(&picture).build(); + let name = path.file_name().unwrap_or_default().to_string_lossy(); + button.set_tooltip_text(Some(&name)); + button.update_property(&[gtk::accessible::Property::Label(&name)]); + button.add_css_class("flat"); + let weak = Rc::downgrade(state); + button.connect_clicked(move |_| { + let Some(state) = weak.upgrade() else { return }; + if !can_leave_current(&state) { + return; + } + { + let mut s = state.borrow_mut(); + s.idx = start + offset; + s.zoom = ZoomState::Fit; + } + update_display(&state); + pulse_osd(&state); + }); + container.append(&button); + buttons.push(button); + } + let weak_buttons: Vec<_> = buttons.iter().map(|b| b.downgrade()).collect(); + state.borrow_mut().film_strip_buttons = buttons; + let paths: Vec<_> = files.iter().skip(start).take(PAGE).cloned().collect(); + let (tx, rx) = mpsc::sync_channel(2); + std::thread::spawn(move || { + crate::codec::with_cancel(cancel.clone(), || { + for (index, path) in paths.iter().enumerate() { + if cancel.load(Ordering::Acquire) { + break; + } + if let Ok((raw, w, h)) = decode_thumbnail(path) { + if tx.send((index, raw, w, h)).is_err() { + break; + } + } + } + }) + }); + glib::timeout_add_local(Duration::from_millis(32), move || { + if window.upgrade().is_none_or(|w| !w.is_visible()) { + return glib::ControlFlow::Break; + } + loop { + match rx.try_recv() { + Ok((index, raw, w, h)) => { + if let Some(button) = weak_buttons.get(index).and_then(|w| w.upgrade()) { + if let Some(picture) = button.child().and_downcast::() { + picture.set_paintable(Some(&texture_from_rgba(raw, w, h))); + } + } + } + Err(mpsc::TryRecvError::Empty) => return glib::ControlFlow::Continue, + Err(mpsc::TryRecvError::Disconnected) => return glib::ControlFlow::Break, + } + } + }); } /// Toggle `.active-thumb` so the film strip mirrors the current selection. /// Also autoscrolls the strip so the active thumb stays in view; uses the /// button's computed bounds (allocation() was deprecated in GTK 4.12). fn refresh_film_strip_active(state: &Rc>) { + let needs_rebuild = { + let s = state.borrow(); + s.idx < s.film_strip_start || s.idx >= s.film_strip_start + s.film_strip_buttons.len() + }; + if needs_rebuild { + rebuild_film_strip(state); + } let s = state.borrow(); let strip = &s.film_strip_scroll; let viewport_w = strip.width() as f64; for (i, btn) in s.film_strip_buttons.iter().enumerate() { - if i == s.idx { + if i + s.film_strip_start == s.idx { btn.add_css_class("active-thumb"); // compute_bounds returns coords in the parent (the HBox inside // the scroll's viewport); that's what hadjustment speaks. @@ -555,18 +1150,32 @@ fn refresh_film_strip_active(state: &Rc>) { } /// Register a CssProvider carrying [`THEATER_CSS`] on the default display. -/// Runs once per activation; duplicate calls are harmless because the same -/// provider just replaces the previous rules. +/// Install once per thread/display so repeated activation retains no providers. fn install_theater_css() { - let provider = gtk::CssProvider::new(); - provider.load_from_string(THEATER_CSS); - if let Some(display) = gdk::Display::default() { - gtk::style_context_add_provider_for_display( - &display, - &provider, - gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, - ); - } + thread_local! { static INSTALLED: Cell = const { Cell::new(false) }; } + INSTALLED.with(|installed| { + if installed.get() { + return; + } + if let Some(display) = gdk::Display::default() { + let provider = gtk::CssProvider::new(); + provider.load_from_string(THEATER_CSS); + gtk::style_context_add_provider_for_display( + &display, + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + installed.set(true); + } + }); +} + +/// Return settings only when the schema is installed. Development builds +/// continue to run from the source tree without requiring a global schema. +fn app_settings() -> Option { + let source = gio::SettingsSchemaSource::default()?; + let schema = source.lookup(APP_ID, true)?; + Some(gio::Settings::new_full(&schema, None::<&gio::SettingsBackend>, None)) } /// Manual film-strip reveal (F key). Just forwards to [`reveal_bottom`] @@ -610,7 +1219,7 @@ fn osd_text(state: &Rc>) -> String { .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| path.display().to_string()); - let zoom = match s.zoom { + let zoom = match zoom::requested_zoom(&s) { ZoomState::Fit => "Ajustar".to_string(), ZoomState::Scale(x) => format!("{}%", (x * 100.0).round() as i32), }; @@ -659,21 +1268,27 @@ fn reveal_top(state: &Rc>) { s.toolbar_view.set_reveal_top_bars(true); } cancel_top_hide(state); - if state.borrow().ui_pinned { + if state.borrow().ui_pinned || !state.borrow().autohide_ui { return; } + let delay = state.borrow().autohide_delay_ms; let clone = state.clone(); - let id = - glib::timeout_add_local_once(std::time::Duration::from_millis(EDGE_HIDE_MS), move || { - hide_top(&clone) - }); + let id = glib::timeout_add_local_once(std::time::Duration::from_millis(delay), move || { + hide_top(&clone) + }); state.borrow_mut().top_hide_source = Some(id); } fn hide_top(state: &Rc>) { let mut s = state.borrow_mut(); s.top_hide_source = None; - s.toolbar_view.set_reveal_top_bars(false); + let window = s.window(); + let keyboard_in_header = gtk::prelude::GtkWindowExt::focus(&window).is_some_and(|widget| { + !widget.is_ancestor(&s.scroller) && !widget.is_ancestor(&s.film_strip_scroll) + }); + if !keyboard_in_header && !adw::StyleManager::default().is_high_contrast() { + s.toolbar_view.set_reveal_top_bars(false); + } } fn cancel_top_hide(state: &Rc>) { @@ -692,22 +1307,36 @@ fn reveal_bottom(state: &Rc>) { } s.strip_revealer.set_reveal_child(true); } + // A faixa nasce rolada no começo. Como o visualizador agora pode abrir + // no meio da pasta (clique num arquivo qualquer), a miniatura ativa + // ficaria fora de vista na primeira vez que o strip aparece. Recentrar + // fica para o idle seguinte porque, enquanto o revealer não alocou a + // faixa, `compute_bounds` devolve largura zero e a conta de centragem + // não teria em que se apoiar. + { + let clone = state.clone(); + glib::idle_add_local_once(move || refresh_film_strip_active(&clone)); + } cancel_bottom_hide(state); - if state.borrow().ui_pinned { + if state.borrow().ui_pinned || !state.borrow().autohide_ui { return; } + let delay = state.borrow().autohide_delay_ms; let clone = state.clone(); - let id = - glib::timeout_add_local_once(std::time::Duration::from_millis(EDGE_HIDE_MS), move || { - hide_bottom(&clone) - }); + let id = glib::timeout_add_local_once(std::time::Duration::from_millis(delay), move || { + hide_bottom(&clone) + }); state.borrow_mut().bottom_hide_source = Some(id); } fn hide_bottom(state: &Rc>) { let mut s = state.borrow_mut(); s.bottom_hide_source = None; - s.strip_revealer.set_reveal_child(false); + let keyboard_in_strip = gtk::prelude::GtkWindowExt::focus(&s.window()) + .is_some_and(|widget| widget.is_ancestor(&s.film_strip_scroll)); + if !keyboard_in_strip && !adw::StyleManager::default().is_high_contrast() { + s.strip_revealer.set_reveal_child(false); + } } fn cancel_bottom_hide(state: &Rc>) { @@ -753,7 +1382,7 @@ fn cancel_osd_hide(state: &Rc>) { fn schedule_cursor_hide(state: &Rc>) { let s = state.borrow(); - if s.ui_pinned { + if s.ui_pinned || !s.autohide_ui || adw::StyleManager::default().is_high_contrast() { return; } // AdwDialog (incluindo AdwAboutDialog) a partir da libadwaita 1.5 é @@ -814,9 +1443,17 @@ fn toggle_ui_pin(state: &Rc>) { } s.window().set_cursor(None); } else { + let s = state.borrow(); + if !s.autohide_ui { + s.toolbar_view.set_reveal_top_bars(true); + if s.film_strip_buttons.len() >= 2 { + s.strip_revealer.set_reveal_child(true); + } + s.window().set_cursor(None); + return; + } // Collapse back to the clean default. Upcoming motion events will // re-reveal edges on demand. - let s = state.borrow(); s.toolbar_view.set_reveal_top_bars(false); s.strip_revealer.set_reveal_child(false); s.osd_revealer.set_reveal_child(false); @@ -903,6 +1540,9 @@ fn handle_key( match new_idx { Some(idx) => { + if !can_leave_current(state) { + return glib::Propagation::Stop; + } { let mut s = state.borrow_mut(); s.idx = idx; @@ -920,10 +1560,13 @@ fn handle_key( /// Avança o índice em +1 (com wrap). Reseta zoom pra Fit como a nav /// normal faz, pra cada imagem aparecer inteira no slideshow. -fn advance_one(state: &Rc>) { +fn advance_one(state: &Rc>) -> bool { let total = state.borrow().files.len(); if total < 2 { - return; + return false; + } + if !can_leave_current(state) { + return false; } { let mut s = state.borrow_mut(); @@ -932,10 +1575,28 @@ fn advance_one(state: &Rc>) { } update_display(state); pulse_osd(state); + true +} + +fn navigate_relative(state: &Rc>, delta: isize) { + let total = state.borrow().files.len(); + if total < 2 { + return; + } + if !can_leave_current(state) { + return; + } + { + let mut s = state.borrow_mut(); + s.idx = (s.idx as isize + delta).rem_euclid(total as isize) as usize; + s.zoom = ZoomState::Fit; + } + update_display(state); + pulse_osd(state); } /// Alterna o slideshow (Space). Ignora quando só há um arquivo. Enquanto -/// ativo, um timer de `SLIDESHOW_INTERVAL_S` avança pra próxima imagem; +/// ativo, um timer configurável avança pra próxima imagem; /// uma nova pulsação no OSD confirma cada tick. fn toggle_slideshow(state: &Rc>) { let total = state.borrow().files.len(); @@ -953,8 +1614,9 @@ fn toggle_slideshow(state: &Rc>) { pulse_osd(state); return; } + let interval = state.borrow().slideshow_interval_s.max(1); let clone = state.clone(); - let id = glib::timeout_add_seconds_local(SLIDESHOW_INTERVAL_S, move || { + let id = glib::timeout_add_seconds_local(interval, move || { // Se a janela fechou entre ticks, encerra — evita mexer em widgets // já removidos do tree. { @@ -965,12 +1627,16 @@ fn toggle_slideshow(state: &Rc>) { return glib::ControlFlow::Break; } } - advance_one(&clone); - glib::ControlFlow::Continue + if advance_one(&clone) { + glib::ControlFlow::Continue + } else { + clone.borrow_mut().slideshow_source = None; + glib::ControlFlow::Break + } }); state.borrow_mut().slideshow_source = Some(id); let s = state.borrow(); - s.osd_label.set_text(&format!("Slideshow: {}s", SLIDESHOW_INTERVAL_S)); + s.osd_label.set_text(&format!("Slideshow: {interval}s")); drop(s); pulse_osd(state); } @@ -1057,59 +1723,29 @@ fn draw_histogram(state: &Rc>, cr: >k::cairo::Context, w: fn apply_edit(state: &Rc>, op: EditOp) { let path = { let s = state.borrow(); - if s.files.is_empty() { + if s.files.is_empty() || s.saving { return; } s.files[s.idx].clone() }; - state.borrow_mut().pending_ops.push(op); + { + let mut s = state.borrow_mut(); + s.pending_ops.push(op); + s.redo_ops.clear(); + } rebuild_edited_texture(state, &path); refresh_title(state); - sync_save_action(state); + sync_edit_actions(state); pulse_osd(state); } /// Reaplica `pending_ops` sobre a imagem no disco e substitui a textura. -/// Se não houver ops, equivale a recarregar o arquivo via `load_texture`. +/// Se não houver ops, recarrega o arquivo pelo worker do viewer. /// Falhas de decode logam e não mexem no paintable (o user vê a imagem /// anterior — melhor do que uma tela preta). fn rebuild_edited_texture(state: &Rc>, path: &Path) { let ops = state.borrow().pending_ops.clone(); - if ops.is_empty() { - // Caminho rápido: sem edições, usa a versão crua do disco direto. - if let Ok((tex, nat, hist)) = load_texture(path) { - let mut s = state.borrow_mut(); - s.natural = nat; - s.histogram = hist; - s.picture.set_paintable(Some(&tex)); - apply_zoom(&s.picture, s.zoom, nat); - s.histogram_area.queue_draw(); - } - return; - } - let img = match bigimage_core::open_image(path) { - Ok(i) => i, - Err(e) => { - tracing::error!(?path, error = %e, "edit: falha ao reabrir pra edição"); - return; - } - }; - let edited = ops.iter().fold(img, |acc, op| apply_op_to_image(acc, *op)); - let rgba = edited.to_rgba8(); - let (w, h) = rgba.dimensions(); - let raw = rgba.into_raw(); - let histogram = compute_histogram(&raw); - let stride = (w as usize) * 4; - let bytes = glib::Bytes::from_owned(raw); - let tex = - gdk::MemoryTexture::new(w as i32, h as i32, gdk::MemoryFormat::R8g8b8a8, &bytes, stride); - let paintable: gdk::Texture = tex.upcast(); - let mut s = state.borrow_mut(); - s.natural = (w, h); - s.histogram = histogram; - s.picture.set_paintable(Some(&paintable)); - apply_zoom(&s.picture, s.zoom, (w, h)); - s.histogram_area.queue_draw(); + request_frame(state, path.to_path_buf(), ops); } fn apply_op_to_image(img: image::DynamicImage, op: EditOp) -> image::DynamicImage { @@ -1125,14 +1761,64 @@ fn apply_op_to_image(img: image::DynamicImage, op: EditOp) -> image::DynamicImag /// Liga/desliga a action `win.save-edits` pra o item de menu e o acelerador /// Ctrl+S ficarem grey quando não houver nada pra salvar. Complementa o /// `•` no título como pista visual do estado "dirty". -fn sync_save_action(state: &Rc>) { +fn sync_edit_actions(state: &Rc>) { let s = state.borrow(); let has_ops = !s.pending_ops.is_empty(); + let idle = !s.saving; if let Some(action) = s.window().lookup_action("save-edits") { if let Some(simple) = action.downcast_ref::() { - simple.set_enabled(has_ops); + simple.set_enabled(has_ops && idle); } } + if let Some(action) = s.window().lookup_action("undo") { + if let Some(simple) = action.downcast_ref::() { + simple.set_enabled(has_ops && idle); + } + } + if let Some(action) = s.window().lookup_action("redo") { + if let Some(simple) = action.downcast_ref::() { + simple.set_enabled(!s.redo_ops.is_empty() && idle); + } + } + for name in ["rotate-cw", "rotate-ccw", "rotate-180", "flip-h", "flip-v"] { + if let Some(action) = s.window().lookup_action(name) { + if let Some(simple) = action.downcast_ref::() { + simple.set_enabled(idle && !s.files.is_empty()); + } + } + } +} + +fn undo_edit(state: &Rc>) { + let path = { + let mut s = state.borrow_mut(); + if s.saving { + return; + } + let Some(op) = s.pending_ops.pop() else { return }; + s.redo_ops.push(op); + s.files[s.idx].clone() + }; + rebuild_edited_texture(state, &path); + refresh_title(state); + sync_edit_actions(state); + pulse_osd(state); +} + +fn redo_edit(state: &Rc>) { + let path = { + let mut s = state.borrow_mut(); + if s.saving { + return; + } + let Some(op) = s.redo_ops.pop() else { return }; + s.pending_ops.push(op); + s.files[s.idx].clone() + }; + rebuild_edited_texture(state, &path); + refresh_title(state); + sync_edit_actions(state); + pulse_osd(state); } /// Salva as edições pendentes no próprio arquivo. Confirma antes — em @@ -1140,7 +1826,7 @@ fn sync_save_action(state: &Rc>) { fn save_edits(state: &Rc>) { let (window, path, ops) = { let s = state.borrow(); - if s.pending_ops.is_empty() || s.files.is_empty() { + if s.pending_ops.is_empty() || s.files.is_empty() || s.saving { return; } (s.window().clone(), s.files[s.idx].clone(), s.pending_ops.clone()) @@ -1171,177 +1857,94 @@ fn save_edits(state: &Rc>) { alert.set_close_response("cancel"); let state = state.clone(); - alert.connect_response(Some("save"), move |_, _| match write_edits_to_disk(&path, &ops) { - Ok(()) => { - state.borrow_mut().pending_ops.clear(); - refresh_title(&state); - sync_save_action(&state); - let s = state.borrow(); - s.osd_label.set_text(&gettext("Salvo")); - drop(s); - pulse_osd(&state); - } - Err(e) => { - tracing::error!(?path, error = %e, "save: falha ao gravar edições"); - let s = state.borrow(); - s.osd_label.set_text(&gettext("Falha ao salvar")); - drop(s); - pulse_osd(&state); + alert.connect_response(Some("save"), move |_, _| { + { + let mut s = state.borrow_mut(); + s.saving = true; + s.loading_spinner.set_visible(true); + s.loading_spinner.start(); } + sync_edit_actions(&state); + + let (tx, rx) = mpsc::channel(); + let worker_path = path.clone(); + let worker_ops = ops.clone(); + std::thread::Builder::new() + .name("bigiris-save".to_string()) + .spawn(move || { + let result = + write_edits_to_disk(&worker_path, &worker_ops).map_err(|e| e.to_string()); + let _ = tx.send(result); + }) + .expect("could not start save worker"); + + let state = state.clone(); + let path = path.clone(); + glib::timeout_add_local(std::time::Duration::from_millis(32), move || { + match rx.try_recv() { + Ok(result) => { + { + let mut s = state.borrow_mut(); + s.saving = false; + s.loading_spinner.stop(); + s.loading_spinner.set_visible(false); + if result.is_ok() { + s.pending_ops.clear(); + s.redo_ops.clear(); + } + } + refresh_title(&state); + sync_edit_actions(&state); + match result { + Ok(()) => state.borrow().osd_label.set_text(&gettext("Salvo")), + Err(error) => { + tracing::error!(?path, %error, "save: falha ao gravar edições"); + state.borrow().osd_label.set_text(&gettext("Falha ao salvar")); + } + } + pulse_osd(&state); + glib::ControlFlow::Break + } + Err(mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, + Err(mpsc::TryRecvError::Disconnected) => { + let mut s = state.borrow_mut(); + s.saving = false; + s.loading_spinner.stop(); + s.loading_spinner.set_visible(false); + drop(s); + sync_edit_actions(&state); + glib::ControlFlow::Break + } + } + }); }); alert.present(Some(&window)); } -/// Grava uma versão editada em cima do arquivo original. Re-decoda pro -/// estado atual das ops e usa `DynamicImage::save` — que escolhe o -/// encoder pela extensão. Nada de pipeline bigimage-core aqui: evita -/// arrastar política de sufixo/overwrite pra dentro do caminho do viewer. +/// Grava em arquivo temporário no mesmo diretório, sincroniza e só então +/// renomeia por cima do destino. Assim, queda de energia ou falha do encoder +/// deixa o original intacto. Permissões existentes são preservadas. fn write_edits_to_disk(path: &Path, ops: &[EditOp]) -> Result<(), Box> { - let img = bigimage_core::open_image(path)?; + let target = if path.symlink_metadata()?.file_type().is_symlink() { + path.canonicalize()? + } else { + path.to_path_buf() + }; + bigimage_core::content::ensure_static(&target)?; + let img = bigimage_core::open_image(&target)?; let edited = ops.iter().fold(img, |acc, op| apply_op_to_image(acc, *op)); - edited.save(path)?; + let format = Format::from_image_format(image::ImageFormat::from_path(&target)?) + .ok_or("formato sem encoder suportado")?; + bigimage_core::save_image( + edited, + target, + format, + &EncodeOptions::default(), + OverwritePolicy::Replace, + )?; Ok(()) } -fn zoom_in(state: &Rc>) { - adjust_zoom(state, ZOOM_STEP, None); -} - -fn zoom_out(state: &Rc>) { - adjust_zoom(state, 1.0 / ZOOM_STEP, None); -} - -/// Rescale by `factor`, optionally anchoring the change at a cursor point -/// (in scroller-local coords) so the image pixel under the cursor stays -/// still. With `cursor = None` we just rescale and let the scroll position -/// clamp itself — fine for keyboard shortcuts. -fn adjust_zoom(state: &Rc>, factor: f64, cursor: Option<(f64, f64)>) { - let (natural, viewport, current, hx, vy) = { - let s = state.borrow(); - ( - s.natural, - (s.scroller.width(), s.scroller.height()), - s.zoom, - s.scroller.hadjustment().value(), - s.scroller.vadjustment().value(), - ) - }; - if natural.0 == 0 || natural.1 == 0 { - return; - } - let old_scale = effective_scale(current, natural, viewport); - let new_scale = (old_scale * factor).clamp(MIN_ZOOM, MAX_ZOOM); - if (new_scale - old_scale).abs() < 1e-6 { - return; - } - - set_zoom(state, ZoomState::Scale(new_scale)); - - // Re-anchor the scroll so the image pixel under `cursor` stays still. - // The Picture uses halign=Center/valign=Center, so when it's smaller - // than the viewport it's padded equally on both sides — that padding - // has to enter the formula or the anchor point drifts. We compute the - // old/new offsets from pre/post-zoom widget sizes vs. viewport size. - if let Some((cx, cy)) = cursor { - let (target_hx, max_hx, new_pic_w) = - anchor_axis(cx, hx, old_scale, new_scale, f64::from(natural.0), f64::from(viewport.0)); - let (target_vy, max_vy, new_pic_h) = - anchor_axis(cy, vy, old_scale, new_scale, f64::from(natural.1), f64::from(viewport.1)); - // The scroller's adjustments update their `upper` only after the - // layout pass that follows our set_width_request(). A single - // idle_add_local_once fires BEFORE that pass on many GTK builds, - // so set_value() gets clamped to the stale upper (0 in Fit mode) - // and the anchor snaps to the top-left. Poll up to a handful of - // idle ticks, apply when upper catches up, and bail defensively. - let state = state.clone(); - let attempts = Rc::new(Cell::new(0u32)); - glib::idle_add_local(move || { - let s = state.borrow(); - let hadj = s.scroller.hadjustment(); - let vadj = s.scroller.vadjustment(); - let ready_x = max_hx == 0.0 || hadj.upper() >= new_pic_w - 0.5; - let ready_y = max_vy == 0.0 || vadj.upper() >= new_pic_h - 0.5; - let tries = attempts.get(); - if ready_x && ready_y { - hadj.set_value(target_hx); - vadj.set_value(target_vy); - return glib::ControlFlow::Break; - } - if tries >= 10 { - // Give up gracefully — apply whatever the adjustment will - // accept so the anchor at least lands in-range. Better - // than looping forever on a misbehaving driver. - hadj.set_value(target_hx); - vadj.set_value(target_vy); - return glib::ControlFlow::Break; - } - attempts.set(tries + 1); - glib::ControlFlow::Continue - }); - } -} - -/// Math-only core of cursor-anchored zoom on one axis. Returns -/// `(target_adj, max_adj, new_pic_size)`: -/// * `target_adj` — the adjustment value (pre-clamp-to-layout) that keeps -/// the image pixel under the cursor in place after scaling. Already -/// clamped to the post-zoom content range so it can be applied directly. -/// * `max_adj` — upper bound of the adjustment (`pic - viewport`, floored -/// at 0), returned so the caller can detect "layout not ready yet". -/// * `new_pic_size` — post-zoom picture size, same rationale. -/// -/// Coordinate model (see `adjust_zoom` doc): the Picture is centered in -/// the viewport when smaller than it, so we fold the letterbox offset into -/// the projection. With `cursor=None` the caller skips this entirely and -/// lets the adjustment clamp naturally. -fn anchor_axis( - cursor: f64, - adj: f64, - old_scale: f64, - new_scale: f64, - natural: f64, - viewport: f64, -) -> (f64, f64, f64) { - let ratio = new_scale / old_scale; - let old_pic = natural * old_scale; - let new_pic = natural * new_scale; - let old_off = ((viewport - old_pic) / 2.0).max(0.0); - let new_off = ((viewport - new_pic) / 2.0).max(0.0); - let new_adj = ratio * (cursor + adj - old_off) - cursor + new_off; - let max_adj = (new_pic - viewport).max(0.0); - (new_adj.clamp(0.0, max_adj), max_adj, new_pic) -} - -fn set_zoom(state: &Rc>, zoom: ZoomState) { - { - let mut s = state.borrow_mut(); - s.zoom = zoom; - apply_zoom(&s.picture, zoom, s.natural); - } - refresh_title(state); -} - -fn apply_zoom(picture: >k::Picture, zoom: ZoomState, natural: (u32, u32)) { - match zoom { - ZoomState::Fit => { - picture.set_content_fit(gtk::ContentFit::Contain); - picture.set_width_request(-1); - picture.set_height_request(-1); - } - ZoomState::Scale(scale) => { - // Always Contain: ContentFit::Fill stretches to the allocated - // size (which the ScrolledWindow may grow past our size_request - // for small-scale images), producing visible distortion. Contain - // keeps the aspect ratio even if the allocation overshoots. - picture.set_content_fit(gtk::ContentFit::Contain); - let w = ((natural.0 as f64) * scale).round().max(1.0) as i32; - let h = ((natural.1 as f64) * scale).round().max(1.0) as i32; - picture.set_width_request(w); - picture.set_height_request(h); - } - } -} - /// Effective on-screen scale factor — used to anchor zoom-in from `Fit` at /// the current visual size, so the first click doesn't teleport the image. fn effective_scale(zoom: ZoomState, natural: (u32, u32), viewport: (i32, i32)) -> f64 { @@ -1363,40 +1966,74 @@ fn update_display(state: &Rc>) { let s = state.borrow(); s.files[s.idx].clone() }; - // Trocar de arquivo descarta edições pendentes — o estilo Loupe - // clássico pediria confirmação; aqui priorizamos velocidade de nav - // (arrow-spam, slideshow) e sinalizamos via OSD quando algo foi - // descartado. - let had_pending = !state.borrow().pending_ops.is_empty(); - state.borrow_mut().pending_ops.clear(); - sync_save_action(state); - match load_texture(&path) { - Ok((tex, nat, hist)) => { - let mut s = state.borrow_mut(); - s.natural = nat; - s.histogram = hist; - s.picture.set_paintable(Some(&tex)); - apply_zoom(&s.picture, s.zoom, nat); + // Reaching another file starts a new edit history. `pending_ops` is + // guaranteed empty by the navigation guard, while a redo-only branch + // (after undoing back to the original) must not leak into the next image. + state.borrow_mut().redo_ops.clear(); + sync_edit_actions(state); + request_frame(state, path, Vec::new()); + refresh_title(state); +} + +/// Never discard edits as a side effect of navigation. The user can save or +/// undo them first; slideshow also stops so it cannot repeatedly nag or skip. +fn can_leave_current(state: &Rc>) -> bool { + if state.borrow().pending_ops.is_empty() { + return true; + } + state.borrow().osd_label.set_text(&gettext("Salve ou desfaça as alterações antes de navegar")); + pulse_osd(state); + false +} + +fn request_frame(state: &Rc>, path: PathBuf, ops: Vec) { + let mut s = state.borrow_mut(); + zoom::cancel_pending(&mut s); + s.load_generation = s.load_generation.wrapping_add(1); + s.loading_spinner.set_visible(true); + s.loading_spinner.start(); + s.loader.request(LoadRequest { generation: s.load_generation, path, ops }); +} + +fn apply_decoded_frame(state: &Rc>, frame: DecodedFrame) { + let is_current = { + let s = state.borrow(); + frame.generation == s.load_generation + && s.files.get(s.idx).is_some_and(|path| path == &frame.path) + }; + if !is_current { + return; + } + + let mut s = state.borrow_mut(); + if !s.saving { + s.loading_spinner.stop(); + s.loading_spinner.set_visible(false); + } + zoom::cancel_pending(&mut s); + match frame.pixels { + Ok((raw, w, h, histogram)) => { + let texture = texture_from_rgba(raw, w, h); + s.natural = (w, h); + s.histogram = histogram; + s.texture = Some(texture); + apply_zoom(&s); s.histogram_area.queue_draw(); } - Err(e) => { - tracing::error!(?path, error = %e, "falha ao carregar imagem"); - let s = state.borrow(); + Err(error) => { + tracing::error!(path = ?frame.path, %error, "falha ao carregar imagem"); + s.texture = None; s.picture.set_paintable(gdk::Paintable::NONE); + s.osd_label.set_text(&gettext("Não foi possível abrir a imagem")); + drop(s); + pulse_osd(state); } } - refresh_title(state); - if had_pending { - let s = state.borrow(); - s.osd_label.set_text(&gettext("Edições descartadas")); - drop(s); - pulse_osd(state); - } } -/// Constrói o botão-menu hamburger. Itens seguem a convenção Loupe -/// (viewer nativo do GNOME): abrir, copiar, imprimir, metadados, lixeira, -/// atalhos, sobre — separados por seções pra agrupar por semântica. +/// Constrói um menu curto para as funções menos frequentes. A navegação e +/// abertura ficam visíveis na barra, enquanto ferramentas robustas aparecem +/// sob demanda — o equilíbrio "simples por padrão, poderoso quando preciso". /// Ações ficam em `win.` (registradas em [`install_viewer_actions`]). fn build_header_menu_button(state: &Rc>) -> gtk::MenuButton { let menu = gio::Menu::new(); @@ -1410,9 +2047,11 @@ fn build_header_menu_button(state: &Rc>) -> gtk::MenuButton s_actions.append(Some(&gettext("Imprimir…")), Some("win.print")); menu.append_section(None, &s_actions); - // Edição in-memory estilo Loupe: giros/espelhos aplicados na hora; + // Edição in-memory: giros/espelhos aplicados na hora; // só vão pra disco quando o user pedir Salvar (Ctrl+S). let s_edit = gio::Menu::new(); + s_edit.append(Some(&gettext("Desfazer")), Some("win.undo")); + s_edit.append(Some(&gettext("Refazer")), Some("win.redo")); s_edit.append(Some(&gettext("Girar à direita")), Some("win.rotate-cw")); s_edit.append(Some(&gettext("Girar à esquerda")), Some("win.rotate-ccw")); s_edit.append(Some(&gettext("Girar 180°")), Some("win.rotate-180")); @@ -1449,8 +2088,11 @@ fn build_header_menu_button(state: &Rc>) -> gtk::MenuButton // auto-hide do ToolbarView dispara com o cursor já dentro do popover // e fecha o menu sozinho (flicker visível: abre e some no mesmo frame). { - let state = state.clone(); + let state = Rc::downgrade(state); btn.connect_active_notify(move |b| { + let Some(state) = state.upgrade() else { + return; + }; if b.is_active() { cancel_top_hide(&state); state.borrow().toolbar_view.set_reveal_top_bars(true); @@ -1473,16 +2115,23 @@ fn install_viewer_actions( // Sobre — já existia, reinstalamos aqui pra ficar tudo num lugar. let about = gio::SimpleAction::new("about", None); { - let window = window.clone(); - about.connect_activate(move |_, _| present_about_dialog(&window)); + let window = window.downgrade(); + about.connect_activate(move |_, _| { + if let Some(window) = window.upgrade() { + present_about_dialog(&window); + } + }); } app.add_action(&about); // win.fullscreen — alterna F11; quick-button do header aciona a mesma. let fullscreen = gio::SimpleAction::new("fullscreen", None); { - let window = window.clone(); + let window = window.downgrade(); fullscreen.connect_activate(move |_, _| { + let Some(window) = window.upgrade() else { + return; + }; window.set_fullscreened(!window.is_fullscreen()); }); } @@ -1492,8 +2141,12 @@ fn install_viewer_actions( // escolhidos. Separar em nova janela mantém o histórico da atual. let open = gio::SimpleAction::new("open", None); { - let window = window.clone(); - open.connect_activate(move |_, _| open_file_chooser(&window)); + let window = window.downgrade(); + open.connect_activate(move |_, _| { + if let Some(window) = window.upgrade() { + open_file_chooser(&window); + } + }); } window.add_action(&open); @@ -1501,17 +2154,94 @@ fn install_viewer_actions( // senão o menu mostra opção clicável que não faz nada, e itens de // ação inválida chegam a fechar o popover ao hover no GTK4. let has_files = !state.borrow().files.is_empty(); + let has_multiple = state.borrow().files.len() > 1; + + for (name, delta) in [("previous", -1isize), ("next", 1isize)] { + let action = gio::SimpleAction::new(name, None); + action.set_enabled(has_multiple); + let state = Rc::downgrade(state); + action.connect_activate(move |_, _| { + let Some(state) = state.upgrade() else { + return; + }; + navigate_relative(&state, delta); + }); + window.add_action(&action); + } + + for (name, operation) in [ + ("zoom-in", zoom_in as fn(&Rc>)), + ("zoom-out", zoom_out as fn(&Rc>)), + ] { + let action = gio::SimpleAction::new(name, None); + action.set_enabled(has_files); + let state = Rc::downgrade(state); + action.connect_activate(move |_, _| { + let Some(state) = state.upgrade() else { + return; + }; + operation(&state); + pulse_osd(&state); + }); + window.add_action(&action); + } + let zoom_fit = gio::SimpleAction::new("zoom-fit", None); + zoom_fit.set_enabled(has_files); + { + let state = Rc::downgrade(state); + zoom_fit.connect_activate(move |_, _| { + let Some(state) = state.upgrade() else { + return; + }; + set_zoom(&state, ZoomState::Fit); + pulse_osd(&state); + }); + } + window.add_action(&zoom_fit); // win.copy — copia a textura atual pro clipboard. Qualquer app (Slack, // Ctrl+V num chat) consegue colar. let copy = gio::SimpleAction::new("copy", None); copy.set_enabled(has_files); { - let state = state.clone(); - copy.connect_activate(move |_, _| copy_current_to_clipboard(&state)); + let state = Rc::downgrade(state); + copy.connect_activate(move |_, _| { + let Some(state) = state.upgrade() else { + return; + }; + copy_current_to_clipboard(&state); + }); } window.add_action(©); + let undo = gio::SimpleAction::new("undo", None); + undo.set_enabled(false); + { + let state = Rc::downgrade(state); + undo.connect_activate(move |_, _| { + let Some(state) = state.upgrade() else { + return; + }; + undo_edit(&state); + }); + } + window.add_action(&undo); + app.set_accels_for_action("win.undo", &["z"]); + + let redo = gio::SimpleAction::new("redo", None); + redo.set_enabled(false); + { + let state = Rc::downgrade(state); + redo.connect_activate(move |_, _| { + let Some(state) = state.upgrade() else { + return; + }; + redo_edit(&state); + }); + } + window.add_action(&redo); + app.set_accels_for_action("win.redo", &["z", "y"]); + // win.rotate-{cw,ccw,180}, win.flip-{h,v} — edições in-memory. Acumulam // em `pending_ops`, atualizam textura na hora e marcam o título com `•`. let edits: [(&str, EditOp, &[&str]); 5] = [ @@ -1525,8 +2255,13 @@ fn install_viewer_actions( let action = gio::SimpleAction::new(name, None); action.set_enabled(has_files); { - let state = state.clone(); - action.connect_activate(move |_, _| apply_edit(&state, op)); + let state = Rc::downgrade(state); + action.connect_activate(move |_, _| { + let Some(state) = state.upgrade() else { + return; + }; + apply_edit(&state, op); + }); } window.add_action(&action); if !accels.is_empty() { @@ -1535,12 +2270,17 @@ fn install_viewer_actions( } // win.save-edits — grava as `pending_ops` no arquivo. Sai grey quando - // não há nada pra salvar (controlado por `sync_save_action`). + // não há nada pra salvar (controlado por `sync_edit_actions`). let save_edits_action = gio::SimpleAction::new("save-edits", None); save_edits_action.set_enabled(false); { - let state = state.clone(); - save_edits_action.connect_activate(move |_, _| save_edits(&state)); + let state = Rc::downgrade(state); + save_edits_action.connect_activate(move |_, _| { + let Some(state) = state.upgrade() else { + return; + }; + save_edits(&state); + }); } window.add_action(&save_edits_action); app.set_accels_for_action("win.save-edits", &["S"]); @@ -1550,8 +2290,13 @@ fn install_viewer_actions( let print = gio::SimpleAction::new("print", None); print.set_enabled(has_files); { - let state = state.clone(); - print.connect_activate(move |_, _| print_current(&state)); + let state = Rc::downgrade(state); + print.connect_activate(move |_, _| { + let Some(state) = state.upgrade() else { + return; + }; + print_current(&state); + }); } window.add_action(&print); @@ -1559,8 +2304,13 @@ fn install_viewer_actions( let info = gio::SimpleAction::new("info", None); info.set_enabled(has_files); { - let state = state.clone(); - info.connect_activate(move |_, _| show_metadata_for_current(&state)); + let state = Rc::downgrade(state); + info.connect_activate(move |_, _| { + let Some(state) = state.upgrade() else { + return; + }; + show_metadata_for_current(&state); + }); } window.add_action(&info); @@ -1570,8 +2320,11 @@ fn install_viewer_actions( let crop = gio::SimpleAction::new("crop", None); crop.set_enabled(has_files); { - let state = state.clone(); + let state = Rc::downgrade(state); crop.connect_activate(move |_, _| { + let Some(state) = state.upgrade() else { + return; + }; let path = { let s = state.borrow(); if s.files.is_empty() { @@ -1591,9 +2344,12 @@ fn install_viewer_actions( for (name, dialog) in [("remove-bg", "remove-bg"), ("upscale", "upscale")] { let action = gio::SimpleAction::new(name, None); action.set_enabled(has_files); - let state = state.clone(); + let state = Rc::downgrade(state); let dialog = dialog.to_string(); action.connect_activate(move |_, _| { + let Some(state) = state.upgrade() else { + return; + }; let path = { let s = state.borrow(); if s.files.is_empty() { @@ -1617,8 +2373,13 @@ fn install_viewer_actions( let trash = gio::SimpleAction::new("trash", None); trash.set_enabled(has_files); { - let state = state.clone(); - trash.connect_activate(move |_, _| trash_current(&state)); + let state = Rc::downgrade(state); + trash.connect_activate(move |_, _| { + let Some(state) = state.upgrade() else { + return; + }; + trash_current(&state); + }); } window.add_action(&trash); @@ -1632,8 +2393,12 @@ fn install_viewer_actions( // legível que gtk::ShortcutsWindow, e combina com o resto do visual. let shortcuts = gio::SimpleAction::new("shortcuts", None); { - let window = window.clone(); - shortcuts.connect_activate(move |_, _| present_shortcuts_dialog(&window)); + let window = window.downgrade(); + shortcuts.connect_activate(move |_, _| { + if let Some(window) = window.upgrade() { + present_shortcuts_dialog(&window); + } + }); } window.add_action(&shortcuts); @@ -1689,24 +2454,16 @@ fn open_file_chooser(window: &adw::ApplicationWindow) { /// Usa `gdk::MemoryTexture` — qualquer consumidor que aceite imagens /// (paste em chat, editor de imagem) recebe a textura. fn copy_current_to_clipboard(state: &Rc>) { - let (window, path) = { + let (window, texture) = { let s = state.borrow(); if s.files.is_empty() { return; } - (s.window().clone(), s.files[s.idx].clone()) - }; - let Ok(img) = bigimage_core::open_image(&path) else { - tracing::warn!(?path, "copy: falha ao decodificar"); - return; + (s.window().clone(), s.texture.clone()) }; - let rgba = img.to_rgba8(); - let (w, h) = rgba.dimensions(); - let stride = (w as usize) * 4; - let bytes = glib::Bytes::from_owned(rgba.into_raw()); - let tex = - gdk::MemoryTexture::new(w as i32, h as i32, gdk::MemoryFormat::R8g8b8a8, &bytes, stride); - window.clipboard().set_texture(&tex); + if let Some(texture) = texture { + window.clipboard().set_texture(&texture); + } } /// Envia a imagem corrente pra impressora via gtk::PrintOperation. @@ -1720,51 +2477,108 @@ fn print_current(state: &Rc>) { } (s.window().clone(), s.files[s.idx].clone()) }; + let cancel = Arc::new(AtomicBool::new(false)); + let closing = cancel.clone(); + let closing_handler = window.connect_unrealize(move |_| closing.store(true, Ordering::Release)); + let input = path.clone(); + let (tx, rx) = mpsc::sync_channel(1); + window.set_cursor_from_name(Some("wait")); + std::thread::spawn(move || { + crate::codec::with_cancel(cancel, || { + let result = bigimage_core::open_image(input) + .map(|image| { + let rgba = image.to_rgba8(); + let size = rgba.dimensions(); + (premultiply_for_cairo(rgba.into_raw()), size) + }) + .map_err(|e| e.to_string()); + let _ = tx.send(result); + }); + }); + let weak = window.downgrade(); + let mut closing_handler = Some(closing_handler); + glib::timeout_add_local(Duration::from_millis(16), move || { + let Some(window) = weak.upgrade() else { return glib::ControlFlow::Break }; + if !window.is_visible() { + return glib::ControlFlow::Break; + } + let result = match rx.try_recv() { + Ok(result) => result, + Err(mpsc::TryRecvError::Empty) => return glib::ControlFlow::Continue, + Err(mpsc::TryRecvError::Disconnected) => Err("O preparo da impressão falhou".into()), + }; + if let Some(handler) = closing_handler.take() { + window.disconnect(handler); + } + window.set_cursor(None); + match result { + Ok((pixels, (width, height))) => { + if let Ok(surface) = gtk::cairo::ImageSurface::create_for_data( + pixels, + gtk::cairo::Format::ARgb32, + width as i32, + height as i32, + width as i32 * 4, + ) { + present_print_dialog(&window, &path, surface); + } else { + show_print_error(&window, "Não foi possível preparar a página"); + } + } + Err(error) => show_print_error(&window, &error), + } + glib::ControlFlow::Break + }); +} + +/// Cairo ARGB32 stores premultiplied components in native byte order. +fn premultiply_for_cairo(mut rgba: Vec) -> Vec { + for pixel in rgba.as_chunks_mut::<4>().0 { + let alpha = u32::from(pixel[3]); + let r = (u32::from(pixel[0]) * alpha + 127) / 255; + let g = (u32::from(pixel[1]) * alpha + 127) / 255; + let b = (u32::from(pixel[2]) * alpha + 127) / 255; + *pixel = ((alpha << 24) | (r << 16) | (g << 8) | b).to_ne_bytes(); + } + rgba +} + +fn show_print_error(window: &adw::ApplicationWindow, error: &str) { + let dialog = adw::AlertDialog::builder() + .heading(gettext("Não foi possível imprimir")) + .body(error) + .build(); + dialog.add_response("close", &gettext("Fechar")); + dialog.present(Some(window)); +} + +fn present_print_dialog( + window: &adw::ApplicationWindow, + path: &Path, + surface: gtk::cairo::ImageSurface, +) { let op = gtk::PrintOperation::new(); op.set_n_pages(1); op.set_job_name( &path .file_name() .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| "BigIris".to_string()), + .unwrap_or_else(|| "BigIris".into()), ); - - op.connect_draw_page(move |_, ctx, _page_nr| { - let Ok(img) = bigimage_core::open_image(&path) else { return }; - let rgba = img.to_rgba8(); - let (iw, ih) = rgba.dimensions(); - - // cairo::Format::Rgb24 e ARgb32 querem BGRA. Convertemos RGBA→BGRA - // in-place (só swap dos canais R↔B). - let mut bgra = rgba.into_raw(); - for px in bgra.chunks_exact_mut(4) { - px.swap(0, 2); - } - let stride = iw as i32 * 4; - let Ok(surface) = gtk::cairo::ImageSurface::create_for_data( - bgra, - gtk::cairo::Format::ARgb32, - iw as i32, - ih as i32, - stride, - ) else { - return; - }; - + op.connect_draw_page(move |_, ctx, _| { + let iw = f64::from(surface.width()); + let ih = f64::from(surface.height()); let cr = ctx.cairo_context(); - let pw = ctx.width(); - let ph = ctx.height(); - let scale = (pw / iw as f64).min(ph / ih as f64); - let dx = (pw - iw as f64 * scale) / 2.0; - let dy = (ph - ih as f64 * scale) / 2.0; - cr.translate(dx, dy); + let scale = (ctx.width() / iw).min(ctx.height() / ih); + cr.translate((ctx.width() - iw * scale) / 2.0, (ctx.height() - ih * scale) / 2.0); cr.scale(scale, scale); - let _ = cr.set_source_surface(&surface, 0.0, 0.0); - let _ = cr.paint(); + if let Err(error) = cr.set_source_surface(&surface, 0.0, 0.0).and_then(|_| cr.paint()) { + tracing::warn!(%error, "erro ao renderizar página"); + } }); - - if let Err(e) = op.run(gtk::PrintOperationAction::PrintDialog, Some(&window)) { - tracing::warn!(error = %e, "impressão cancelada/erro"); + if let Err(error) = op.run(gtk::PrintOperationAction::PrintDialog, Some(window)) { + tracing::warn!(%error, "erro no diálogo de impressão"); + show_print_error(window, &error.to_string()); } } @@ -1787,9 +2601,11 @@ fn show_metadata_for_current(state: &Rc>) { let dialog = build_properties_window(&app, &path); // Quando o user fecha, soltamos a referência — próximo clique abre fresco. { - let state = state.clone(); + let state = Rc::downgrade(state); dialog.connect_close_request(move |_| { - state.borrow_mut().props_dialog = None; + if let Some(state) = state.upgrade() { + state.borrow_mut().props_dialog = None; + } glib::Propagation::Proceed }); } @@ -1844,7 +2660,7 @@ fn trash_current(state: &Rc>) { fn present_shortcuts_dialog(parent: &adw::ApplicationWindow) { parent.set_cursor(None); // Usa o widget nativo `gtk::ShortcutsWindow` — layout denso estilo - // Loupe/GNOME com colunas, seções e chips de tecla renderizadas pelo + // Layout compacto com colunas, seções e chips de tecla renderizados pelo // próprio tema. Bem mais limpo do que texto em AlertDialog. let shortcuts: [(&str, &[(&str, &str)]); 6] = [ ( @@ -1878,6 +2694,8 @@ fn present_shortcuts_dialog(parent: &adw::ApplicationWindow) { ( "Edição", &[ + ("Z", "Desfazer"), + ("Z", "Refazer"), ("R", "Girar à direita"), ("R", "Girar à esquerda"), ("R", "Recortar…"), @@ -1912,10 +2730,7 @@ fn present_shortcuts_dialog(parent: &adw::ApplicationWindow) { window.present(); } -/// AdwAboutDialog padrão do GNOME — mostra nome, ícone, versão, licença, -/// desenvolvedores e links de suporte. Acionado pelo item "Sobre" do -/// menu da header. Esse é o análogo ao bloco "Detalhes / Relatar / Créditos -/// / Aviso legal" do Big OCR PDF que o usuário mostrou nas screenshots. +/// About dialog with project, license and support links. fn present_about_dialog(parent: &adw::ApplicationWindow) { // Restaura o cursor antes de apresentar — o viewer pode tê-lo // escondido por idle; dentro do AdwAboutDialog (modal in-window) @@ -1927,18 +2742,16 @@ fn present_about_dialog(parent: &adw::ApplicationWindow) { .application_icon(APP_ID) .version(env!("CARGO_PKG_VERSION")) .developer_name("Leonardo Athayde") - .copyright("© 2026 Leonardo Athayde · Comunidade BigLinux") + .copyright("© 2026 Leonardo Athayde e contribuidores") .license_type(gtk::License::Gpl30) - .website("https://biglinux.com.br") + .website("https://github.com/xathay/bigiris") .issue_url("https://github.com/xathay/bigiris/issues") - .support_url("https://forum.biglinux.com.br") .developers(vec!["Leonardo Athayde".to_string()]) - .designers(vec!["Comunidade BigLinux".to_string()]) + .designers(vec!["Leonardo Athayde".to_string(), "Comunidade BigLinux".to_string()]) .translator_credits("Leonardo Athayde ") .comments( - "Visualizador e conversor de imagens do BigLinux. \ - Rápido, acessível por teclado e integrado ao gerenciador \ - de arquivos via clique-direito.", + "Visualizador e conversor de imagens para Linux. Rápido, \ + acessível por teclado e integrado ao gerenciador de arquivos.", ) .build(); about.present(Some(parent)); @@ -1954,9 +2767,8 @@ fn build_dashboard(window: &adw::ApplicationWindow) -> gtk::Widget { .icon_name("com.biglinux.Iris") .title(gettext("Bem-vindo ao BigIris")) .description( - "Visualizador e conversor de imagens do BigLinux.\n\ - Pensado para ser rápido, acessível por teclado e integrado\n\ - ao gerenciador de arquivos.", + "Abra uma imagem para visualizar sem distrações ou processe\n\ + uma pasta inteira com as ferramentas Prisma.", ) .build(); status.add_css_class("compact"); @@ -1964,12 +2776,11 @@ fn build_dashboard(window: &adw::ApplicationWindow) -> gtk::Widget { // Feature cards — cada um abre um picker de imagens e relança o // próprio binário no modo certo (viewer, --dialog=batch, etc.). // Vai-e-volta evita reimplementar o pipeline em cima do dashboard. - let features = adw::PreferencesGroup::builder() - .title(gettext("O que dá pra fazer")) - .margin_top(16) - .build(); + let features = + adw::PreferencesGroup::builder().title(gettext("Ferramentas")).margin_top(16).build(); for (title, subtitle, icon, action) in FEATURE_HIGHLIGHTS { let row = adw::ActionRow::builder() + .use_markup(false) .title(*title) .subtitle(*subtitle) .activatable(action.is_some()) @@ -1981,9 +2792,13 @@ fn build_dashboard(window: &adw::ApplicationWindow) -> gtk::Widget { let arrow = gtk::Image::from_icon_name("go-next-symbolic"); arrow.add_css_class("dim-label"); row.add_suffix(&arrow); - let window = window.clone(); + let window = window.downgrade(); let cmd = *cmd; - row.connect_activated(move |_| dispatch_feature(&window, cmd)); + row.connect_activated(move |_| { + if let Some(window) = window.upgrade() { + dispatch_feature(&window, cmd); + } + }); } features.add(&row); } @@ -1999,15 +2814,30 @@ fn build_dashboard(window: &adw::ApplicationWindow) -> gtk::Widget { .css_classes(["pill", "suggested-action"]) .build(); { - let window = window.clone(); - open_btn.connect_clicked(move |_| open_files_dialog(&window)); + let window = window.downgrade(); + open_btn.connect_clicked(move |_| { + if let Some(window) = window.upgrade() { + open_files_dialog(&window); + } + }); } actions.append(&open_btn); + let folder_btn = gtk::Button::builder().label("Processar uma pasta…").build(); + { + let window = window.downgrade(); + folder_btn.connect_clicked(move |_| { + if let Some(window) = window.upgrade() { + open_batch_folder_dialog(&window); + } + }); + } + actions.append(&folder_btn); + let tip = gtk::Label::builder() .label( - "💡 Clique com o direito em imagens no Nautilus / Dolphin / \ - Nemo / Thunar para ver todas as operações rápidas.", + "No gerenciador de arquivos, clique com o botão direito em imagens\n\ + para acessar as operações rápidas do BigIris.", ) .justify(gtk::Justification::Center) .wrap(true) @@ -2032,7 +2862,7 @@ fn build_dashboard(window: &adw::ApplicationWindow) -> gtk::Widget { const FEATURE_HIGHLIGHTS: &[(&str, &str, &str, Option<&str>)] = &[ ( "Visualizar imagens", - "JPEG, PNG, WebP, AVIF, GIF, TIFF, BMP e RAW de câmeras", + "JPEG, PNG, WebP, AVIF, GIF, TIFF, BMP, QOI, HDR e OpenEXR", "image-x-generic-symbolic", Some("viewer"), ), @@ -2042,32 +2872,31 @@ const FEATURE_HIGHLIGHTS: &[(&str, &str, &str, Option<&str>)] = &[ "document-save-as-symbolic", Some("batch"), ), - ( - "Ajustar cores", - "Brilho, contraste, saturação e gamma — em lote", - "image-adjust-symbolic", - Some("adjust"), - ), - ( - "Remover fundo (IA)", - "BiRefNet 100% local, sem enviar fotos para a nuvem", - "applications-science-symbolic", - Some("remove-bg"), - ), - ( - "Criar GIF animado", - "A partir de uma sequência de frames, com controle de loop e fps", - "video-x-generic-symbolic", - Some("animate"), - ), ( "Clique direito no gerenciador", - "Integração com Nautilus, Dolphin, Nemo, Thunar, PCManFM-Qt", + "Ações rápidas integradas ao Dolphin e a outros gerenciadores", "view-list-symbolic", None, ), ]; +fn open_batch_folder_dialog(window: &adw::ApplicationWindow) { + let dialog = gtk::FileDialog::builder() + .title(gettext("Selecionar pasta com imagens")) + .modal(true) + .build(); + let parent = window.clone(); + let window = window.clone(); + dialog.select_folder(Some(&parent), gio::Cancellable::NONE, move |result| { + let Ok(folder) = result else { return }; + let Some(path) = folder.path() else { return }; + let Ok(exe) = std::env::current_exe() else { return }; + if std::process::Command::new(exe).arg("--dialog=batch").arg(path).spawn().is_ok() { + window.close(); + } + }); +} + /// Pega os arquivos escolhidos e relança o próprio binário no modo /// apropriado. Tratamos "viewer" como caso-especial (executável sem /// subcomando); o resto vira `--dialog=`, exceto `remove-bg` que @@ -2094,9 +2923,6 @@ fn dispatch_feature(window: &adw::ApplicationWindow, action: &'static str) { "image/gif", "image/tiff", "image/bmp", - "image/heic", - "image/heif", - "image/jxl", ] { filter.add_mime_type(mime); } @@ -2151,9 +2977,6 @@ fn open_files_dialog(window: &adw::ApplicationWindow) { "image/gif", "image/tiff", "image/bmp", - "image/heic", - "image/heif", - "image/jxl", ] { filter.add_mime_type(mime); } @@ -2196,7 +3019,7 @@ fn refresh_title(state: &Rc>) { .unwrap_or_else(|| path.display().to_string()); // `•` à esquerda sinaliza edições pendentes. Convenção Loupe/TextEditor. let name = if s.pending_ops.is_empty() { base_name } else { format!("• {base_name}") }; - let zoom_desc = match s.zoom { + let zoom_desc = match zoom::requested_zoom(&s) { ZoomState::Fit => "Ajustar".to_string(), ZoomState::Scale(x) => format!("{}%", (x * 100.0).round() as i32), }; @@ -2209,19 +3032,23 @@ fn refresh_title(state: &Rc>) { } type Histogram = [u32; 768]; -type LoadedTexture = (gdk::Texture, (u32, u32), Histogram); +type RawFrame = (Vec, u32, u32, Histogram); -fn load_texture(path: &Path) -> Result> { +fn decode_frame(path: &Path, ops: &[EditOp]) -> Result> { let img = bigimage_core::open_image(path)?; - let rgba = img.to_rgba8(); + let edited = ops.iter().fold(img, |acc, op| apply_op_to_image(acc, *op)); + let rgba = edited.to_rgba8(); let (w, h) = rgba.dimensions(); let raw = rgba.into_raw(); let histogram = compute_histogram(&raw); + Ok((raw, w, h, histogram)) +} + +fn texture_from_rgba(raw: Vec, w: u32, h: u32) -> gdk::Texture { let stride = (w as usize) * 4; let bytes = glib::Bytes::from_owned(raw); - let tex = - gdk::MemoryTexture::new(w as i32, h as i32, gdk::MemoryFormat::R8g8b8a8, &bytes, stride); - Ok((tex.upcast(), (w, h), histogram)) + gdk::MemoryTexture::new(w as i32, h as i32, gdk::MemoryFormat::R8g8b8a8, &bytes, stride) + .upcast() } /// Conta ocorrências de cada byte (0..256) por canal em um buffer RGBA @@ -2230,115 +3057,54 @@ fn load_texture(path: &Path) -> Result /// concatenado pra o rendering ficar com `.chunks(256)`. fn compute_histogram(rgba: &[u8]) -> [u32; 768] { let mut h = [0u32; 768]; - for px in rgba.chunks_exact(4) { - h[px[0] as usize] += 1; - h[256 + px[1] as usize] += 1; - h[512 + px[2] as usize] += 1; - } - h -} - -// -------------------------------------------------------------------------- -// Dialog modals (Prisma) — launched by service menus via `bigiris --dialog=X`. -// -------------------------------------------------------------------------- - -use bigimage_core::{ - adjust_file, convert_file_to, crop_file, flip_file, make_gif, metadata, resize_file, - rotate_file, AdjustOps, AnimateOptions, ConvertOutcome, CropRect, EncodeOptions, Filter, - FlipAxis, Format, LoopMode, OverwritePolicy, PreviewOp, PreviewSession, ResizeMode, Rotation, -}; -use image::DynamicImage; -use std::time::Duration; - -/// Turn a `DynamicImage` into a `gdk::Texture` suitable for `gtk::Picture`. -/// Consumes the image (the RGBA buffer is handed to a `glib::Bytes` that -/// owns the allocation from there on). -fn dynamic_image_to_texture(img: DynamicImage) -> gdk::Texture { - let rgba = img.to_rgba8(); - let (w, h) = rgba.dimensions(); - let stride = (w as usize) * 4; - let bytes = glib::Bytes::from_owned(rgba.into_raw()); - gdk::MemoryTexture::new(w as i32, h as i32, gdk::MemoryFormat::R8g8b8a8, &bytes, stride) - .upcast() -} - -/// Build the preview-area column every dialog uses on its right-hand side. -/// Returns the `gtk::Picture` the caller drives via `set_paintable` and the -/// outer container to append into the dialog's horizontal box. -fn build_preview_column(session: &PreviewSession) -> (gtk::Picture, gtk::Box) { - let picture = gtk::Picture::new(); - picture.set_content_fit(gtk::ContentFit::Contain); - picture.set_hexpand(true); - picture.set_vexpand(true); - picture.set_size_request(360, 360); - - // Initial paintable = unmodified thumbnail. - let initial = dynamic_image_to_texture(session.thumbnail().clone()); - picture.set_paintable(Some(&initial)); - - let heading = - gtk::Label::builder().label("Pré-visualização").use_markup(true).xalign(0.0).build(); - - let (nw, nh) = session.natural_size(); - let info = gtk::Label::builder().label(format!("Original: {nw}×{nh} px")).xalign(0.0).build(); - info.add_css_class("dim-label"); - - let frame = gtk::Frame::new(None); - frame.set_child(Some(&picture)); - - let column = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(8) - .hexpand(true) - .vexpand(true) - .build(); - column.append(&heading); - column.append(&frame); - column.append(&info); - - (picture, column) + for px in rgba.as_chunks::<4>().0 { + h[px[0] as usize] += 1; + h[256 + px[1] as usize] += 1; + h[512 + px[2] as usize] += 1; + } + h } -/// Wrap the GTK idle+timeout dance into a single handle. Callers schedule -/// a preview repaint by calling [`PreviewDebounce::trigger`], and the -/// closure runs at most once per debounce window no matter how many times -/// the sliders fire in the meantime. -struct PreviewDebounce { - pending: std::cell::Cell>, -} +// -------------------------------------------------------------------------- +// Dialog modals (Prisma) — launched by service menus via `bigiris --dialog=X`. +// -------------------------------------------------------------------------- -impl PreviewDebounce { - fn new() -> Rc { - Rc::new(Self { pending: std::cell::Cell::new(None) }) - } +use bigimage_core::{ + adjust_file, convert_file_to, crop_file, extent_file, flip_file, make_gif, metadata, + resize_file, rotate_file, AdjustOps, AnimateOptions, CropRect, EncodeOptions, ExtentOptions, + ExtentSize, Filter, FlipAxis, Format, Gravity, LoopMode, OverwritePolicy, PreviewOp, + PreviewSession, ResizeMode, Rotation, +}; +use image::DynamicImage; +use std::time::Duration; - fn trigger(self: &Rc, delay: Duration, mut f: F) { - if let Some(id) = self.pending.take() { - id.remove(); +/// Dialog signals capture related widgets for the duration of the dialog. +/// Disconnect when the surface is released, before object finalization: +/// a strong callback cycle would prevent `destroy` from being emitted. +fn disconnect_with_window( + window: &adw::ApplicationWindow, + object: &impl IsA, + handler: glib::SignalHandlerId, +) { + let object = object.as_ref().downgrade(); + let handler = RefCell::new(Some(handler)); + window.connect_unrealize(move |_| { + if let (Some(object), Some(handler)) = (object.upgrade(), handler.borrow_mut().take()) { + object.disconnect(handler); } - let weak = Rc::downgrade(self); - let id = glib::timeout_add_local(delay, move || { - f(); - if let Some(strong) = weak.upgrade() { - strong.pending.set(None); - } - glib::ControlFlow::Break - }); - self.pending.set(Some(id)); - } + }); } -/// Apply `op` to `session` and push the resulting thumbnail into `picture`. -/// Errors are logged and the previous paintable is left untouched. -fn refresh_preview(session: &PreviewSession, picture: >k::Picture, op: &PreviewOp) { - match session.apply(op) { - Ok(img) => picture.set_paintable(Some(&dynamic_image_to_texture(img))), - Err(e) => tracing::warn!(error = %e, "preview: apply failed"), - } +macro_rules! dialog_signal { + ($window:expr, $object:expr, $method:ident, $callback:expr) => {{ + let owner = $window.clone(); + let object = $object.clone(); + let handler = object.$method($callback); + disconnect_with_window(&owner, &object, handler); + }}; } -/// Seven target formats presented in the convert dialog. Order drives the -/// default: PNG first. +/// Target formats shared by Prisma dialogs; keep common formats first. const CONVERT_FORMATS: &[(&str, Format)] = &[ ("PNG", Format::Png), ("JPG (JPEG)", Format::Jpeg), @@ -2347,8 +3113,33 @@ const CONVERT_FORMATS: &[(&str, Format)] = &[ ("TIFF", Format::Tiff), ("BMP", Format::Bmp), ("GIF", Format::Gif), + ("ICO (até 256 × 256)", Format::Ico), + ("PNM (Netpbm)", Format::Pnm), + ("TGA", Format::Tga), + ("QOI", Format::Qoi), + ("HDR", Format::Hdr), + ("OpenEXR", Format::OpenExr), ]; +/// Enable only the controls honored by the selected encoder. +fn bind_encode_controls( + window: &adw::ApplicationWindow, + format_row: &adw::ComboRow, + quality: &adw::SpinRow, + optimize: &adw::SwitchRow, +) { + let selected = format_row.clone(); + let quality = quality.clone(); + let optimize = optimize.clone(); + let update = move || { + let format = CONVERT_FORMATS[selected.selected() as usize].1; + quality.set_sensitive(matches!(format, Format::Jpeg | Format::Avif)); + optimize.set_sensitive(matches!(format, Format::Png | Format::Avif)); + }; + update(); + dialog_signal!(window, format_row, connect_selected_notify, move |_| update()); +} + /// Overwrite choices shared by convert and resize dialogs. const OVERWRITE_CHOICES: &[(&str, OverwritePolicy)] = &[ ("Pular se já existir", OverwritePolicy::Skip), @@ -2383,57 +3174,58 @@ fn build_convert_dialog(app: &adw::Application, files: Rc>) -> adw: .default_height(500) .title(gettext("Converter imagens — Prisma")) .build(); + prepare_dialog(&window, files, fill_convert_dialog); + window +} +fn fill_convert_dialog( + window: adw::ApplicationWindow, + files: Rc>, + preview_session: Option>, +) -> adw::ApplicationWindow { let header = adw::HeaderBar::new(); let title = adw::WindowTitle::new("Converter", "Prisma"); header.set_title_widget(Some(&title)); - let preview_session = files.first().and_then(|p| PreviewSession::open(p).ok()).map(Rc::new); - let format_names: Vec<&str> = CONVERT_FORMATS.iter().map(|(n, _)| *n).collect(); let format_row = adw::ComboRow::builder() + .use_markup(false) .title(gettext("Formato de destino")) .model(>k::StringList::new(&format_names)) .build(); let overwrite_names: Vec<&str> = OVERWRITE_CHOICES.iter().map(|(n, _)| *n).collect(); let overwrite_row = adw::ComboRow::builder() + .use_markup(false) .title(gettext("Se o arquivo de saída existir")) .model(>k::StringList::new(&overwrite_names)) .build(); let files_row = adw::ActionRow::builder() + .use_markup(false) .title(format!("{} arquivo(s) selecionado(s)", files.len())) - .subtitle(files_subtitle(&files)) .build(); + files_row.set_subtitle(&files_subtitle(&files)); let quality_spin = adw::SpinRow::with_range(1.0, 100.0, 1.0); - quality_spin.set_title(&gettext("Qualidade (JPEG/lossy)")); - quality_spin.set_subtitle("1 pior · 85 bom · 100 sem perdas"); + quality_spin.set_title(&gettext("Qualidade (JPEG/AVIF)")); + quality_spin.set_subtitle("1 menor qualidade · 85 equilíbrio · 100 maior qualidade"); quality_spin.set_value(85.0); - let progressive_switch = adw::SwitchRow::builder() - .title(gettext("JPEG progressivo")) - .subtitle("carregamento em passes (formatos que suportam)") - .active(false) - .build(); - let optimize_switch = adw::SwitchRow::builder() + .use_markup(false) .title(gettext("Otimizar tamanho")) .subtitle("PNG com compressão máxima; AVIF mais lento; arquivo menor") .active(false) .build(); - // Advanced opts under an ExpanderRow so the primary view stays - // focused on Format → Quality → Output. JPEG progressivo and - // "Otimizar tamanho" are niche enough that the average user never - // needs to see them — but power users still find them in one tap. + // Keep encoder tuning outside the primary conversion workflow. let advanced_expander = adw::ExpanderRow::builder() .title(gettext("Avançado")) - .subtitle(gettext("JPEG progressivo e otimização do encoder")) + .subtitle(gettext("Otimização do encoder")) .build(); - advanced_expander.add_row(&progressive_switch); advanced_expander.add_row(&optimize_switch); + bind_encode_controls(&window, &format_row, &quality_spin, &optimize_switch); // ── Conversão group ─────────────────────────────────────────────── let conv_group = adw::PreferencesGroup::builder().title(gettext("Conversão")).build(); @@ -2450,6 +3242,7 @@ fn build_convert_dialog(app: &adw::Application, files: Rc>) -> adw: // pasta do arquivo de origem (comportamento histórico). let output_dir: Rc>> = Rc::new(RefCell::new(None)); let folder_row = adw::ActionRow::builder() + .use_markup(false) .title(gettext("Pasta de saída")) .subtitle(gettext("Mesma pasta do arquivo de origem")) .activatable(true) @@ -2501,7 +3294,7 @@ fn build_convert_dialog(app: &adw::Application, files: Rc>) -> adw: banner.set_revealed(warn); }; update(); - format_row.connect_selected_notify(move |_| update()); + dialog_signal!(window, format_row, connect_selected_notify, move |_| update()); } } @@ -2543,7 +3336,7 @@ fn build_convert_dialog(app: &adw::Application, files: Rc>) -> adw: let folder_row_for_pick = folder_row.clone(); let clear_btn = clear_folder_btn.clone(); let window = window.clone(); - folder_row.clone().connect_activated(move |_| { + dialog_signal!(window, folder_row.clone(), connect_activated, move |_| { pick_output_folder(&window, &output_dir, &folder_row_for_pick, &clear_btn); }); } @@ -2551,7 +3344,7 @@ fn build_convert_dialog(app: &adw::Application, files: Rc>) -> adw: let output_dir = output_dir.clone(); let folder_row = folder_row.clone(); let clear_btn = clear_folder_btn.clone(); - clear_folder_btn.connect_clicked(move |_| { + dialog_signal!(window, clear_folder_btn, connect_clicked, move |_| { *output_dir.borrow_mut() = None; folder_row.set_subtitle(&gettext("Mesma pasta do arquivo de origem")); clear_btn.set_visible(false); @@ -2579,7 +3372,7 @@ fn build_convert_dialog(app: &adw::Application, files: Rc>) -> adw: { let window = window.clone(); - cancel_btn.connect_clicked(move |_| window.close()); + dialog_signal!(window, cancel_btn, connect_clicked, move |_| window.close()); } { @@ -2587,21 +3380,19 @@ fn build_convert_dialog(app: &adw::Application, files: Rc>) -> adw: let format_row = format_row.clone(); let overwrite_row = overwrite_row.clone(); let quality_spin = quality_spin.clone(); - let progressive_switch = progressive_switch.clone(); let optimize_switch = optimize_switch.clone(); let status = status.clone(); let apply_btn = apply_btn.clone(); let cancel_btn = cancel_btn.clone(); let window = window.clone(); let output_dir = output_dir.clone(); - apply_btn.clone().connect_clicked(move |_| { + dialog_signal!(window, apply_btn.clone(), connect_clicked, move |_| { let fmt_idx = format_row.selected() as usize; let policy_idx = overwrite_row.selected() as usize; let fmt = CONVERT_FORMATS[fmt_idx].1; let policy = OVERWRITE_CHOICES[policy_idx].1; let opts = EncodeOptions { quality: Some(quality_spin.value() as u8), - progressive: progressive_switch.is_active(), optimize: optimize_switch.is_active(), ..EncodeOptions::default() }; @@ -2648,18 +3439,27 @@ fn build_resize_dialog(app: &adw::Application, files: Rc>) -> adw:: .default_height(640) .title(gettext("Redimensionar imagens — Prisma")) .build(); + prepare_dialog(&window, files, fill_resize_dialog); + window +} +fn fill_resize_dialog( + window: adw::ApplicationWindow, + files: Rc>, + preview_session: Option>, +) -> adw::ApplicationWindow { let header = adw::HeaderBar::new(); let title = adw::WindowTitle::new("Redimensionar", "Prisma"); header.set_title_widget(Some(&title)); - let preview_session = files.first().and_then(|p| PreviewSession::open(p).ok()).map(Rc::new); - // ---- Mode selector ------------------------------------------------------ let mode_names = ["Aresta máxima", "Escala (%)", "Dimensão exata (WxH)", "Encaixar em (WxH)"]; - let mode_row = - adw::ComboRow::builder().title("Modo").model(>k::StringList::new(&mode_names)).build(); + let mode_row = adw::ComboRow::builder() + .use_markup(false) + .title("Modo") + .model(>k::StringList::new(&mode_names)) + .build(); // Per-mode parameter pages live inside a Stack; switching the combo swaps // the page. Each page owns its own SpinRows so we can read values back. @@ -2707,7 +3507,7 @@ fn build_resize_dialog(app: &adw::Application, files: Rc>) -> adw:: { let stack = stack.clone(); - mode_row.connect_selected_notify(move |combo| { + dialog_signal!(window, mode_row, connect_selected_notify, move |combo| { let name = match combo.selected() { 0 => "max-edge", 1 => "percent", @@ -2722,27 +3522,31 @@ fn build_resize_dialog(app: &adw::Application, files: Rc>) -> adw:: let filter_names: Vec<&str> = RESIZE_FILTERS.iter().map(|(n, _)| *n).collect(); let filter_row = adw::ComboRow::builder() + .use_markup(false) .title(gettext("Kernel de interpolação")) .model(>k::StringList::new(&filter_names)) .build(); - let mut target_names = vec!["Manter (mesmo formato da origem)"]; + let mut target_names = vec!["Manter o formato original"]; target_names.extend(CONVERT_FORMATS.iter().map(|(n, _)| *n)); let target_row = adw::ComboRow::builder() + .use_markup(false) .title(gettext("Formato de destino")) .model(>k::StringList::new(&target_names)) .build(); let overwrite_names: Vec<&str> = OVERWRITE_CHOICES.iter().map(|(n, _)| *n).collect(); let overwrite_row = adw::ComboRow::builder() + .use_markup(false) .title(gettext("Se o arquivo de saída existir")) .model(>k::StringList::new(&overwrite_names)) .build(); let files_row = adw::ActionRow::builder() + .use_markup(false) .title(format!("{} arquivo(s) selecionado(s)", files.len())) - .subtitle(files_subtitle(&files)) .build(); + files_row.set_subtitle(&files_subtitle(&files)); let group_files = adw::PreferencesGroup::new(); group_files.add(&files_row); @@ -2865,24 +3669,24 @@ fn build_resize_dialog(app: &adw::Application, files: Rc>) -> adw:: // Mode / filter combo changes { let s = schedule.clone(); - mode_row_clone.connect_selected_notify(move |_| s()); + dialog_signal!(window, mode_row_clone, connect_selected_notify, move |_| s()); } { let s = schedule.clone(); - filter_row_clone.connect_selected_notify(move |_| s()); + dialog_signal!(window, filter_row_clone, connect_selected_notify, move |_| s()); } // Spin changes in each mode page for row in &[max_edge_clone, percent_clone, exact_w_clone, exact_h_clone, fit_w_clone, fit_h_clone] { let s = schedule.clone(); - row.connect_changed(move |_| s()); + dialog_signal!(window, row, connect_changed, move |_| s()); } } { let window = window.clone(); - cancel_btn.connect_clicked(move |_| window.close()); + dialog_signal!(window, cancel_btn, connect_clicked, move |_| window.close()); } { @@ -2901,7 +3705,7 @@ fn build_resize_dialog(app: &adw::Application, files: Rc>) -> adw:: let apply_btn = apply_btn.clone(); let cancel_btn = cancel_btn.clone(); let window = window.clone(); - apply_btn.clone().connect_clicked(move |_| { + dialog_signal!(window, apply_btn.clone(), connect_clicked, move |_| { let mode = match mode_row.selected() { 0 => ResizeMode::MaxEdge(max_edge_spin.value() as u32), 1 => ResizeMode::Percent(percent_spin.value() as f32), @@ -2985,17 +3789,22 @@ fn build_rotate_dialog(app: &adw::Application, files: Rc>) -> adw:: header.set_title_widget(Some(&adw::WindowTitle::new("Girar", "Prisma"))); let rotations = ["90° (horário)", "180°", "270° (anti-horário)"]; - let rotation_row = - adw::ComboRow::builder().title("Rotação").model(>k::StringList::new(&rotations)).build(); + let rotation_row = adw::ComboRow::builder() + .use_markup(false) + .title("Rotação") + .model(>k::StringList::new(&rotations)) + .build(); - let mut target_names = vec!["Manter (mesmo formato da origem)"]; + let mut target_names = vec!["Manter o formato original"]; target_names.extend(CONVERT_FORMATS.iter().map(|(n, _)| *n)); let target_row = adw::ComboRow::builder() + .use_markup(false) .title(gettext("Formato de destino")) .model(>k::StringList::new(&target_names)) .build(); let overwrite_row = adw::ComboRow::builder() + .use_markup(false) .title(gettext("Se o arquivo de saída existir")) .model(>k::StringList::new( &OVERWRITE_CHOICES.iter().map(|(n, _)| *n).collect::>(), @@ -3003,9 +3812,10 @@ fn build_rotate_dialog(app: &adw::Application, files: Rc>) -> adw:: .build(); let files_row = adw::ActionRow::builder() + .use_markup(false) .title(format!("{} arquivo(s) selecionado(s)", files.len())) - .subtitle(files_subtitle(&files)) .build(); + files_row.set_subtitle(&files_subtitle(&files)); let group = adw::PreferencesGroup::new(); group.add(&files_row); @@ -3048,7 +3858,7 @@ fn build_rotate_dialog(app: &adw::Application, files: Rc>) -> adw:: { let window = window.clone(); - cancel_btn.connect_clicked(move |_| window.close()); + dialog_signal!(window, cancel_btn, connect_clicked, move |_| window.close()); } { @@ -3060,7 +3870,7 @@ fn build_rotate_dialog(app: &adw::Application, files: Rc>) -> adw:: let apply_btn = apply_btn.clone(); let cancel_btn = cancel_btn.clone(); let window = window.clone(); - apply_btn.clone().connect_clicked(move |_| { + dialog_signal!(window, apply_btn.clone(), connect_clicked, move |_| { let rotation = match rotation_row.selected() { 0 => Rotation::Deg90, 1 => Rotation::Deg180, @@ -3402,7 +4212,15 @@ fn build_crop_dialog(app: &adw::Application, files: Rc>) -> adw::Ap .default_height(560) .title(gettext("Recortar — Prisma")) .build(); + prepare_dialog(&window, files, fill_crop_dialog); + window +} +fn fill_crop_dialog( + window: adw::ApplicationWindow, + files: Rc>, + preview_session: Option>, +) -> adw::ApplicationWindow { let header = adw::HeaderBar::new(); header.set_title_widget(Some(&adw::WindowTitle::new("Recortar", "Prisma"))); @@ -3417,7 +4235,6 @@ fn build_crop_dialog(app: &adw::Application, files: Rc>) -> adw::Ap // Preview is keyed off the first file — multi-file dialogs share the // rect and per-file validation catches out-of-bounds at apply time. - let preview_session = files.first().and_then(|p| PreviewSession::open(p).ok()).map(Rc::new); let natural = preview_session.as_ref().map(|s| s.natural_size()).unwrap_or((1, 1)); let initial = default_crop(natural.0, natural.1); @@ -3429,11 +4246,13 @@ fn build_crop_dialog(app: &adw::Application, files: Rc>) -> adw::Ap .build(); let files_row = adw::ActionRow::builder() + .use_markup(false) .title(format!("{} arquivo(s) selecionado(s)", files.len())) - .subtitle(files_subtitle(&files)) .build(); + files_row.set_subtitle(&files_subtitle(&files)); let natural_row = adw::ActionRow::builder() + .use_markup(false) .title(gettext("Tamanho original")) .subtitle(format!("{} × {} px (primeiro arquivo)", natural.0, natural.1)) .build(); @@ -3456,14 +4275,16 @@ fn build_crop_dialog(app: &adw::Application, files: Rc>) -> adw::Ap h_spin.set_title(&gettext("Altura (px)")); h_spin.set_value(initial.height as f64); - let mut target_names = vec!["Manter (mesmo formato da origem)"]; + let mut target_names = vec!["Manter o formato original"]; target_names.extend(CONVERT_FORMATS.iter().map(|(n, _)| *n)); let target_row = adw::ComboRow::builder() + .use_markup(false) .title(gettext("Formato de destino")) .model(>k::StringList::new(&target_names)) .build(); let overwrite_row = adw::ComboRow::builder() + .use_markup(false) .title(gettext("Se o arquivo de saída existir")) .model(>k::StringList::new( &OVERWRITE_CHOICES.iter().map(|(n, _)| *n).collect::>(), @@ -3503,58 +4324,432 @@ fn build_crop_dialog(app: &adw::Application, files: Rc>) -> adw::Ap content_h.append(&form_col); - // Interactive preview: full thumbnail + DrawingArea overlay that strokes - // the selection rect and shades the outside. Spin rows are the source of - // truth; drag gestures update them and the overlay redraws. - if let Some(sess) = preview_session.as_ref() { - let column = build_crop_preview_column(sess, natural, &x_spin, &y_spin, &w_spin, &h_spin); - content_h.append(&column); + // Interactive preview: full thumbnail + DrawingArea overlay that strokes + // the selection rect and shades the outside. Spin rows are the source of + // truth; drag gestures update them and the overlay redraws. + if let Some(sess) = preview_session.as_ref() { + let column = build_crop_preview_column(sess, natural, &x_spin, &y_spin, &w_spin, &h_spin); + content_h.append(&column); + } + + let toolbar_view = adw::ToolbarView::new(); + toolbar_view.add_top_bar(&header); + toolbar_view.set_content(Some(&content_h)); + window.set_content(Some(&toolbar_view)); + + // Reset button → re-centre to 50% of the first image. + { + let x_spin = x_spin.clone(); + let y_spin = y_spin.clone(); + let w_spin = w_spin.clone(); + let h_spin = h_spin.clone(); + dialog_signal!(window, reset_btn, connect_clicked, move |_| { + let d = default_crop(natural.0, natural.1); + w_spin.set_value(d.width as f64); + h_spin.set_value(d.height as f64); + x_spin.set_value(d.x as f64); + y_spin.set_value(d.y as f64); + }); + } + + { + let window = window.clone(); + dialog_signal!(window, cancel_btn, connect_clicked, move |_| window.close()); + } + + { + let files = files.clone(); + let x_spin = x_spin.clone(); + let y_spin = y_spin.clone(); + let w_spin = w_spin.clone(); + let h_spin = h_spin.clone(); + let target_row = target_row.clone(); + let overwrite_row = overwrite_row.clone(); + let status = status.clone(); + let apply_btn = apply_btn.clone(); + let cancel_btn = cancel_btn.clone(); + let window = window.clone(); + dialog_signal!(window, apply_btn.clone(), connect_clicked, move |_| { + let rect = CropRect::new( + x_spin.value() as u32, + y_spin.value() as u32, + w_spin.value() as u32, + h_spin.value() as u32, + ); + let target: Option = match target_row.selected() { + 0 => None, + n => Some(CONVERT_FORMATS[(n as usize) - 1].1), + }; + let policy = OVERWRITE_CHOICES[overwrite_row.selected() as usize].1; + + apply_btn.set_sensitive(false); + cancel_btn.set_sensitive(false); + status.set_text(&gettext("Recortando…")); + + let work_files: Vec = (*files).clone(); + run_batch_async( + work_files, + move |f| crop_file(f, rect, target, policy), + gettext("Recortando"), + status.clone(), + apply_btn.clone(), + cancel_btn.clone(), + window.clone(), + ); + }); + } + + window +} + +/// Aspect-ratio presets for the "Letterbox" (extent) dialog. Each preset +/// is `(label, (w, h))` with `w:h` the target ratio. Order drives the +/// default selection — YouTube community 1:1 sits first because it's the +/// case that motivated the feature (post thumbnail forces square crop on +/// 16:9 source). +const EXTENT_RATIO_PRESETS: &[(&str, (u32, u32))] = &[ + ("YouTube community (1:1)", (1, 1)), + ("Instagram feed (4:5)", (4, 5)), + ("Stories / Shorts (9:16)", (9, 16)), + ("Widescreen (16:9)", (16, 9)), + ("Personalizado…", (0, 0)), +]; + +/// Background-colour presets for the "Letterbox" dialog. The first entry +/// is the default. `Custom` (last) reveals the hex entry. +const EXTENT_BG_PRESETS: &[(&str, [u8; 4])] = &[ + ("Preto suave (#0d0e12)", [0x0d, 0x0e, 0x12, 0xFF]), + ("Preto puro (#000000)", [0, 0, 0, 0xFF]), + ("Branco (#ffffff)", [0xFF, 0xFF, 0xFF, 0xFF]), + ("Transparente", [0, 0, 0, 0]), + ("Personalizada (hex)", [0, 0, 0, 0xFF]), +]; + +/// Gravity choices exposed in the Prisma dialog. Limited to centre + +/// vertical pinning because horizontal letterbox use cases are rarer in +/// social-media workflows; the CLI exposes all nine for completeness. +const EXTENT_GRAVITIES: &[(&str, Gravity)] = + &[("Centro", Gravity::Center), ("Topo", Gravity::North), ("Base", Gravity::South)]; + +/// Run the "Letterbox" modal. Returns GTK's exit code. +pub fn run_extent_dialog(files: Vec) -> i32 { + let app = adw::Application::builder().application_id(APP_ID).build(); + let files = Rc::new(files); + app.connect_activate(move |app| { + build_extent_dialog(app, files.clone()).present(); + }); + let code = app.run_with_args::<&str>(&[]); + i32::from(u8::from(code)) +} + +fn build_extent_dialog(app: &adw::Application, files: Rc>) -> adw::ApplicationWindow { + let window = adw::ApplicationWindow::builder() + .application(app) + .default_width(900) + .default_height(620) + .title(gettext("Letterbox — Prisma")) + .build(); + prepare_dialog(&window, files, fill_extent_dialog); + window +} + +fn fill_extent_dialog( + window: adw::ApplicationWindow, + files: Rc>, + preview_session: Option>, +) -> adw::ApplicationWindow { + let header = adw::HeaderBar::new(); + header.set_title_widget(Some(&adw::WindowTitle::new("Letterbox", "Prisma · sem corte"))); + + let content_h = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(16) + .margin_top(16) + .margin_bottom(16) + .margin_start(16) + .margin_end(16) + .build(); + + let natural = preview_session.as_ref().map(|s| s.natural_size()).unwrap_or((1, 1)); + + let form_col = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(12) + .width_request(380) + .build(); + + let intro = gtk::Label::builder() + .label(gettext( + "Estende a tela em volta da imagem para encaixá-la num formato \ + fixo (post de YouTube, feed do Instagram, Stories…) sem cortar \ + nenhum pixel. A imagem original é centralizada e o resto é \ + preenchido com a cor escolhida.", + )) + .wrap(true) + .xalign(0.0) + .build(); + intro.add_css_class("dim-label"); + + let files_row = adw::ActionRow::builder() + .use_markup(false) + .title(format!("{} arquivo(s) selecionado(s)", files.len())) + .build(); + files_row.set_subtitle(&files_subtitle(&files)); + + let natural_row = adw::ActionRow::builder() + .use_markup(false) + .title(gettext("Tamanho original")) + .subtitle(format!("{} × {} px (primeiro arquivo)", natural.0, natural.1)) + .build(); + + // Aspect preset combo. + let ratio_names: Vec<&str> = EXTENT_RATIO_PRESETS.iter().map(|(n, _)| *n).collect(); + let ratio_row = adw::ComboRow::builder() + .use_markup(false) + .title(gettext("Proporção alvo")) + .model(>k::StringList::new(&ratio_names)) + .build(); + + // Custom W:H entries — visible only when the "Personalizado…" preset is + // selected. Defaults to 1:1 so switching back and forth feels stable. + let custom_w_spin = adw::SpinRow::with_range(1.0, 99.0, 1.0); + custom_w_spin.set_title(&gettext("Razão W")); + custom_w_spin.set_value(1.0); + let custom_h_spin = adw::SpinRow::with_range(1.0, 99.0, 1.0); + custom_h_spin.set_title(&gettext("Razão H")); + custom_h_spin.set_value(1.0); + custom_w_spin.set_visible(false); + custom_h_spin.set_visible(false); + + // Background colour combo + hex entry (entry shown when "Personalizada" + // is picked). + let bg_names: Vec<&str> = EXTENT_BG_PRESETS.iter().map(|(n, _)| *n).collect(); + let bg_row = adw::ComboRow::builder() + .use_markup(false) + .title(gettext("Cor de fundo")) + .model(>k::StringList::new(&bg_names)) + .build(); + + let bg_hex = adw::EntryRow::builder() + .use_markup(false) + .title(gettext("Hex (#RRGGBB)")) + .text("#0d0e12") + .build(); + bg_hex.set_visible(false); + + let gravity_names: Vec<&str> = EXTENT_GRAVITIES.iter().map(|(n, _)| *n).collect(); + let gravity_row = adw::ComboRow::builder() + .use_markup(false) + .title(gettext("Posição da imagem")) + .model(>k::StringList::new(&gravity_names)) + .build(); + + let predicted_row = adw::ActionRow::builder() + .use_markup(false) + .title(gettext("Destino previsto")) + .subtitle("—") + .build(); + + let mut target_names = vec!["Manter o formato original"]; + target_names.extend(CONVERT_FORMATS.iter().map(|(n, _)| *n)); + let target_row = adw::ComboRow::builder() + .use_markup(false) + .title(gettext("Formato de destino")) + .model(>k::StringList::new(&target_names)) + .build(); + + let overwrite_row = adw::ComboRow::builder() + .use_markup(false) + .title(gettext("Se o arquivo de saída existir")) + .model(>k::StringList::new( + &OVERWRITE_CHOICES.iter().map(|(n, _)| *n).collect::>(), + )) + .build(); + + let group = adw::PreferencesGroup::new(); + group.add(&files_row); + group.add(&natural_row); + group.add(&ratio_row); + group.add(&custom_w_spin); + group.add(&custom_h_spin); + group.add(&bg_row); + group.add(&bg_hex); + group.add(&gravity_row); + group.add(&predicted_row); + group.add(&target_row); + group.add(&overwrite_row); + + form_col.append(&intro); + form_col.append(&group); + + let status = gtk::Label::builder().label("").wrap(true).xalign(0.0).build(); + status.add_css_class("dim-label"); + form_col.append(&status); + + let cancel_btn = gtk::Button::with_label("Cancelar"); + let apply_btn = gtk::Button::builder().label(gettext("Estender")).build(); + apply_btn.add_css_class("suggested-action"); + apply_btn.add_css_class("pill"); + + let actions = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .halign(gtk::Align::End) + .build(); + actions.append(&cancel_btn); + actions.append(&apply_btn); + form_col.append(&actions); + + content_h.append(&form_col); + + // Live preview on the right, driven by PreviewOp::Extent. + let picture_opt = if let Some(sess) = preview_session.as_ref() { + let (picture, column) = build_preview_column(sess); + content_h.append(&column); + Some(picture) + } else { + None + }; + + let toolbar_view = adw::ToolbarView::new(); + toolbar_view.add_top_bar(&header); + toolbar_view.set_content(Some(&content_h)); + window.set_content(Some(&toolbar_view)); + + // Shared closure that reads every control and produces an ExtentOptions + // (or None if the form is in an invalid state — e.g. malformed hex). + let build_opts = { + let ratio_row = ratio_row.clone(); + let custom_w_spin = custom_w_spin.clone(); + let custom_h_spin = custom_h_spin.clone(); + let bg_row = bg_row.clone(); + let bg_hex = bg_hex.clone(); + let gravity_row = gravity_row.clone(); + move || -> std::result::Result { + let (rw, rh) = { + let idx = ratio_row.selected() as usize; + let preset = EXTENT_RATIO_PRESETS[idx].1; + if preset == (0, 0) { + (custom_w_spin.value() as u32, custom_h_spin.value() as u32) + } else { + preset + } + }; + let size = ExtentSize::Aspect { w: rw, h: rh }; + + let background = { + let idx = bg_row.selected() as usize; + if idx == EXTENT_BG_PRESETS.len() - 1 { + bigimage_core::parse_hex_color(&bg_hex.text())? + } else { + EXTENT_BG_PRESETS[idx].1 + } + }; + + let gravity = EXTENT_GRAVITIES[gravity_row.selected() as usize].1; + + Ok(ExtentOptions { size, background, gravity }) + } + }; + + // Refresh the predicted-size row and the preview painting on every + // control change. Errors (bad hex) surface in the status label. + let refresh = { + let session_opt = preview_session.clone(); + let picture_opt = picture_opt.clone(); + let predicted_row = predicted_row.clone(); + let status = status.clone(); + let build_opts = build_opts.clone(); + move || match build_opts() { + Ok(opts) => { + let (nw, nh) = match opts.size.resolve(natural.0, natural.1) { + Ok(d) => d, + Err(e) => { + status.set_text(&e.to_string()); + predicted_row.set_subtitle("—"); + return; + } + }; + predicted_row.set_subtitle(&format!("{nw} × {nh} px")); + status.set_text(""); + if let (Some(sess), Some(picture)) = (session_opt.as_ref(), picture_opt.as_ref()) { + refresh_preview(sess, picture, &PreviewOp::Extent(opts)); + } + } + Err(e) => { + status.set_text(&e); + } + } + }; + + // Reveal Custom W:H spins when the "Personalizado…" preset is picked, + // and hide them again on every other selection. + { + let custom_w_spin = custom_w_spin.clone(); + let custom_h_spin = custom_h_spin.clone(); + let refresh = refresh.clone(); + dialog_signal!(window, ratio_row, connect_selected_notify, move |row| { + let idx = row.selected() as usize; + let is_custom = EXTENT_RATIO_PRESETS[idx].1 == (0, 0); + custom_w_spin.set_visible(is_custom); + custom_h_spin.set_visible(is_custom); + refresh(); + }); } - let toolbar_view = adw::ToolbarView::new(); - toolbar_view.add_top_bar(&header); - toolbar_view.set_content(Some(&content_h)); - window.set_content(Some(&toolbar_view)); - - // Reset button → re-centre to 50% of the first image. + // Reveal the hex entry when "Personalizada" is selected. { - let x_spin = x_spin.clone(); - let y_spin = y_spin.clone(); - let w_spin = w_spin.clone(); - let h_spin = h_spin.clone(); - reset_btn.connect_clicked(move |_| { - let d = default_crop(natural.0, natural.1); - w_spin.set_value(d.width as f64); - h_spin.set_value(d.height as f64); - x_spin.set_value(d.x as f64); - y_spin.set_value(d.y as f64); + let bg_hex = bg_hex.clone(); + let refresh = refresh.clone(); + dialog_signal!(window, bg_row, connect_selected_notify, move |row| { + let idx = row.selected() as usize; + bg_hex.set_visible(idx == EXTENT_BG_PRESETS.len() - 1); + refresh(); }); } + { + let refresh = refresh.clone(); + dialog_signal!(window, custom_w_spin, connect_value_notify, move |_| refresh()); + } + { + let refresh = refresh.clone(); + dialog_signal!(window, custom_h_spin, connect_value_notify, move |_| refresh()); + } + { + let refresh = refresh.clone(); + dialog_signal!(window, bg_hex, connect_changed, move |_| refresh()); + } + { + let refresh = refresh.clone(); + dialog_signal!(window, gravity_row, connect_selected_notify, move |_| refresh()); + } + + // Initial paint. + refresh(); + { let window = window.clone(); - cancel_btn.connect_clicked(move |_| window.close()); + dialog_signal!(window, cancel_btn, connect_clicked, move |_| window.close()); } { let files = files.clone(); - let x_spin = x_spin.clone(); - let y_spin = y_spin.clone(); - let w_spin = w_spin.clone(); - let h_spin = h_spin.clone(); + let build_opts = build_opts.clone(); let target_row = target_row.clone(); let overwrite_row = overwrite_row.clone(); let status = status.clone(); let apply_btn = apply_btn.clone(); let cancel_btn = cancel_btn.clone(); let window = window.clone(); - apply_btn.clone().connect_clicked(move |_| { - let rect = CropRect::new( - x_spin.value() as u32, - y_spin.value() as u32, - w_spin.value() as u32, - h_spin.value() as u32, - ); + dialog_signal!(window, apply_btn.clone(), connect_clicked, move |_| { + let opts = match build_opts() { + Ok(opts) => opts, + Err(e) => { + status.set_text(&e); + return; + } + }; let target: Option = match target_row.selected() { 0 => None, n => Some(CONVERT_FORMATS[(n as usize) - 1].1), @@ -3563,13 +4758,13 @@ fn build_crop_dialog(app: &adw::Application, files: Rc>) -> adw::Ap apply_btn.set_sensitive(false); cancel_btn.set_sensitive(false); - status.set_text(&gettext("Recortando…")); + status.set_text(&gettext("Estendendo…")); let work_files: Vec = (*files).clone(); run_batch_async( work_files, - move |f| crop_file(f, rect, target, policy), - gettext("Recortando"), + move |f| extent_file(f, opts, target, policy), + gettext("Estendendo"), status.clone(), apply_btn.clone(), cancel_btn.clone(), @@ -3604,7 +4799,15 @@ fn build_upscale_dialog(app: &adw::Application, files: Rc>) -> adw: .default_height(500) .title(gettext("Aumentar resolução — Prisma")) .build(); + prepare_dialog(&window, files, fill_upscale_dialog); + window +} +fn fill_upscale_dialog( + window: adw::ApplicationWindow, + files: Rc>, + preview_session: Option>, +) -> adw::ApplicationWindow { let header = adw::HeaderBar::new(); header .set_title_widget(Some(&adw::WindowTitle::new("Aumentar resolução", "Prisma · Lanczos3"))); @@ -3618,7 +4821,6 @@ fn build_upscale_dialog(app: &adw::Application, files: Rc>) -> adw: .margin_end(16) .build(); - let preview_session = files.first().and_then(|p| PreviewSession::open(p).ok()).map(Rc::new); let natural = preview_session.as_ref().map(|s| s.natural_size()).unwrap_or((0, 0)); let form_col = gtk::Box::builder() @@ -3639,11 +4841,13 @@ fn build_upscale_dialog(app: &adw::Application, files: Rc>) -> adw: engine_note.add_css_class("dim-label"); let files_row = adw::ActionRow::builder() + .use_markup(false) .title(format!("{} arquivo(s) selecionado(s)", files.len())) - .subtitle(files_subtitle(&files)) .build(); + files_row.set_subtitle(&files_subtitle(&files)); let natural_row = adw::ActionRow::builder() + .use_markup(false) .title(gettext("Tamanho original")) .subtitle(if natural == (0, 0) { "—".to_string() @@ -3653,11 +4857,17 @@ fn build_upscale_dialog(app: &adw::Application, files: Rc>) -> adw: .build(); let factor_names: Vec<&str> = UPSCALE_FACTORS.iter().map(|(n, _)| *n).collect(); - let factor_row = - adw::ComboRow::builder().title("Fator").model(>k::StringList::new(&factor_names)).build(); + let factor_row = adw::ComboRow::builder() + .use_markup(false) + .title("Fator") + .model(>k::StringList::new(&factor_names)) + .build(); - let predicted_row = - adw::ActionRow::builder().title(gettext("Destino previsto")).subtitle("—").build(); + let predicted_row = adw::ActionRow::builder() + .use_markup(false) + .title(gettext("Destino previsto")) + .subtitle("—") + .build(); { let factor_row_c = factor_row.clone(); let predicted_row_c = predicted_row.clone(); @@ -3673,17 +4883,19 @@ fn build_upscale_dialog(app: &adw::Application, files: Rc>) -> adw: predicted_row_c.set_subtitle(&sub); }; refresh_prediction(); - factor_row.connect_selected_notify(move |_| refresh_prediction()); + dialog_signal!(window, factor_row, connect_selected_notify, move |_| refresh_prediction()); } - let mut target_names = vec!["Manter (mesmo formato da origem)"]; + let mut target_names = vec!["Manter o formato original"]; target_names.extend(CONVERT_FORMATS.iter().map(|(n, _)| *n)); let target_row = adw::ComboRow::builder() + .use_markup(false) .title(gettext("Formato de destino")) .model(>k::StringList::new(&target_names)) .build(); let overwrite_row = adw::ComboRow::builder() + .use_markup(false) .title(gettext("Se o arquivo de saída existir")) .model(>k::StringList::new( &OVERWRITE_CHOICES.iter().map(|(n, _)| *n).collect::>(), @@ -3744,7 +4956,7 @@ fn build_upscale_dialog(app: &adw::Application, files: Rc>) -> adw: } }; refresh(); - factor_row.connect_selected_notify(move |_| refresh()); + dialog_signal!(window, factor_row, connect_selected_notify, move |_| refresh()); } let toolbar_view = adw::ToolbarView::new(); @@ -3754,7 +4966,7 @@ fn build_upscale_dialog(app: &adw::Application, files: Rc>) -> adw: { let window = window.clone(); - cancel_btn.connect_clicked(move |_| window.close()); + dialog_signal!(window, cancel_btn, connect_clicked, move |_| window.close()); } { @@ -3766,7 +4978,7 @@ fn build_upscale_dialog(app: &adw::Application, files: Rc>) -> adw: let apply_btn = apply_btn.clone(); let cancel_btn = cancel_btn.clone(); let window = window.clone(); - apply_btn.clone().connect_clicked(move |_| { + dialog_signal!(window, apply_btn.clone(), connect_clicked, move |_| { let factor = UPSCALE_FACTORS[factor_row.selected() as usize].1; let target: Option = match target_row.selected() { 0 => None, @@ -3820,17 +5032,22 @@ fn build_flip_dialog(app: &adw::Application, files: Rc>) -> adw::Ap header.set_title_widget(Some(&adw::WindowTitle::new("Espelhar", "Prisma"))); let axes = ["Horizontal (esquerda ↔ direita)", "Vertical (cima ↔ baixo)"]; - let axis_row = - adw::ComboRow::builder().title("Eixo").model(>k::StringList::new(&axes)).build(); + let axis_row = adw::ComboRow::builder() + .use_markup(false) + .title("Eixo") + .model(>k::StringList::new(&axes)) + .build(); - let mut target_names = vec!["Manter (mesmo formato da origem)"]; + let mut target_names = vec!["Manter o formato original"]; target_names.extend(CONVERT_FORMATS.iter().map(|(n, _)| *n)); let target_row = adw::ComboRow::builder() + .use_markup(false) .title(gettext("Formato de destino")) .model(>k::StringList::new(&target_names)) .build(); let overwrite_row = adw::ComboRow::builder() + .use_markup(false) .title(gettext("Se o arquivo de saída existir")) .model(>k::StringList::new( &OVERWRITE_CHOICES.iter().map(|(n, _)| *n).collect::>(), @@ -3838,9 +5055,10 @@ fn build_flip_dialog(app: &adw::Application, files: Rc>) -> adw::Ap .build(); let files_row = adw::ActionRow::builder() + .use_markup(false) .title(format!("{} arquivo(s) selecionado(s)", files.len())) - .subtitle(files_subtitle(&files)) .build(); + files_row.set_subtitle(&files_subtitle(&files)); let group = adw::PreferencesGroup::new(); group.add(&files_row); @@ -3883,7 +5101,7 @@ fn build_flip_dialog(app: &adw::Application, files: Rc>) -> adw::Ap { let window = window.clone(); - cancel_btn.connect_clicked(move |_| window.close()); + dialog_signal!(window, cancel_btn, connect_clicked, move |_| window.close()); } { @@ -3895,7 +5113,7 @@ fn build_flip_dialog(app: &adw::Application, files: Rc>) -> adw::Ap let apply_btn = apply_btn.clone(); let cancel_btn = cancel_btn.clone(); let window = window.clone(); - apply_btn.clone().connect_clicked(move |_| { + dialog_signal!(window, apply_btn.clone(), connect_clicked, move |_| { let axis = match axis_row.selected() { 0 => FlipAxis::Horizontal, _ => FlipAxis::Vertical, @@ -3944,10 +5162,17 @@ fn build_adjust_dialog(app: &adw::Application, files: Rc>) -> adw:: .default_height(640) .title(gettext("Ajustar cores — Prisma")) .build(); + prepare_dialog(&window, files, fill_adjust_dialog); + window +} +fn fill_adjust_dialog( + window: adw::ApplicationWindow, + files: Rc>, + preview_session: Option>, +) -> adw::ApplicationWindow { // Preview session — loaded from the first file. Multi-select still works // (Aplicar opera em todos), mas o preview mostra o primeiro. - let preview_session = files.first().and_then(|p| PreviewSession::open(p).ok()).map(Rc::new); let header = adw::HeaderBar::new(); header.set_title_widget(Some(&adw::WindowTitle::new("Ajustar cores", "Prisma"))); @@ -3969,18 +5194,20 @@ fn build_adjust_dialog(app: &adw::Application, files: Rc>) -> adw:: let gamma = adw::SpinRow::with_range(0.1, 10.0, 0.05); gamma.set_title(&gettext("Gamma")); - gamma.set_subtitle("< 1 clareia midtones · 1 neutro · > 1 escurece"); + gamma.set_subtitle("Abaixo de 1 clareia os meios-tons · 1 mantém · acima de 1 escurece"); gamma.set_digits(2); gamma.set_value(1.0); - let mut target_names = vec!["Manter (mesmo formato da origem)"]; + let mut target_names = vec!["Manter o formato original"]; target_names.extend(CONVERT_FORMATS.iter().map(|(n, _)| *n)); let target_row = adw::ComboRow::builder() + .use_markup(false) .title(gettext("Formato de destino")) .model(>k::StringList::new(&target_names)) .build(); let overwrite_row = adw::ComboRow::builder() + .use_markup(false) .title(gettext("Se o arquivo de saída existir")) .model(>k::StringList::new( &OVERWRITE_CHOICES.iter().map(|(n, _)| *n).collect::>(), @@ -3988,9 +5215,10 @@ fn build_adjust_dialog(app: &adw::Application, files: Rc>) -> adw:: .build(); let files_row = adw::ActionRow::builder() + .use_markup(false) .title(format!("{} arquivo(s) selecionado(s)", files.len())) - .subtitle(files_subtitle(&files)) .build(); + files_row.set_subtitle(&files_subtitle(&files)); let group_files = adw::PreferencesGroup::new(); group_files.add(&files_row); @@ -4086,7 +5314,7 @@ fn build_adjust_dialog(app: &adw::Application, files: Rc>) -> adw:: let contrast = contrast.clone(); let saturation = saturation.clone(); let gamma = gamma.clone(); - row.connect_changed(move |_| { + dialog_signal!(window, row, connect_changed, move |_| { let ops = AdjustOps { brightness: brightness.value() as i32, contrast: contrast.value() as f32, @@ -4104,7 +5332,7 @@ fn build_adjust_dialog(app: &adw::Application, files: Rc>) -> adw:: { let window = window.clone(); - cancel_btn.connect_clicked(move |_| window.close()); + dialog_signal!(window, cancel_btn, connect_clicked, move |_| window.close()); } { @@ -4119,7 +5347,7 @@ fn build_adjust_dialog(app: &adw::Application, files: Rc>) -> adw:: let apply_btn = apply_btn.clone(); let cancel_btn = cancel_btn.clone(); let window = window.clone(); - apply_btn.clone().connect_clicked(move |_| { + dialog_signal!(window, apply_btn.clone(), connect_clicked, move |_| { let ops = AdjustOps { brightness: brightness.value() as i32, contrast: contrast.value() as f32, @@ -4186,20 +5414,62 @@ fn build_properties_window(app: &adw::Application, path: &Path) -> adw::Applicat .default_height(640) .title(format!("Propriedades — {filename}")) .build(); + let input = path.to_path_buf(); + let cancel = Arc::new(AtomicBool::new(false)); + let closing = cancel.clone(); + window.connect_unrealize(move |_| closing.store(true, Ordering::Release)); + let (tx, rx) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + let result = crate::codec::with_cancel(cancel, || { + let info = crate::codec::information(&input).ok(); + let fsmeta = std::fs::metadata(&input).map_err(|e| e.to_string()); + (info, fsmeta) + }); + let _ = tx.send(result); + }); + let spinner = gtk::Spinner::new(); + spinner.start(); + window.set_content(Some(&spinner)); + let weak = window.downgrade(); + let input = path.to_path_buf(); + glib::timeout_add_local(Duration::from_millis(16), move || { + let Some(window) = weak.upgrade() else { return glib::ControlFlow::Break }; + if !window.is_visible() { + return glib::ControlFlow::Break; + } + match rx.try_recv() { + Ok((info, fsmeta)) => { + fill_properties_window(window, &input, info.as_ref(), fsmeta.as_ref()); + } + Err(std::sync::mpsc::TryRecvError::Empty) => return glib::ControlFlow::Continue, + Err(std::sync::mpsc::TryRecvError::Disconnected) => {} + } + glib::ControlFlow::Break + }); + window +} +fn fill_properties_window( + window: adw::ApplicationWindow, + path: &Path, + info: Option<&crate::codec::Information>, + fsmeta: std::result::Result<&std::fs::Metadata, &String>, +) -> adw::ApplicationWindow { + let filename = path.file_name().unwrap_or_default().to_string_lossy(); let header = adw::HeaderBar::new(); header.set_title_widget(Some(&adw::WindowTitle::new("Propriedades", &filename))); let page = adw::PreferencesPage::new(); - page.add(&build_properties_file_group(path)); - page.add(&build_properties_image_group(path)); + page.add(&build_properties_file_group(path, info, fsmeta)); + page.add(&build_properties_image_group(info)); - if let Ok(meta) = metadata::read(path) { + if let Some(meta) = info.and_then(|i| i.metadata.as_ref()) { if !meta.is_empty() { if meta.has_gps || meta.has_camera_info { let warning = adw::PreferencesGroup::builder().title(gettext("Privacidade")).build(); let row = adw::ActionRow::builder() + .use_markup(false) .title(if meta.has_gps { "Coordenadas GPS presentes" } else { @@ -4210,7 +5480,7 @@ fn build_properties_window(app: &adw::Application, path: &Path) -> adw::Applicat warning.add(&row); page.add(&warning); } - page.add(&build_properties_exif_group(&meta)); + page.add(&build_properties_exif_group(meta)); } } @@ -4222,7 +5492,11 @@ fn build_properties_window(app: &adw::Application, path: &Path) -> adw::Applicat window } -fn build_properties_file_group(path: &Path) -> adw::PreferencesGroup { +fn build_properties_file_group( + path: &Path, + info: Option<&crate::codec::Information>, + fsmeta: std::result::Result<&std::fs::Metadata, &String>, +) -> adw::PreferencesGroup { let group = adw::PreferencesGroup::builder().title(gettext("Arquivo")).build(); let filename = path @@ -4243,11 +5517,7 @@ fn build_properties_file_group(path: &Path) -> adw::PreferencesGroup { // even when the bytes are a valid PNG/JPEG/etc. Falls back to extension // when sniffing fails (truly unknown / corrupt / non-image file). let ext = path.extension().map(|e| e.to_string_lossy().to_uppercase()); - let sniffed = image::ImageReader::open(path) - .ok() - .and_then(|r| r.with_guessed_format().ok()) - .and_then(|r| r.format()) - .map(format_image_format); + let sniffed = info.and_then(|i| i.format.clone()); match (sniffed, ext) { (Some(s), Some(e)) if s != e => { group.add(&info_row("Formato", &format!("{s} (extensão: {e})"))); @@ -4257,7 +5527,7 @@ fn build_properties_file_group(path: &Path) -> adw::PreferencesGroup { (None, None) => {} } - match std::fs::metadata(path) { + match fsmeta { Ok(fsmeta) => { group.add(&info_row("Tamanho", &format_bytes(fsmeta.len()))); if let Ok(mtime) = fsmeta.modified() { @@ -4272,21 +5542,24 @@ fn build_properties_file_group(path: &Path) -> adw::PreferencesGroup { group } -fn build_properties_image_group(path: &Path) -> adw::PreferencesGroup { +fn build_properties_image_group(info: Option<&crate::codec::Information>) -> adw::PreferencesGroup { let group = adw::PreferencesGroup::builder().title(gettext("Imagem")).build(); - // One open() reused for dimensions + color type. into_decoder() reads - // only the header for most formats (PNG IHDR, JPEG SOF, WebP RIFF), so - // partial-sync files like Nextcloud `.chunking-*` still report their - // shape even if the pixel stream is incomplete. - let decoder = image::ImageReader::open(path) - .ok() - .and_then(|r| r.with_guessed_format().ok()) - .and_then(|r| r.into_decoder().ok()); - match decoder { - Some(dec) => { - let (w, h) = image::ImageDecoder::dimensions(&dec); - let color = image::ImageDecoder::color_type(&dec); + match info.and_then(|i| i.dimensions) { + Some((w, h)) => { + let color = info.and_then(|i| i.color).and_then(|id| match id { + 1 => Some(image::ColorType::L8), + 2 => Some(image::ColorType::La8), + 3 => Some(image::ColorType::Rgb8), + 4 => Some(image::ColorType::Rgba8), + 5 => Some(image::ColorType::L16), + 6 => Some(image::ColorType::La16), + 7 => Some(image::ColorType::Rgb16), + 8 => Some(image::ColorType::Rgba16), + 9 => Some(image::ColorType::Rgb32F), + 10 => Some(image::ColorType::Rgba32F), + _ => None, + }); group.add(&info_row("Dimensões", &format!("{w} × {h} px"))); let mp = (f64::from(w) * f64::from(h)) / 1_000_000.0; @@ -4297,8 +5570,10 @@ fn build_properties_image_group(path: &Path) -> adw::PreferencesGroup { let (rw, rh) = simplify_ratio(w, h); group.add(&info_row("Razão de aspecto", &format!("{rw}:{rh}"))); } - group.add(&info_row("Modo de cor", color_type_label(color))); - group.add(&info_row("Bits por canal", color_type_bits(color))); + if let Some(color) = color { + group.add(&info_row("Modo de cor", color_type_label(color))); + group.add(&info_row("Bits por canal", color_type_bits(color))); + } } None => { group.add(&info_row( @@ -4309,7 +5584,7 @@ fn build_properties_image_group(path: &Path) -> adw::PreferencesGroup { } // Orientação EXIF — só aparece se houver tag e != 1 (1 = upright). - if let Ok(meta) = metadata::read(path) { + if let Some(meta) = info.and_then(|i| i.metadata.as_ref()) { if let Some(o) = meta.orientation { if o != 1 { group.add(&info_row( @@ -4322,31 +5597,6 @@ fn build_properties_image_group(path: &Path) -> adw::PreferencesGroup { group } -/// Maps `image::ImageFormat` to the short human label users expect to see -/// (matches what's shown in the Convert dialog). -fn format_image_format(f: image::ImageFormat) -> String { - use image::ImageFormat::*; - match f { - Png => "PNG", - Jpeg => "JPEG", - Gif => "GIF", - WebP => "WebP", - Avif => "AVIF", - Tiff => "TIFF", - Bmp => "BMP", - Ico => "ICO", - Hdr => "HDR", - OpenExr => "OpenEXR", - Pnm => "PNM", - Tga => "TGA", - Dds => "DDS", - Farbfeld => "Farbfeld", - Qoi => "QOI", - _ => "—", - } - .to_string() -} - /// Friendly label for the decoded pixel layout, matching what the Convert /// dialog says when picking encoders. fn color_type_label(c: image::ColorType) -> &'static str { @@ -4400,7 +5650,9 @@ fn build_properties_exif_group(meta: &metadata::Metadata) -> adw::PreferencesGro /// ActionRow só-leitura com título+valor. Padrão visual usado no dialog /// de propriedades pra cada campo. fn info_row(title: &str, value: &str) -> adw::ActionRow { - adw::ActionRow::builder().title(title).subtitle(value).build() + let row = adw::ActionRow::builder().use_markup(false).title(title).build(); + row.set_subtitle(value); + row } /// Formata bytes pra uma escala humana (KB/MB/GB em base 1024). @@ -4479,8 +5731,8 @@ fn build_compare_dialog(app: &adw::Application, files: Rc>) -> adw: // Captions gone — titlebar now carries the Antes/Depois info so the // full vertical space goes to the pictures. - let left_picture = make_compare_side(left_path); - let right_picture = make_compare_side(right_path); + let left_picture = make_compare_side(left_path, &window); + let right_picture = make_compare_side(right_path, &window); let paned = gtk::Paned::builder() .orientation(gtk::Orientation::Horizontal) @@ -4526,8 +5778,11 @@ fn build_compare_dialog(app: &adw::Application, files: Rc>) -> adw: // Keyboard: Esc closes. let key = gtk::EventControllerKey::new(); { - let window = window.clone(); + let window = window.downgrade(); key.connect_key_pressed(move |_, k, _, _| { + let Some(window) = window.upgrade() else { + return glib::Propagation::Proceed; + }; if matches!(k, gdk::Key::Escape) { window.close(); glib::Propagation::Stop @@ -4541,19 +5796,12 @@ fn build_compare_dialog(app: &adw::Application, files: Rc>) -> adw: window } -fn make_compare_side(path: &Path) -> gtk::Box { +fn make_compare_side(path: &Path, window: &adw::ApplicationWindow) -> gtk::Box { let picture = gtk::Picture::new(); picture.set_content_fit(gtk::ContentFit::Contain); picture.set_hexpand(true); picture.set_vexpand(true); - match load_texture(path) { - Ok((tex, _, _)) => picture.set_paintable(Some(&tex)), - Err(e) => { - tracing::warn!(?path, error = %e, "compare: failed to load"); - } - } - let column = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(0) @@ -4561,6 +5809,61 @@ fn make_compare_side(path: &Path) -> gtk::Box { .vexpand(true) .build(); column.append(&picture); + let spinner = gtk::Spinner::new(); + spinner.start(); + column.append(&spinner); + let cancel = Arc::new(AtomicBool::new(false)); + let closing = cancel.clone(); + window.connect_unrealize(move |_| closing.store(true, Ordering::Release)); + let input = path.to_path_buf(); + let (tx, rx) = mpsc::sync_channel(1); + std::thread::spawn(move || { + crate::codec::with_cancel(cancel, || { + let result = bigimage_core::open_image(input) + .map(|image| { + let rgba = image.thumbnail(2048, 2048).to_rgba8(); + let size = rgba.dimensions(); + (rgba.into_raw(), size) + }) + .map_err(|e| e.to_string()); + let _ = tx.send(result); + }) + }); + let weak_picture = picture.downgrade(); + let weak_column = column.downgrade(); + let weak_spinner = spinner.downgrade(); + let weak_window = window.downgrade(); + glib::timeout_add_local(Duration::from_millis(16), move || { + if weak_window.upgrade().is_none_or(|w| !w.is_visible()) { + return glib::ControlFlow::Break; + } + match rx.try_recv() { + Ok(result) => { + if let (Some(column), Some(spinner)) = + (weak_column.upgrade(), weak_spinner.upgrade()) + { + column.remove(&spinner); + } + match result { + Ok((raw, (w, h))) => { + if let Some(picture) = weak_picture.upgrade() { + picture.set_paintable(Some(&texture_from_rgba(raw, w, h))); + } + } + Err(error) => { + if let Some(column) = weak_column.upgrade() { + let label = gtk::Label::new(Some(&error)); + label.set_wrap(true); + column.append(&label); + } + } + } + } + Err(mpsc::TryRecvError::Empty) => return glib::ControlFlow::Continue, + Err(mpsc::TryRecvError::Disconnected) => {} + } + glib::ControlFlow::Break + }); column } @@ -4571,41 +5874,11 @@ fn make_compare_side(path: &Path) -> gtk::Box { // something playable. // --------------------------------------------------------------------------- -/// Progress event for the batch dialog. Emitted from the worker thread -/// over an mpsc channel; the timeout poll on the main loop translates -/// each variant into widget updates. -enum BatchTick { - /// Worker just started processing file `idx + 1` of `total`. `file` - /// is the bare display name (no directory) for the progress bar. - Started { idx: usize, total: usize, file: String }, - /// File `done`/`total` finished. The poll uses this to advance the - /// progress fraction without waiting for the next Started message. - Finished { done: usize, total: usize }, - /// Worker exited (cleanly or via cancel). Carries the summary the - /// dialog renders in the status row, plus elapsed wall-clock and - /// bytes-in/out for the after-batch stats line. - Done { - ok: usize, - skip: usize, - fail: usize, - first_err: Option, - done: usize, - total: usize, - cancelled: bool, - elapsed: std::time::Duration, - bytes_in: u64, - bytes_out: u64, - }, -} - /// Prisma Lote — converter em lote. Pega a lista de arquivos recebida via -/// CLI (hoje) e aplica a conversão escolhida um por um, com barra de -/// progresso e botão Cancelar. MVP: só conversão + qualidade + overwrite. -/// Próximas rodadas: resize + rotate combinados, picker de pasta destino, -/// adicionar/remover arquivos dentro do próprio diálogo. +/// CLI, expande pastas e aplica a conversão no pool paralelo compartilhado. pub fn run_batch_dialog(files: Vec) -> i32 { let app = adw::Application::builder().application_id(APP_ID).build(); - let files = Rc::new(RefCell::new(files)); + let files = Rc::new(RefCell::new(expand_batch_inputs(files))); app.connect_activate(move |app| { build_batch_dialog(app, files.clone()).present(); }); @@ -4613,6 +5886,20 @@ pub fn run_batch_dialog(files: Vec) -> i32 { i32::from(u8::from(code)) } +fn expand_batch_inputs(inputs: Vec) -> Vec { + let mut files = Vec::new(); + for input in inputs { + if input.is_dir() { + files.extend(collect_images_in_dir(&input)); + } else if input.is_file() && is_viewable(&input) { + files.push(input); + } + } + files.sort_by(|a, b| a.as_os_str().cmp(b.as_os_str())); + files.dedup(); + files +} + fn build_batch_dialog( app: &adw::Application, files: Rc>>, @@ -4636,7 +5923,7 @@ fn build_batch_dialog( // arquivos e pastas depois do diálogo aberto. `summary_row` é // re-renderizada sempre que mudar a lista, via `refresh_files_row`. let files_group = adw::PreferencesGroup::builder().title("Arquivos").build(); - let summary_row = adw::ActionRow::builder().activatable(false).build(); + let summary_row = adw::ActionRow::builder().use_markup(false).activatable(false).build(); let add_files_btn = gtk::Button::builder() .icon_name("list-add-symbolic") .tooltip_text("Adicionar arquivos") @@ -4658,32 +5945,30 @@ fn build_batch_dialog( let convert_group = adw::PreferencesGroup::builder().title("Conversão").build(); let format_row = adw::ComboRow::builder() + .use_markup(false) .title(gettext("Formato de destino")) .model(>k::StringList::new(&format_names)) .build(); convert_group.add(&format_row); let quality_spin = adw::SpinRow::with_range(1.0, 100.0, 1.0); - quality_spin.set_title(&gettext("Qualidade (JPEG/lossy)")); - quality_spin.set_subtitle("1 pior · 85 bom · 100 sem perdas"); + quality_spin.set_title(&gettext("Qualidade (JPEG/AVIF)")); + quality_spin.set_subtitle("1 menor qualidade · 85 equilíbrio · 100 maior qualidade"); quality_spin.set_value(85.0); convert_group.add(&quality_spin); - let progressive_switch = adw::SwitchRow::builder() - .title(gettext("JPEG progressivo")) - .subtitle("carregamento em passes (formatos que suportam)") - .build(); - convert_group.add(&progressive_switch); - let optimize_switch = adw::SwitchRow::builder() + .use_markup(false) .title(gettext("Otimizar tamanho")) - .subtitle("PNG com compressão máxima; mais lento, arquivo menor") + .subtitle("PNG com compressão máxima; AVIF mais lento; arquivo menor") .build(); convert_group.add(&optimize_switch); + bind_encode_controls(&window, &format_row, &quality_spin, &optimize_switch); // ── Grupo: destino ────────────────────────────────────────────────── let dest_group = adw::PreferencesGroup::builder().title("Destino").build(); let overwrite_row = adw::ComboRow::builder() + .use_markup(false) .title(gettext("Se o arquivo de saída existir")) .model(>k::StringList::new(&overwrite_names)) .build(); @@ -4691,6 +5976,7 @@ fn build_batch_dialog( // Pasta de saída: None = mesma do original. Clicar abre FolderDialog. let output_dir: Rc>> = Rc::new(RefCell::new(None)); let folder_row = adw::ActionRow::builder() + .use_markup(false) .title("Pasta de saída") .subtitle("Mesma pasta do arquivo de origem") .activatable(true) @@ -4773,7 +6059,7 @@ fn build_batch_dialog( let files = files.clone(); let summary_row = summary_row.clone(); let window = window.clone(); - add_files_btn.connect_clicked(move |_| { + dialog_signal!(window, add_files_btn, connect_clicked, move |_| { add_files_to_batch(&window, &files, &summary_row); }); } @@ -4781,7 +6067,7 @@ fn build_batch_dialog( let files = files.clone(); let summary_row = summary_row.clone(); let window = window.clone(); - add_folder_btn.connect_clicked(move |_| { + dialog_signal!(window, add_folder_btn, connect_clicked, move |_| { add_folder_to_batch(&window, &files, &summary_row); }); } @@ -4793,7 +6079,7 @@ fn build_batch_dialog( let folder_row = folder_row.clone(); let clear_btn = clear_folder_btn.clone(); let window = window.clone(); - folder_row.clone().connect_activated(move |_| { + dialog_signal!(window, folder_row.clone(), connect_activated, move |_| { pick_output_folder(&window, &output_dir, &folder_row, &clear_btn); }); } @@ -4801,7 +6087,7 @@ fn build_batch_dialog( let output_dir = output_dir.clone(); let folder_row = folder_row.clone(); let clear_btn = clear_folder_btn.clone(); - clear_folder_btn.connect_clicked(move |_| { + dialog_signal!(window, clear_folder_btn, connect_clicked, move |_| { *output_dir.borrow_mut() = None; folder_row.set_subtitle("Mesma pasta do arquivo de origem"); clear_btn.set_visible(false); @@ -4812,7 +6098,7 @@ fn build_batch_dialog( let cancelled = cancelled.clone(); let running = running.clone(); let window = window.clone(); - cancel_btn.connect_clicked(move |_| { + dialog_signal!(window, cancel_btn, connect_clicked, move |_| { if running.get() { cancelled.store(true, std::sync::atomic::Ordering::Relaxed); } else { @@ -4826,7 +6112,6 @@ fn build_batch_dialog( let format_row = format_row.clone(); let overwrite_row = overwrite_row.clone(); let quality_spin = quality_spin.clone(); - let progressive_switch = progressive_switch.clone(); let optimize_switch = optimize_switch.clone(); let progress = progress.clone(); let status = status.clone(); @@ -4836,20 +6121,22 @@ fn build_batch_dialog( let cancelled = cancelled.clone(); let running = running.clone(); let output_dir = output_dir.clone(); - apply_btn.clone().connect_clicked(move |_| { + dialog_signal!(window, apply_btn.clone(), connect_clicked, move |_| { let fmt_idx = format_row.selected() as usize; let policy_idx = overwrite_row.selected() as usize; let fmt = CONVERT_FORMATS[fmt_idx].1; let policy = OVERWRITE_CHOICES[policy_idx].1; let opts = EncodeOptions { quality: Some(quality_spin.value() as u8), - progressive: progressive_switch.is_active(), optimize: optimize_switch.is_active(), ..EncodeOptions::default() }; let out_dir_snapshot = output_dir.borrow().clone(); let files_owned: Vec = files.borrow().clone(); - let total = files_owned.len(); + if files_owned.is_empty() { + status.set_text(&gettext("Adicione ao menos uma imagem para processar.")); + return; + } // Reset cancel for a re-run, mark a worker as live. cancelled.store(false, std::sync::atomic::Ordering::Relaxed); @@ -4860,65 +6147,11 @@ fn build_batch_dialog( progress.set_text(Some(&gettext("Iniciando…"))); status.set_text(&gettext("Processando…")); - // Worker thread runs the entire batch off the GTK main loop. - // Progress flows back as `BatchTick` messages; the timeout - // poll on the main loop translates them into widget updates. - let (tx, rx) = std::sync::mpsc::channel::(); - let cancelled_w = cancelled.clone(); - std::thread::spawn(move || { - let start = std::time::Instant::now(); - let bytes_in: u64 = files_owned - .iter() - .filter_map(|p| std::fs::metadata(p).ok()) - .map(|m| m.len()) - .sum(); - let mut bytes_out = 0u64; - let mut ok = 0usize; - let mut skip = 0usize; - let mut fail = 0usize; - let mut first_err: Option = None; - let mut done_count = 0usize; - for (idx, f) in files_owned.iter().enumerate() { - if cancelled_w.load(std::sync::atomic::Ordering::Relaxed) { - break; - } - let display_name = f - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| f.display().to_string()); - let _ = tx.send(BatchTick::Started { idx, total, file: display_name }); - match convert_file_to(f, out_dir_snapshot.as_deref(), fmt, &opts, policy) { - Ok(ConvertOutcome::Written { output }) => { - ok += 1; - if let Ok(meta) = std::fs::metadata(&output) { - bytes_out += meta.len(); - } - } - Ok(ConvertOutcome::Skipped { .. }) => skip += 1, - Err(e) => { - fail += 1; - if first_err.is_none() { - first_err = Some(format!("{}: {e}", f.display())); - } - } - } - done_count += 1; - let _ = tx.send(BatchTick::Finished { done: done_count, total }); - } - let was_cancelled = cancelled_w.load(std::sync::atomic::Ordering::Relaxed); - let _ = tx.send(BatchTick::Done { - ok, - skip, - fail, - first_err, - done: done_count, - total, - cancelled: was_cancelled, - elapsed: start.elapsed(), - bytes_in, - bytes_out, - }); - }); + let rx = batch_runner::spawn_batch_workers( + files_owned, + move |path| convert_file_to(path, out_dir_snapshot.as_deref(), fmt, &opts, policy), + cancelled.clone(), + ); let progress = progress.clone(); let status = status.clone(); @@ -4928,13 +6161,11 @@ fn build_batch_dialog( let running = running.clone(); glib::timeout_add_local(std::time::Duration::from_millis(50), move || loop { match rx.try_recv() { - Ok(BatchTick::Started { idx, total, file }) => { - progress.set_text(Some(&format!("{}/{} · {}", idx + 1, total, file))); - } - Ok(BatchTick::Finished { done, total }) => { + Ok(batch_runner::AsyncBatchEvent::Progress { done, total, file }) => { progress.set_fraction(done as f64 / total.max(1) as f64); + progress.set_text(Some(&format!("{done}/{total} · {file}"))); } - Ok(BatchTick::Done { + Ok(batch_runner::AsyncBatchEvent::Done { ok, skip, fail, @@ -5031,9 +6262,6 @@ fn add_files_to_batch( "image/gif", "image/tiff", "image/bmp", - "image/heic", - "image/heif", - "image/jxl", ] { filter.add_mime_type(mime); } @@ -5095,8 +6323,10 @@ fn append_unique(files: &Rc>>, new_paths: Vec) { } /// Extensões que aceitamos numa varredura de pasta. Case-insensitive. -const IMAGE_EXTENSIONS: &[&str] = - &["jpg", "jpeg", "png", "webp", "avif", "gif", "tif", "tiff", "bmp", "heic", "heif", "jxl"]; +const IMAGE_EXTENSIONS: &[&str] = &[ + "jpg", "jpeg", "png", "webp", "avif", "gif", "tif", "tiff", "bmp", "ico", "pnm", "ppm", "pgm", + "pbm", "pam", "tga", "qoi", "hdr", "exr", +]; fn collect_images_in_dir(dir: &Path) -> Vec { let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; @@ -5183,7 +6413,7 @@ fn build_animate_dialog(app: &adw::Application, files: Rc>) -> adw: let close = gtk::Button::with_label("Fechar"); { let window = window.clone(); - close.connect_clicked(move |_| window.close()); + dialog_signal!(window, close, connect_clicked, move |_| window.close()); } content.append(&close); @@ -5201,6 +6431,7 @@ fn build_animate_dialog(app: &adw::Application, files: Rc>) -> adw: delay_spin.set_value(100.0); let loop_row = adw::ComboRow::builder() + .use_markup(false) .title("Loop") .model(>k::StringList::new(&[ "Infinito (padrão)", @@ -5221,13 +6452,15 @@ fn build_animate_dialog(app: &adw::Application, files: Rc>) -> adw: .parent() .map(|p| p.join("animated.gif")) .unwrap_or_else(|| PathBuf::from("animated.gif")); - let output_row = adw::EntryRow::builder().title("Arquivo de saída (.gif)").build(); + let output_row = + adw::EntryRow::builder().use_markup(false).title("Arquivo de saída (.gif)").build(); output_row.set_text(&default_output.to_string_lossy()); let files_row = adw::ActionRow::builder() + .use_markup(false) .title(format!("{} quadro(s)", files.len())) - .subtitle(files_subtitle(&files)) .build(); + files_row.set_subtitle(&files_subtitle(&files)); let group = adw::PreferencesGroup::new(); group.add(&files_row); @@ -5262,7 +6495,7 @@ fn build_animate_dialog(app: &adw::Application, files: Rc>) -> adw: { let window = window.clone(); - cancel_btn.connect_clicked(move |_| window.close()); + dialog_signal!(window, cancel_btn, connect_clicked, move |_| window.close()); } { @@ -5275,7 +6508,7 @@ fn build_animate_dialog(app: &adw::Application, files: Rc>) -> adw: let apply_btn = apply_btn.clone(); let cancel_btn = cancel_btn.clone(); let window = window.clone(); - apply_btn.clone().connect_clicked(move |_| { + dialog_signal!(window, apply_btn.clone(), connect_clicked, move |_| { let loop_mode = match loop_row.selected() { 0 => LoopMode::Infinite, 1 => LoopMode::Once, @@ -5348,10 +6581,18 @@ fn remove_bg_one_with_session( session: &mut bigimage_ai::background::BgSession, src: &Path, ) -> Result { + bigimage_core::content::ensure_static(src).map_err(|e| e.to_string())?; let img = bigimage_core::open_image(src).map_err(|e| format!("decode: {e}"))?; let out = session.process(&img).map_err(|e| e.to_string())?; let dest = nobg_output_path(src); - out.save_with_format(&dest, image::ImageFormat::Png).map_err(|e| format!("encode: {e}"))?; + bigimage_core::save_image( + out, + &dest, + Format::Png, + &EncodeOptions::default(), + OverwritePolicy::Replace, + ) + .map_err(|e| format!("encode: {e}"))?; Ok(dest) } @@ -5418,7 +6659,7 @@ fn build_remove_bg_dialog( let close = gtk::Button::with_label("Fechar"); { let window = window.clone(); - close.connect_clicked(move |_| window.close()); + dialog_signal!(window, close, connect_clicked, move |_| window.close()); } content.append(&close); @@ -5445,14 +6686,26 @@ fn build_remove_bg_dialog( content.append(&banner); } + let memory_row = gtk::Label::builder() + .label(gettext( + "Remoção de fundo: requer 8 GiB de RAM livre para iniciar. Este recurso é opcional.", + )) + .wrap(true) + .xalign(0.0) + .build(); + memory_row.add_css_class("dim-label"); + content.append(&memory_row); + let files_row = adw::ActionRow::builder() + .use_markup(false) .title(format!("{} imagem(ns)", files.len())) - .subtitle(files_subtitle(&files)) .build(); + files_row.set_subtitle(&files_subtitle(&files)); let files_icon = gtk::Image::from_icon_name("image-x-generic-symbolic"); files_row.add_prefix(&files_icon); let model_row = adw::ActionRow::builder() + .use_markup(false) .title("Modelo") .subtitle("BiRefNet-lite (MIT) · roda offline no seu computador") .build(); @@ -5463,6 +6716,7 @@ fn build_remove_bg_dialog( // eram interpretados como tag malformada e a linha ficava vazia. // Usar chevrons Unicode evita o parser sem escaping manual. let output_row = adw::ActionRow::builder() + .use_markup(false) .title(gettext("Saída")) .subtitle("«origem»_nobg.png · PNG com alfa, sem sobrescrever") .build(); @@ -5525,7 +6779,7 @@ fn build_remove_bg_dialog( } { let window = window.clone(); - cancel_btn.connect_clicked(move |_| window.close()); + dialog_signal!(window, cancel_btn, connect_clicked, move |_| window.close()); } { @@ -5536,7 +6790,11 @@ fn build_remove_bg_dialog( let cancel_btn = cancel_btn.clone(); let window = window.clone(); let cancelled = cancelled.clone(); - apply_btn.clone().connect_clicked(move |_| { + dialog_signal!(window, apply_btn.clone(), connect_clicked, move |_| { + if let Err(error) = bigimage_ai::background::check_resources() { + status.set_text(&error.to_string()); + return; + } apply_btn.set_sensitive(false); // Cancelar continua disponível — vira "abortar e fechar". cancel_btn.set_label(&gettext("Cancelar")); @@ -5793,6 +7051,30 @@ mod anchor_tests { assert_eq!(fv.get_pixel(0, 0).0, [255, 0, 0, 255]); } + #[test] + fn edited_save_is_atomic_and_preserves_permissions() { + use super::{write_edits_to_disk, EditOp}; + use image::{Rgb, RgbImage}; + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("photo.png"); + RgbImage::from_pixel(3, 2, Rgb([10, 20, 30])).save(&path).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap(); + + write_edits_to_disk(&path, &[EditOp::RotateCw]).unwrap(); + + let saved = image::open(&path).unwrap(); + assert_eq!((saved.width(), saved.height()), (2, 3)); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o640); + let leftovers: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .flatten() + .filter(|entry| entry.file_name().to_string_lossy().contains(".bigiris-")) + .collect(); + assert!(leftovers.is_empty(), "atomic save left a temporary file behind"); + } + #[test] fn histogram_counts_channels_and_ignores_alpha() { use super::compute_histogram; @@ -6006,3 +7288,534 @@ mod anchor_tests { assert_eq!(p, PathBuf::from("solo_nobg.png")); } } + +#[cfg(test)] +mod folder_nav_tests { + use super::{expand_to_folder, is_viewable, natural_cmp}; + use std::cmp::Ordering; + use std::path::{Path, PathBuf}; + + fn touch(dir: &Path, name: &str) -> PathBuf { + let path = dir.join(name); + std::fs::write(&path, b"").expect("escrever arquivo de teste"); + path + } + + #[test] + fn digits_compare_as_numbers_not_text() { + assert_eq!(natural_cmp("2.png", "10.png"), Ordering::Less); + assert_eq!(natural_cmp("10.png", "2.png"), Ordering::Greater); + assert_eq!(natural_cmp("100.png", "99.png"), Ordering::Greater); + } + + #[test] + fn equal_values_order_by_leading_zeros() { + assert_eq!(natural_cmp("7.png", "007.png"), Ordering::Less); + assert_eq!(natural_cmp("7.png", "7.png"), Ordering::Equal); + } + + #[test] + fn text_compares_case_insensitively() { + assert_eq!(natural_cmp("alpha.png", "Beta.png"), Ordering::Less); + assert_eq!(natural_cmp("IMG_2.jpg", "img_10.jpg"), Ordering::Less); + } + + #[test] + fn viewable_follows_the_core_format_table() { + assert!(is_viewable(Path::new("a.png"))); + assert!(is_viewable(Path::new("a.JPEG"))); + assert!(is_viewable(Path::new("a.avif"))); + assert!(!is_viewable(Path::new("a.txt"))); + assert!(!is_viewable(Path::new("sem-extensao"))); + } + + #[test] + fn single_file_expands_to_its_folder_in_natural_order() { + let dir = tempfile::tempdir().expect("tempdir"); + for name in ["10.png", "2.png", "1.png", "notas.txt"] { + touch(dir.path(), name); + } + let clicked = dir.path().join("2.png"); + + let (files, idx) = expand_to_folder(vec![clicked.clone()]); + + let names: Vec = + files.iter().map(|p| p.file_name().unwrap().to_string_lossy().into_owned()).collect(); + assert_eq!(names, vec!["1.png", "2.png", "10.png"], "o .txt fica de fora"); + assert_eq!(idx, 1, "abre posicionado no arquivo clicado"); + } + + #[test] + fn explicit_selection_is_left_alone() { + let dir = tempfile::tempdir().expect("tempdir"); + let a = touch(dir.path(), "1.png"); + let b = touch(dir.path(), "2.png"); + touch(dir.path(), "3.png"); + + let (files, idx) = expand_to_folder(vec![a.clone(), b.clone()]); + + assert_eq!(files, vec![a, b], "seleção da linha de comando é respeitada"); + assert_eq!(idx, 0); + } + + #[test] + fn lone_image_in_folder_stays_alone() { + let dir = tempfile::tempdir().expect("tempdir"); + let only = touch(dir.path(), "unica.png"); + touch(dir.path(), "leia-me.txt"); + + let (files, idx) = expand_to_folder(vec![only.clone()]); + + assert_eq!(files, vec![only]); + assert_eq!(idx, 0); + } + + #[test] + fn missing_file_falls_back_to_the_input() { + let dir = tempfile::tempdir().expect("tempdir"); + let ghost = dir.path().join("nao-existe.png"); + + let (files, idx) = expand_to_folder(vec![ghost.clone()]); + + assert_eq!(files, vec![ghost]); + assert_eq!(idx, 0); + } +} + +#[cfg(test)] +mod lifecycle_tests { + use super::*; + + #[test] + fn prisma_formats_match_tested_core_formats() { + let formats: std::collections::HashSet<_> = + CONVERT_FORMATS.iter().map(|(_, format)| *format).collect(); + assert_eq!(formats.len(), CONVERT_FORMATS.len(), "duplicate UI format"); + assert_eq!(formats, Format::all().iter().copied().collect()); + } + + #[test] + fn idle_decoder_exits_when_loader_is_dropped() { + let loader = ViewerLoader::new(); + let witness = Arc::downgrade(&loader.pending); + drop(loader); + let deadline = std::time::Instant::now() + Duration::from_secs(3); + while witness.upgrade().is_some() && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + assert!(witness.upgrade().is_none(), "idle decoder retained its queue after shutdown"); + } + + // Capture only our synthetic windows, never the user's desktop. + fn capture_window(window: &adw::ApplicationWindow, name: &str) { + let Some(directory) = std::env::var_os("BIGIRIS_TEST_SCREENSHOTS_DIR") else { return }; + let directory = PathBuf::from(directory); + std::fs::create_dir_all(&directory).unwrap(); + let context = glib::MainContext::default(); + let deadline = std::time::Instant::now() + Duration::from_millis(150); + while std::time::Instant::now() < deadline { + while context.pending() { + context.iteration(false); + } + std::thread::sleep(Duration::from_millis(5)); + } + let paintable = gtk::WidgetPaintable::new(Some(window)); + let snapshot = gtk::Snapshot::new(); + paintable.snapshot(&snapshot, window.width() as f64, window.height() as f64); + let node = snapshot.to_node().expect("window must have rendered content"); + let texture = window.renderer().expect("window renderer").render_texture(&node, None); + texture.save_to_png(directory.join(format!("{name}.png"))).unwrap(); + } + + fn exercise_pointer_zoom(app: &adw::Application, directory: &Path) { + let source = directory.join("zoom-grid.png"); + image::RgbImage::from_fn(1600, 1200, |x, y| { + image::Rgb([(x / 8 % 256) as u8, (y / 8 % 256) as u8, 100]) + }) + .save(&source) + .unwrap(); + let (window, witness) = build_window_tracked(app, Rc::new(vec![source.clone(), source]), 0); + window.set_default_size(640, 500); + let state = witness.upgrade().unwrap(); + { + let mut s = state.borrow_mut(); + s.autohide_ui = false; + s.ui_pinned = true; + } + window.present(); + let animations_before = window.settings().is_gtk_enable_animations(); + window.settings().set_gtk_enable_animations(true); + let context = glib::MainContext::default(); + let wait = |condition: &dyn Fn() -> bool| { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while !condition() { + while context.pending() { + context.iteration(false); + } + assert!(std::time::Instant::now() < deadline, "zoom did not settle"); + std::thread::sleep(Duration::from_millis(5)); + } + }; + wait(&|| state.borrow().natural == (1600, 1200) && state.borrow().scroller.width() > 0); + // Allow the first real allocation after decode, not just header math. + for _ in 0..10 { + while context.pending() { + context.iteration(false); + } + std::thread::sleep(Duration::from_millis(10)); + } + let point = { + let s = state.borrow(); + ( + s.scroller.hadjustment().page_size() * 0.68, + s.scroller.vadjustment().page_size() * 0.52, + ) + }; + let image_point = || { + let s = state.borrow(); + let bounds = s.picture.compute_bounds(&s.scroller).unwrap(); + let paintable = s.picture.paintable().unwrap(); + let (iw, ih) = + (f64::from(paintable.intrinsic_width()), f64::from(paintable.intrinsic_height())); + let (pw, ph) = (f64::from(s.picture.width()), f64::from(s.picture.height())); + let mut scale = (pw / iw).min(ph / ih); + if s.picture.content_fit() == gtk::ContentFit::ScaleDown { + scale = scale.min(1.0); + } + let origin = ( + f64::from(bounds.x()) + (pw - iw * scale) / 2.0, + f64::from(bounds.y()) + (ph - ih * scale) / 2.0, + ); + ( + (point.0 - origin.0) / (iw * scale) * f64::from(s.natural.0), + (point.1 - origin.1) / (ih * scale) * f64::from(s.natural.1), + ) + }; + let rendered_pixel = || { + let until = std::time::Instant::now() + Duration::from_millis(80); + while std::time::Instant::now() < until { + while context.pending() { + context.iteration(false); + } + std::thread::sleep(Duration::from_millis(5)); + } + let point_in_window = state + .borrow() + .scroller + .compute_point(&window, &graphene::Point::new(point.0 as f32, point.1 as f32)) + .unwrap(); + let snapshot = gtk::Snapshot::new(); + gtk::WidgetPaintable::new(Some(&window)).snapshot( + &snapshot, + f64::from(window.width()), + f64::from(window.height()), + ); + let node = snapshot.to_node().unwrap(); + let texture = window.renderer().unwrap().render_texture(&node, None); + let path = directory.join("zoom-render.png"); + texture.save_to_png(&path).unwrap(); + *image::open(path) + .unwrap() + .to_rgba8() + .get_pixel(point_in_window.x().round() as u32, point_in_window.y().round() as u32) + }; + let frame_trace = Rc::new(RefCell::new(Vec::new())); + let clock = window.frame_clock().unwrap(); + let weak = Rc::downgrade(&state); + let trace = frame_trace.clone(); + let sample_signal = clock.connect_after_paint(move |clock| { + let Some(state) = weak.upgrade() else { return }; + let s = state.borrow(); + let Some(bounds) = s.picture.compute_bounds(&s.scroller) else { return }; + let Some(paintable) = s.picture.paintable() else { return }; + let iw = f64::from(paintable.intrinsic_width()); + let ih = f64::from(paintable.intrinsic_height()); + let pw = f64::from(s.picture.width()); + let ph = f64::from(s.picture.height()); + let mut scale = (pw / iw).min(ph / ih); + if s.picture.content_fit() == gtk::ContentFit::ScaleDown { + scale = scale.min(1.0); + } + let x = f64::from(bounds.x()) + (pw - iw * scale) / 2.0; + let y = f64::from(bounds.y()) + (ph - ih * scale) / 2.0; + trace.borrow_mut().push(( + (point.0 - x) / (iw * scale) * 1600.0, + (point.1 - y) / (ih * scale) * 1200.0, + iw * scale, + ih * scale, + clock.frame_time(), + )); + }); + let before = image_point(); + let pixel_before = rendered_pixel(); + capture_window(&window, "zoom-fit"); + let controllers = state.borrow().scroller.observe_controllers(); + let motion = (0..controllers.n_items()) + .find_map(|index| { + controllers.item(index)?.downcast::().ok().filter( + |controller| controller.name().as_deref() == Some("bigiris-zoom-pointer"), + ) + }) + .expect("canvas pointer controller"); + motion.emit_by_name::<()>("motion", &[&point.0, &point.1]); + let reports = RefCell::new(Vec::new()); + let anchored = |name: &str, expected: (f64, f64)| { + wait(&|| state.borrow().pending_zoom.is_none()); + let actual = image_point(); + assert!( + (actual.0 - expected.0).abs() < 3.0 && (actual.1 - expected.1).abs() < 3.0, + "displayed point drifted: {expected:?} -> {actual:?}" + ); + let pixel = rendered_pixel(); + assert!( + pixel.0.iter().zip(pixel_before.0).all(|(a, b)| a.abs_diff(b) <= 2), + "rendered pixel drifted: {pixel_before:?} -> {pixel:?}" + ); + let frames = std::mem::take(&mut *frame_trace.borrow_mut()); + assert!(!frames.is_empty(), "no painted frames for {name}"); + let area = { + let s = state.borrow(); + (s.scroller.hadjustment().page_size(), s.scroller.vadjustment().page_size()) + }; + // When a side is smaller than the viewport, GTK centers it and + // cannot scroll it. Compare to the constrained geometric target; + // once scrolling is possible the original point remains anchored. + let projected = + |natural_point: f64, natural: f64, size: f64, cursor: f64, page: f64| { + let padding = ((page - size) / 2.0).max(0.0); + let target = (natural_point / natural * size - cursor + padding) + .clamp(0.0, (size - page).max(0.0)); + (cursor + target - padding) / size * natural + }; + assert!( + frames.iter().all(|&(x, y, width, height, _)| { + let px = projected(expected.0, 1600.0, width, point.0, area.0); + let py = projected(expected.1, 1200.0, height, point.1, area.1); + (x - px).abs() * width / 1600.0 < 1.5 && (y - py).abs() * height / 1200.0 < 1.5 + }), + "zoom drift during painted frames ({name}): before={expected:?}, frames={frames:?}" + ); + let first = frames.first().unwrap().2; + let last = frames.last().unwrap().2; + let increasing = last >= first; + assert!( + name == "direction-change" + || frames.windows(2).all(|pair| if increasing { + pair[1].2 >= pair[0].2 - 1.0 + } else { + pair[1].2 <= pair[0].2 + 1.0 + }), + "zoom reversed during transition ({name}): {frames:?}" + ); + if name == "direction-change" { + assert!(frames.windows(2).any(|p| p[1].2 < p[0].2 - 1.0)); + assert!(frames.windows(2).any(|p| p[1].2 > p[0].2 + 1.0)); + } + let intermediate = frames + .iter() + .filter(|frame| frame.2 > first.min(last) + 1.0 && frame.2 < first.max(last) - 1.0) + .count(); + let animations = window.settings().is_gtk_enable_animations(); + if animations && (last - first).abs() > 32.0 { + assert!(intermediate >= 2, "zoom skipped intermediate scales ({name}): {frames:?}"); + } + reports.borrow_mut().push(serde_json::json!({"case":name, + "animations":animations,"anchor":expected,"cursor":point,"viewport":area, + "max_screen_error":1.5,"frames":frames,"intermediate_frames":intermediate})); + }; + handle_key(&state, gdk::Key::plus, gdk::ModifierType::empty()); + anchored("first-in", before); + let rapid_anchor = image_point(); + for _ in 0..4 { + handle_key(&state, gdk::Key::plus, gdk::ModifierType::empty()); + } + anchored("rapid-in", rapid_anchor); + let reversal_anchor = image_point(); + zoom_out(&state); + wait(&|| frame_trace.borrow().len() >= 4); + assert!(state.borrow().pending_zoom.is_some(), "reversal test missed the animation"); + zoom_in(&state); + anchored("direction-change", reversal_anchor); + let toolbar_anchor = image_point(); + zoom_out(&state); // the toolbar action uses the same selected point + anchored("toolbar-out", toolbar_anchor); + let full_anchor = image_point(); + handle_key(&state, gdk::Key::_1, gdk::ModifierType::empty()); + anchored("100-percent", full_anchor); + assert_eq!(state.borrow().picture.width(), 1600); + assert_eq!(state.borrow().picture.height(), 1200); + capture_window(&window, "zoom-pointer-100pct"); + let reduced_anchor = image_point(); + window.settings().set_gtk_enable_animations(false); + zoom_out(&state); + anchored("reduced-motion", reduced_anchor); + window.settings().set_gtk_enable_animations(true); + zoom_in(&state); + let drag = (0..controllers.n_items()) + .find_map(|index| controllers.item(index)?.downcast::().ok()) + .unwrap(); + drag.emit_by_name::<()>("drag-begin", &[&point.0, &point.1]); + assert!(state.borrow().pending_zoom.is_none(), "animation fought manual pan"); + if let Some(folder) = std::env::var_os("BIGIRIS_TEST_ZOOM_REPORT_DIR") { + let folder = PathBuf::from(folder); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write( + folder.join("painted-frames.json"), + serde_json::to_vec_pretty(&reports.into_inner()).unwrap(), + ) + .unwrap(); + } + clock.disconnect(sample_signal); + window.settings().set_gtk_enable_animations(animations_before); + zoom_in(&state); + handle_key(&state, gdk::Key::_0, gdk::ModifierType::empty()); + assert!(state.borrow().pending_zoom.is_none()); + zoom_in(&state); + navigate_relative(&state, 1); + assert!(state.borrow().pending_zoom.is_none(), "old zoom survived navigation"); + wait(&|| state.borrow().texture.is_some() && !state.borrow().loading_spinner.is_visible()); + zoom_in(&state); + window.destroy(); + drop(state); + drop(window); + wait(&|| witness.upgrade().is_none()); + } + + #[test] + #[ignore = "requires a graphical display; run alone with GSETTINGS_BACKEND=memory"] + fn viewer_lifecycle_releases_state_and_workers() { + assert_eq!( + std::env::var("GSETTINGS_BACKEND").as_deref(), + Ok("memory"), + "synthetic GUI tests require GSETTINGS_BACKEND=memory" + ); + adw::init().expect("GTK display required"); + if let Ok(scheme) = std::env::var("BIGIRIS_TEST_COLOR_SCHEME") { + let scheme = match scheme.as_str() { + "light" => adw::ColorScheme::ForceLight, + "dark" => adw::ColorScheme::ForceDark, + _ => panic!("test color scheme must be light or dark"), + }; + adw::StyleManager::default().set_color_scheme(scheme); + } + let app = adw::Application::builder() + .application_id("com.biglinux.Iris.QualityProbe") + .flags(gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<&gio::Cancellable>).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("fixture <&>.png"); + image::RgbImage::from_pixel(320, 240, image::Rgb([10, 20, 30])).save(&source).unwrap(); + let context = glib::MainContext::default(); + for index in 0..30 { + let (window, state) = build_window_tracked(&app, Rc::new(vec![source.clone()]), 0); + window.present(); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while state.upgrade().is_some_and(|s| s.borrow().natural == (0, 0)) { + while context.pending() { + context.iteration(false); + } + assert!(std::time::Instant::now() < deadline, "viewer failed to decode fixture"); + std::thread::sleep(Duration::from_millis(5)); + } + if index == 0 { + capture_window(&window, "viewer"); + } + let window_witness = window.downgrade(); + window.destroy(); + drop(window); + let deadline = std::time::Instant::now() + Duration::from_secs(3); + while state.upgrade().is_some() && std::time::Instant::now() < deadline { + while context.pending() { + context.iteration(false); + } + std::thread::sleep(Duration::from_millis(5)); + } + assert!( + state.upgrade().is_none(), + "viewer state leaked: references={}, window_alive={}", + state.strong_count(), + window_witness.upgrade().is_some() + ); + } + exercise_pointer_zoom(&app, dir.path()); + + // A large collection creates only one page of GTK thumbnails. + let (large, witness) = + build_window_tracked(&app, Rc::new(vec![source.clone(); 10_000]), 9_500); + large.present(); + { + let state = witness.upgrade().unwrap(); + assert_eq!(state.borrow().film_strip_buttons.len(), 41); + state.borrow_mut().idx = 9_999; + refresh_film_strip_active(&state); + let state = state.borrow(); + assert!(state.film_strip_buttons.len() <= 41); + assert!(state.idx >= state.film_strip_start); + assert!(state.idx < state.film_strip_start + state.film_strip_buttons.len()); + } + large.destroy(); + drop(large); + while context.pending() { + context.iteration(false); + } + assert!(witness.upgrade().is_none(), "large collection retained viewer state"); + + let files = Rc::new(vec![source.clone(), source.clone()]); + type DialogBuilder = fn(&adw::Application, Rc>) -> adw::ApplicationWindow; + fn batch(app: &adw::Application, files: Rc>) -> adw::ApplicationWindow { + build_batch_dialog(app, Rc::new(RefCell::new(files.as_ref().clone()))) + } + fn properties(app: &adw::Application, files: Rc>) -> adw::ApplicationWindow { + build_properties_window(app, &files[0]) + } + for (index, builder) in [ + build_convert_dialog as DialogBuilder, + build_resize_dialog, + build_rotate_dialog, + build_crop_dialog, + build_extent_dialog, + build_upscale_dialog, + build_flip_dialog, + build_adjust_dialog, + build_compare_dialog, + build_animate_dialog, + build_remove_bg_dialog, + batch, + properties, + ] + .into_iter() + .enumerate() + { + let window = builder(&app, files.clone()); + let witness = window.downgrade(); + window.present(); + while context.pending() { + context.iteration(false); + } + capture_window(&window, &format!("prisma-{index:02}")); + window.destroy(); + drop(window); + let deadline = std::time::Instant::now() + Duration::from_secs(3); + while witness.upgrade().is_some() && std::time::Instant::now() < deadline { + while context.pending() { + context.iteration(false); + } + std::thread::sleep(Duration::from_millis(5)); + } + assert!(witness.upgrade().is_none(), "Prisma window {index} leaked after destroy"); + } + } +} + +#[cfg(test)] +mod print_pixel_tests { + #[test] + fn cairo_pixels_premultiply_alpha_in_native_order() { + let result = + super::premultiply_for_cairo(vec![200, 100, 50, 128, 255, 1, 240, 0, 2, 3, 4, 255]); + let values: Vec<_> = + result.as_chunks::<4>().0.iter().map(|p| u32::from_ne_bytes(*p)).collect(); + assert_eq!(values, vec![0x80643219, 0x00000000, 0xff020304]); + } +} diff --git a/crates/bigiris/src/gui/batch_runner.rs b/crates/bigiris/src/gui/batch_runner.rs index 7e85a99..cefb69a 100644 --- a/crates/bigiris/src/gui/batch_runner.rs +++ b/crates/bigiris/src/gui/batch_runner.rs @@ -22,6 +22,8 @@ //! batch is skipped cleanly. use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{mpsc, Arc}; use std::time::{Duration, Instant}; use bigimage_core::ConvertOutcome; @@ -135,7 +137,7 @@ pub(super) fn finish_dialog( } /// Progress message sent from the worker pool to the UI poll. -enum AsyncBatchEvent { +pub(super) enum AsyncBatchEvent { /// One file finished (in any of the workers, in any order). `done` /// is the cumulative count of finished files; `total` is fixed at /// the start. `file` is the bare file name (no directory) of the @@ -153,71 +155,34 @@ enum AsyncBatchEvent { elapsed: Duration, bytes_in: u64, bytes_out: u64, + done: usize, + total: usize, + cancelled: bool, }, } -/// Pick a worker count that uses most of the box but never starves the -/// GUI thread. On a 16-core desktop we burn 15 threads on the batch and -/// leave one for the main loop / file manager / browser. On a 2-core -/// laptop we still parallelize across both cores (saturating `max(1)` -/// keeps single-core hardware functional). -fn worker_pool_size() -> usize { - std::thread::available_parallelism().map(|n| n.get().saturating_sub(1).max(1)).unwrap_or(1) -} - -/// Run a per-file batch across a worker pool, polling progress on the -/// GTK main loop. The `work` closure is the per-file CPU op (e.g. -/// `bigimage_core::convert_file`); it must be `Fn + Send + Sync + -/// 'static` so multiple threads can invoke it concurrently. Widgets -/// (`status`, buttons, window) stay on the main thread — they're only -/// touched inside the timeout closure, which runs locally. -/// -/// Cancellation: closing the window flips an atomic flag the workers -/// check between files. The current file in each worker finishes (the -/// encoder isn't interruptible mid-flight today), but the rest of the -/// batch is skipped cleanly. -#[allow(clippy::too_many_arguments)] -pub(super) fn run_batch_async( +/// Spawn the GTK-independent worker pool. The generic dialog and the richer +/// folder/batch window share this exact engine, so progress, cancellation, +/// parallelism and accounting cannot drift apart again. +pub(super) fn spawn_batch_workers( files: Vec, work: W, - busy_label: String, - status: gtk::Label, - apply_btn: gtk::Button, - cancel_btn: gtk::Button, - window: adw::ApplicationWindow, -) where + cancelled: Arc, +) -> mpsc::Receiver +where W: Fn(&Path) -> bigimage_core::Result + Send + Sync + 'static, { - use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; - use std::sync::mpsc; - use std::sync::Arc; - let (tx, rx) = mpsc::channel::(); - let cancelled = Arc::new(AtomicBool::new(false)); - - { - let cancelled = cancelled.clone(); - window.connect_close_request(move |_| { - cancelled.store(true, Ordering::Relaxed); - gtk4::glib::Propagation::Proceed - }); - } - - let coordinator_cancelled = cancelled.clone(); std::thread::spawn(move || { let total = files.len(); let start = Instant::now(); - // Sum input bytes upfront — cheap (one stat per file) and - // sequential so the order is stable. We only count files we can - // stat; missing files surface later as a Decode error per-file. - let bytes_in: u64 = - files.iter().filter_map(|p| std::fs::metadata(p).ok()).map(|m| m.len()).sum(); + let bytes_in = Arc::new(AtomicU64::new(0)); let files = Arc::new(files); let next_idx = Arc::new(AtomicUsize::new(0)); let progress_done = Arc::new(AtomicUsize::new(0)); - let bytes_out = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let bytes_out = Arc::new(AtomicU64::new(0)); let work = Arc::new(work); - let n_workers = worker_pool_size().min(total.max(1)); + let n_workers = bigimage_core::resources::batch_worker_count(&files); let workers: Vec<_> = (0..n_workers) .map(|_| { @@ -225,12 +190,11 @@ pub(super) fn run_batch_async( let next_idx = next_idx.clone(); let progress_done = progress_done.clone(); let bytes_out = bytes_out.clone(); - let cancelled = coordinator_cancelled.clone(); + let bytes_in = bytes_in.clone(); + let cancelled = cancelled.clone(); let work = work.clone(); let tx = tx.clone(); std::thread::spawn(move || { - // Per-thread tally — merged at the end so we don't - // need a Mutex for the counters. let mut ok = 0usize; let mut skip = 0usize; let mut fail = 0usize; @@ -248,9 +212,18 @@ pub(super) fn run_batch_async( .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default(); - match work(path) { + let input_bytes = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + let result = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| work(path))) + .unwrap_or_else(|_| { + Err(bigimage_core::BigImageError::Other( + "falha inesperada no worker".to_string(), + )) + }); + match result { Ok(ConvertOutcome::Written { output }) => { ok += 1; + bytes_in.fetch_add(input_bytes, Ordering::Relaxed); if let Ok(meta) = std::fs::metadata(&output) { bytes_out.fetch_add(meta.len(), Ordering::Relaxed); } @@ -264,42 +237,90 @@ pub(super) fn run_batch_async( } } let done = progress_done.fetch_add(1, Ordering::Relaxed) + 1; - let _ = tx.send(AsyncBatchEvent::Progress { done, total, file: display }); + if tx + .send(AsyncBatchEvent::Progress { done, total, file: display }) + .is_err() + { + break; + } } (ok, skip, fail, first_err) }) }) .collect(); - // Merge per-thread tallies. `first_err` keeps the lexicographically - // first non-None seen — tally order matches worker spawn order so - // results are deterministic given the same input list. let mut ok = 0usize; let mut skip = 0usize; let mut fail = 0usize; let mut first_err: Option = None; - for h in workers { - if let Ok((l_ok, l_skip, l_fail, l_first_err)) = h.join() { - ok += l_ok; - skip += l_skip; - fail += l_fail; - if first_err.is_none() { - first_err = l_first_err; + for handle in workers { + match handle.join() { + Ok((local_ok, local_skip, local_fail, local_first_err)) => { + ok += local_ok; + skip += local_skip; + fail += local_fail; + if first_err.is_none() { + first_err = local_first_err; + } + } + Err(_) => { + fail += 1; + first_err + .get_or_insert_with(|| "um worker terminou inesperadamente".to_string()); } } } - let elapsed = start.elapsed(); - let bytes_out = bytes_out.load(Ordering::Relaxed); + let done = progress_done.load(Ordering::Relaxed); let _ = tx.send(AsyncBatchEvent::Done { ok, skip, fail, first_err, - elapsed, - bytes_in, - bytes_out, + elapsed: start.elapsed(), + bytes_in: bytes_in.load(Ordering::Relaxed), + bytes_out: bytes_out.load(Ordering::Relaxed), + done, + total, + cancelled: cancelled.load(Ordering::Relaxed), }); }); + rx +} + +/// Run a per-file batch across a worker pool, polling progress on the +/// GTK main loop. The `work` closure is the per-file CPU op (e.g. +/// `bigimage_core::convert_file`); it must be `Fn + Send + Sync + +/// 'static` so multiple threads can invoke it concurrently. Widgets +/// (`status`, buttons, window) stay on the main thread — they're only +/// touched inside the timeout closure, which runs locally. +/// +/// Cancellation: closing the window flips an atomic flag the workers +/// check between files. The current file in each worker finishes (the +/// encoder isn't interruptible mid-flight today), but the rest of the +/// batch is skipped cleanly. +#[allow(clippy::too_many_arguments)] +pub(super) fn run_batch_async( + files: Vec, + work: W, + busy_label: String, + status: gtk::Label, + apply_btn: gtk::Button, + cancel_btn: gtk::Button, + window: adw::ApplicationWindow, +) where + W: Fn(&Path) -> bigimage_core::Result + Send + Sync + 'static, +{ + let cancelled = Arc::new(AtomicBool::new(false)); + + { + let cancelled = cancelled.clone(); + window.connect_close_request(move |_| { + cancelled.store(true, Ordering::Relaxed); + gtk4::glib::Propagation::Proceed + }); + } + + let rx = spawn_batch_workers(files, work, cancelled); gtk4::glib::timeout_add_local(std::time::Duration::from_millis(50), move || loop { match rx.try_recv() { @@ -314,6 +335,7 @@ pub(super) fn run_batch_async( elapsed, bytes_in, bytes_out, + .. }) => { finish_dialog( &status, @@ -335,3 +357,75 @@ pub(super) fn run_batch_async( } }); } + +#[cfg(test)] +mod worker_tests { + use super::*; + + fn summary(rx: mpsc::Receiver) -> AsyncBatchEvent { + loop { + let event = rx.recv_timeout(Duration::from_secs(5)).expect("workers failed to finish"); + if matches!(event, AsyncBatchEvent::Done { .. }) { + return event; + } + } + } + + #[test] + fn cancellation_before_start_processes_no_files() { + let cancelled = Arc::new(AtomicBool::new(true)); + let rx = spawn_batch_workers( + vec![PathBuf::from("unread.png")], + |_| panic!("work after cancellation"), + cancelled, + ); + assert!(matches!(summary(rx), AsyncBatchEvent::Done { done: 0, cancelled: true, .. })); + } + + #[test] + fn worker_panic_is_reported_as_failure_without_losing_other_jobs() { + let dir = tempfile::tempdir().unwrap(); + let files: Vec<_> = (0..10) + .map(|i| { + let path = dir.path().join(format!("{i}.png")); + image::RgbImage::new(4, 4).save(&path).unwrap(); + path + }) + .collect(); + let rx = spawn_batch_workers( + files, + |path| { + if path.file_name().unwrap() == "3.png" { + panic!("synthetic failure"); + } + Ok(ConvertOutcome::Skipped { output: path.to_path_buf() }) + }, + Arc::new(AtomicBool::new(false)), + ); + assert!(matches!(summary(rx), AsyncBatchEvent::Done { fail: 1, skip: 9, done: 10, .. })); + } + + #[test] + fn compression_accounting_excludes_skipped_inputs() { + let dir = tempfile::tempdir().unwrap(); + let written = dir.path().join("written.png"); + let skipped = dir.path().join("skipped.png"); + std::fs::write(&written, [0u8; 10]).unwrap(); + std::fs::write(&skipped, [0u8; 100]).unwrap(); + let rx = spawn_batch_workers( + vec![written, skipped], + |path| { + if path.file_name().unwrap() == "written.png" { + Ok(ConvertOutcome::Written { output: path.to_path_buf() }) + } else { + Ok(ConvertOutcome::Skipped { output: path.to_path_buf() }) + } + }, + Arc::new(AtomicBool::new(false)), + ); + assert!(matches!( + summary(rx), + AsyncBatchEvent::Done { ok: 1, skip: 1, bytes_in: 10, bytes_out: 10, .. } + )); + } +} diff --git a/crates/bigiris/src/gui/previews.rs b/crates/bigiris/src/gui/previews.rs new file mode 100644 index 0000000..4032edb --- /dev/null +++ b/crates/bigiris/src/gui/previews.rs @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Bounded, cancellable preview preparation and rendering. +use super::*; + +/// Turn a `DynamicImage` into a `gdk::Texture` suitable for `gtk::Picture`. +/// Consumes the image (the RGBA buffer is handed to a `glib::Bytes` that +/// owns the allocation from there on). +pub(super) fn dynamic_image_to_texture(img: DynamicImage) -> gdk::Texture { + let rgba = img.to_rgba8(); + let (w, h) = rgba.dimensions(); + let stride = (w as usize) * 4; + let bytes = glib::Bytes::from_owned(rgba.into_raw()); + gdk::MemoryTexture::new(w as i32, h as i32, gdk::MemoryFormat::R8g8b8a8, &bytes, stride) + .upcast() +} + +/// Build the preview-area column every dialog uses on its right-hand side. +/// Returns the `gtk::Picture` the caller drives via `set_paintable` and the +/// outer container to append into the dialog's horizontal box. +pub(super) fn build_preview_column(session: &PreviewSession) -> (gtk::Picture, gtk::Box) { + let picture = gtk::Picture::new(); + picture.set_content_fit(gtk::ContentFit::Contain); + picture.set_hexpand(true); + picture.set_vexpand(true); + picture.set_size_request(360, 360); + + // Initial paintable = unmodified thumbnail. + let initial = dynamic_image_to_texture(session.thumbnail().clone()); + picture.set_paintable(Some(&initial)); + + let heading = + gtk::Label::builder().label("Pré-visualização").use_markup(true).xalign(0.0).build(); + + let (nw, nh) = session.natural_size(); + let info = gtk::Label::builder().label(format!("Original: {nw}×{nh} px")).xalign(0.0).build(); + info.add_css_class("dim-label"); + + let frame = gtk::Frame::new(None); + frame.set_child(Some(&picture)); + + let column = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .hexpand(true) + .vexpand(true) + .build(); + column.append(&heading); + column.append(&frame); + column.append(&info); + + (picture, column) +} + +/// Wrap the GTK idle+timeout dance into a single handle. Callers schedule +/// a preview repaint by calling [`PreviewDebounce::trigger`], and the +/// closure runs at most once per debounce window no matter how many times +/// the sliders fire in the meantime. +pub(super) struct PreviewDebounce { + pending: std::cell::Cell>, +} + +impl PreviewDebounce { + pub(super) fn new() -> Rc { + Rc::new(Self { pending: std::cell::Cell::new(None) }) + } + + pub(super) fn trigger(self: &Rc, delay: Duration, mut f: F) { + if let Some(id) = self.pending.take() { + id.remove(); + } + let weak = Rc::downgrade(self); + let id = glib::timeout_add_local(delay, move || { + f(); + if let Some(strong) = weak.upgrade() { + strong.pending.set(None); + } + glib::ControlFlow::Break + }); + self.pending.set(Some(id)); + } +} + +/// Apply `op` to `session` and push the resulting thumbnail into `picture`. +/// Errors are logged and the previous paintable is left untouched. +struct PreviewRenderQueue { + stopped: bool, + next: Option<(u64, PreviewOp)>, + result: Option<(u64, std::result::Result)>, +} +struct PreviewRenderer { + queue: Arc<(Mutex, Condvar)>, + revision: std::cell::Cell, +} +impl Drop for PreviewRenderer { + fn drop(&mut self) { + let (lock, wake) = &*self.queue; + let mut pending = lock.lock().unwrap(); + pending.stopped = true; + pending.next = None; + pending.result = None; + wake.notify_one(); + } +} +thread_local! { + static PREVIEW_RENDERERS: RefCell, Rc)>> = const { RefCell::new(Vec::new()) }; +} +pub(super) fn refresh_preview(session: &PreviewSession, picture: >k::Picture, op: &PreviewOp) { + let renderer = PREVIEW_RENDERERS.with(|renderers| { + let mut renderers = renderers.borrow_mut(); + renderers.retain(|(weak, _)| weak.upgrade().is_some()); + if let Some((_, renderer)) = + renderers.iter().find(|(weak, _)| weak.upgrade().as_ref() == Some(picture)) + { + return renderer.clone(); + } + let queue = Arc::new(( + Mutex::new(PreviewRenderQueue { stopped: false, next: None, result: None }), + Condvar::new(), + )); + let renderer = + Rc::new(PreviewRenderer { queue: queue.clone(), revision: std::cell::Cell::new(0) }); + let session = session.clone(); + std::thread::spawn(move || loop { + let (lock, wake) = &*queue; + let request = { + let mut pending = lock.lock().unwrap(); + while !pending.stopped && pending.next.is_none() { + pending = wake.wait(pending).unwrap(); + } + if pending.stopped { + break; + } + pending.next.take().unwrap() + }; + let image = session + .apply(&request.1) + .map(|img| DynamicImage::ImageRgba8(img.to_rgba8())) + .map_err(|e| e.to_string()); + let mut pending = lock.lock().unwrap(); + if !pending.stopped { + pending.result = Some((request.0, image)); + } + }); + let weak = picture.downgrade(); + let weak_renderer = Rc::downgrade(&renderer); + glib::timeout_add_local(Duration::from_millis(16), move || { + let Some(picture) = weak.upgrade() else { + PREVIEW_RENDERERS + .with(|entries| entries.borrow_mut().retain(|(w, _)| w.upgrade().is_some())); + return glib::ControlFlow::Break; + }; + let Some(renderer) = weak_renderer.upgrade() else { return glib::ControlFlow::Break }; + let result = renderer.queue.0.lock().unwrap().result.take(); + if let Some((revision, result)) = result { + if revision == renderer.revision.get() { + match result { + Ok(image) => picture.set_paintable(Some(&dynamic_image_to_texture(image))), + Err(error) => tracing::warn!(%error, "preview: apply failed"), + } + } + } + glib::ControlFlow::Continue + }); + renderers.push((picture.downgrade(), renderer.clone())); + renderer + }); + let revision = renderer.revision.get().wrapping_add(1); + renderer.revision.set(revision); + let (lock, wake) = &*renderer.queue; + lock.lock().unwrap().next = Some((revision, *op)); + wake.notify_one(); +} + +/// Present the window immediately; file I/O, ICC and resampling run off GTK. +/// Closing the window cancels the isolated codec and drops pending results. +type DialogInitializer = fn( + adw::ApplicationWindow, + Rc>, + Option>, +) -> adw::ApplicationWindow; +pub(super) fn prepare_dialog( + window: &adw::ApplicationWindow, + files: Rc>, + fill: DialogInitializer, +) { + let spinner = gtk::Spinner::new(); + spinner.start(); + let loading = gtk::Box::new(gtk::Orientation::Vertical, 16); + loading.set_valign(gtk::Align::Center); + loading.append(&spinner); + loading.append(>k::Label::new(Some("Preparando pré-visualização…"))); + let cancel_button = gtk::Button::with_label("Cancelar"); + cancel_button.set_halign(gtk::Align::Center); + let weak_window = window.downgrade(); + cancel_button.connect_clicked(move |_| { + if let Some(window) = weak_window.upgrade() { + window.close(); + } + }); + loading.append(&cancel_button); + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&adw::HeaderBar::new()); + toolbar.set_content(Some(&loading)); + window.set_content(Some(&toolbar)); + let cancel = Arc::new(AtomicBool::new(false)); + let closing = cancel.clone(); + window.connect_unrealize(move |_| closing.store(true, Ordering::Release)); + let input = files.first().cloned(); + let (tx, rx) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + let result = crate::codec::with_cancel(cancel, || { + input + .ok_or_else(|| "Nenhuma imagem selecionada".to_string()) + .and_then(|path| PreviewSession::open(path).map_err(|e| e.to_string())) + }); + let _ = tx.send(result); + }); + let weak = window.downgrade(); + glib::timeout_add_local(Duration::from_millis(16), move || { + let Some(window) = weak.upgrade() else { return glib::ControlFlow::Break }; + if !window.is_visible() { + return glib::ControlFlow::Break; + } + match rx.try_recv() { + Ok(Ok(session)) => { + fill(window, files.clone(), Some(Rc::new(session))); + } + Ok(Err(error)) => { + let page = adw::StatusPage::builder() + .title("Não foi possível abrir a imagem") + .description(error) + .icon_name("dialog-error-symbolic") + .build(); + window.set_content(Some(&page)); + } + Err(std::sync::mpsc::TryRecvError::Empty) => return glib::ControlFlow::Continue, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + window.set_content(Some(>k::Label::new(Some("A preparação foi interrompida")))); + } + } + glib::ControlFlow::Break + }); +} diff --git a/crates/bigiris/src/gui/zoom.rs b/crates/bigiris/src/gui/zoom.rs new file mode 100644 index 0000000..d571be4 --- /dev/null +++ b/crates/bigiris/src/gui/zoom.rs @@ -0,0 +1,365 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Frame-clock zoom with atomic layout and a stable pointer anchor. +use super::*; + +#[derive(Clone, Copy)] +pub(super) struct PendingZoom { + cursor: (f64, f64), + image_point: (f64, f64), + old_scale: f64, + new_scale: f64, + natural: (u32, u32), + started: std::time::Instant, + frame_start: i64, + duration_us: f64, +} +impl PendingZoom { + fn project(self, viewport: (f64, f64)) -> ((f64, f64), (f64, f64)) { + self.project_scale(self.new_scale, viewport) + } + fn project_scale(self, scale: f64, viewport: (f64, f64)) -> ((f64, f64), (f64, f64)) { + let width = scaled_side(f64::from(self.natural.0), scale); + let height = scaled_side(f64::from(self.natural.1), scale); + let (x, _, _) = project_axis(self.cursor.0, self.image_point.0, width, viewport.0); + let (y, _, _) = project_axis(self.cursor.1, self.image_point.1, height, viewport.1); + ((x, y), (width, height)) + } +} +fn viewport(scroller: >k::ScrolledWindow) -> (f64, f64) { + let width = scroller.hadjustment().page_size(); + let height = scroller.vadjustment().page_size(); + ( + if width > 0.0 { width } else { f64::from(scroller.width()) }, + if height > 0.0 { height } else { f64::from(scroller.height()) }, + ) +} +fn scaled_side(natural: f64, scale: f64) -> f64 { + (natural * scale).round().clamp(1.0, f64::from(i32::MAX)) +} +fn project_axis(cursor: f64, image_point: f64, size: f64, viewport: f64) -> (f64, f64, f64) { + let padding = ((viewport - size) / 2.0).max(0.0); + let maximum = (size - viewport).max(0.0); + ((image_point * size - cursor + padding).clamp(0.0, maximum), maximum, size) +} +#[cfg(test)] +fn anchor_between_viewports( + cursor: f64, + adj: f64, + old_scale: f64, + new_scale: f64, + natural: f64, + old_viewport: f64, + new_viewport: f64, +) -> (f64, f64, f64) { + let old_size = scaled_side(natural, old_scale); + let new_size = scaled_side(natural, new_scale); + let old_padding = ((old_viewport - old_size) / 2.0).max(0.0); + project_axis(cursor, (cursor + adj - old_padding) / old_size, new_size, new_viewport) +} +#[cfg(test)] +pub(super) fn anchor_axis( + cursor: f64, + adj: f64, + old_scale: f64, + new_scale: f64, + natural: f64, + viewport: f64, +) -> (f64, f64, f64) { + anchor_between_viewports(cursor, adj, old_scale, new_scale, natural, viewport, viewport) +} +pub(super) fn requested_zoom(state: &ViewerState) -> ZoomState { + state.pending_zoom.map_or(state.zoom, |pending| ZoomState::Scale(pending.new_scale)) +} +pub(super) fn cancel_pending(state: &mut ViewerState) { + if let Some(source) = state.zoom_source.take() { + source.remove(); + } + state.pending_zoom = None; +} +fn ready(pending: PendingZoom, s: &ViewerState) -> bool { + let area = viewport(&s.scroller); + let (_, size) = pending.project(area); + (s.scroller.hadjustment().upper() - size.0.max(area.0)).abs() < 1.0 + && (s.scroller.vadjustment().upper() - size.1.max(area.1)).abs() < 1.0 +} +pub(super) fn zoom_in(state: &Rc>) { + adjust_zoom(state, ZOOM_STEP, None); +} +pub(super) fn zoom_out(state: &Rc>) { + adjust_zoom(state, 1.0 / ZOOM_STEP, None); +} +pub(super) fn adjust_zoom( + state: &Rc>, + factor: f64, + cursor: Option<(f64, f64)>, +) { + let current = { + let s = state.borrow(); + let area = viewport(&s.scroller); + s.pending_zoom.map_or_else( + || effective_scale(s.zoom, s.natural, (area.0 as i32, area.1 as i32)), + |pending| pending.new_scale, + ) + }; + rescale(state, (current * factor).clamp(MIN_ZOOM, MAX_ZOOM), cursor); +} +pub(super) fn set_zoom(state: &Rc>, zoom: ZoomState) { + match zoom { + ZoomState::Scale(scale) => rescale(state, scale.clamp(MIN_ZOOM, MAX_ZOOM), None), + ZoomState::Fit => { + let mut s = state.borrow_mut(); + cancel_pending(&mut s); + s.zoom = zoom; + apply_zoom(&s); + drop(s); + refresh_title(state); + } + } +} +/// Preserve high-resolution surface deltas; never turn a tiny touchpad event +/// into a whole mouse-wheel notch. Bound extreme single-event deltas. +pub(super) fn scroll_factor(delta: f64, surface: bool) -> Option { + if !delta.is_finite() || delta.abs() < 1e-6 { + return None; + } + let steps = if surface { delta / 48.0 } else { delta }; + Some(ZOOM_STEP.powf(-steps.clamp(-3.0, 3.0))) +} +fn animation_duration(from: f64, to: f64) -> f64 { + ((to / from).ln().abs() * 600_000.0).clamp(160_000.0, 700_000.0) +} +fn animated_scale(from: f64, to: f64, fraction: f64) -> f64 { + let t = fraction.clamp(0.0, 1.0); + let eased = 1.0 - (1.0 - t) * (1.0 - t); + from * ((to / from).ln() * eased).exp() +} +fn prepare_frame(s: &mut ViewerState, pending: PendingZoom, scale: f64) { + let area = viewport(&s.scroller); + let (target, size) = pending.project_scale(scale, area); + // GtkViewport must see the matching offset on the first allocation of + // this scale. Configuring only after paint produced two jumping frames. + s.scroller.hadjustment().configure( + target.0, + 0.0, + size.0.max(area.0), + 10.0, + area.0 * 0.9, + area.0, + ); + s.scroller.vadjustment().configure( + target.1, + 0.0, + size.1.max(area.1), + 10.0, + area.1 * 0.9, + area.1, + ); + s.zoom = ZoomState::Scale(scale); + apply_zoom(s); +} +fn rescale(state: &Rc>, scale: f64, cursor: Option<(f64, f64)>) { + let needs_tick = { + let mut s = state.borrow_mut(); + if s.natural.0 == 0 || s.natural.1 == 0 || s.texture.is_none() { + return; + } + let area = viewport(&s.scroller); + if area.0 <= 0.0 || area.1 <= 0.0 { + return; + } + let requested_scale = effective_scale(s.zoom, s.natural, (area.0 as i32, area.1 as i32)); + if (requested_scale - scale).abs() < 1e-6 && s.pending_zoom.is_none() { + return; + } + let pointer = cursor + .or(s.zoom_pointer) + .filter(|&(x, y)| { + x.is_finite() && y.is_finite() && x >= 0.0 && y >= 0.0 && x <= area.0 && y <= area.1 + }) + .unwrap_or((area.0 / 2.0, area.1 / 2.0)); + // Fit mode may render fractional content dimensions inside the + // allocated picture. Use those real bounds when capturing the anchor. + let Some(bounds) = s.picture.compute_bounds(&s.scroller) else { return }; + let Some(paintable) = s.picture.paintable() else { return }; + let iw = f64::from(paintable.intrinsic_width()); + let ih = f64::from(paintable.intrinsic_height()); + if iw <= 0.0 || ih <= 0.0 { + return; + } + let pw = f64::from(s.picture.width()); + let ph = f64::from(s.picture.height()); + let mut fit = (pw / iw).min(ph / ih); + if s.picture.content_fit() == gtk::ContentFit::ScaleDown { + fit = fit.min(1.0); + } + if fit <= 0.0 { + return; + } + let measured_point = ( + (pointer.0 - f64::from(bounds.x()) - (pw - iw * fit) / 2.0) / (iw * fit), + (pointer.1 - f64::from(bounds.y()) - (ph - ih * fit) / 2.0) / (ih * fit), + ); + let image_point = s + .pending_zoom + .filter(|pending| pending.cursor == pointer && pending.natural == s.natural) + .map_or(measured_point, |pending| pending.image_point); + let old_scale = iw * fit / f64::from(s.natural.0); + let clock = s.picture.frame_clock(); + let animate = clock.is_some() + && s.picture.is_mapped() + && s.picture.settings().is_gtk_enable_animations(); + let pending = PendingZoom { + cursor: pointer, + image_point, + old_scale, + new_scale: scale, + natural: s.natural, + started: std::time::Instant::now(), + frame_start: clock.map_or(0, |clock| clock.frame_time()), + duration_us: if animate { animation_duration(old_scale, scale) } else { 0.0 }, + }; + s.pending_zoom = Some(pending); + if !animate { + prepare_frame(&mut s, pending, scale); + } + s.zoom_source.is_none() + }; + refresh_title(state); + if !needs_tick { + return; + } + let weak = Rc::downgrade(state); + let source = state.borrow().picture.add_tick_callback(move |_, clock| { + let Some(state) = weak.upgrade() else { return glib::ControlFlow::Break }; + let mut s = state.borrow_mut(); + let Some(pending) = s.pending_zoom else { + s.zoom_source = None; + return glib::ControlFlow::Break; + }; + if s.window_weak.upgrade().is_none_or(|window| !window.is_visible()) { + s.zoom_source = None; + s.pending_zoom = None; + return glib::ControlFlow::Break; + } + let fraction = if pending.duration_us == 0.0 { + 1.0 + } else { + (clock.frame_time() - pending.frame_start) as f64 / pending.duration_us + }; + let area = viewport(&s.scroller); + let (target, size) = pending.project(area); + let translated = s.picture.compute_bounds(&s.scroller).is_some_and(|bounds| { + let expected_x = ((area.0 - size.0) / 2.0).max(0.0) - target.0; + let expected_y = ((area.1 - size.1) / 2.0).max(0.0) - target.1; + (f64::from(bounds.x()) - expected_x).abs() < 1.0 + && (f64::from(bounds.y()) - expected_y).abs() < 1.0 + && (f64::from(s.picture.width()) - size.0).abs() < 1.0 + && (f64::from(s.picture.height()) - size.1).abs() < 1.0 + }); + if fraction >= 1.0 + && ready(pending, &s) + && translated + && (effective_scale(s.zoom, s.natural, (area.0 as i32, area.1 as i32)) + - pending.new_scale) + .abs() + < 1e-6 + { + s.zoom_source = None; + s.pending_zoom = None; + drop(s); + refresh_title(&state); + return glib::ControlFlow::Break; + } + if pending.started.elapsed() >= Duration::from_secs(2) { + tracing::warn!("zoom layout did not settle before deadline"); + s.zoom_source = None; + s.pending_zoom = None; + drop(s); + refresh_title(&state); + return glib::ControlFlow::Break; + } + let scale = animated_scale(pending.old_scale, pending.new_scale, fraction); + if (effective_scale(s.zoom, s.natural, (area.0 as i32, area.1 as i32)) - scale).abs() + >= 1e-6 + { + prepare_frame(&mut s, pending, scale); + } else { + s.picture.queue_allocate(); + } + glib::ControlFlow::Continue + }); + state.borrow_mut().zoom_source = Some(source); +} + +pub(super) fn apply_zoom(state: &ViewerState) { + let picture = &state.picture; + let Some(texture) = state.texture.as_ref() else { return }; + match state.zoom { + ZoomState::Fit => { + picture.set_hexpand(true); + picture.set_vexpand(true); + picture.set_content_fit(gtk::ContentFit::ScaleDown); + picture.set_size_request(-1, -1); + picture.set_paintable(Some(texture)); + } + ZoomState::Scale(scale) => { + let width = scaled_side(f64::from(state.natural.0), scale) as i32; + let height = scaled_side(f64::from(state.natural.1), scale) as i32; + // This paintable references the same texture; no pixel resize or + // additional image allocation happens on GTK's thread. + let snapshot = gtk::Snapshot::new(); + snapshot.append_texture( + texture, + &graphene::Rect::new(0.0, 0.0, width as f32, height as f32), + ); + let paintable = + snapshot.to_paintable(Some(&graphene::Size::new(width as f32, height as f32))); + picture.set_hexpand(false); + picture.set_vexpand(false); + picture.set_content_fit(gtk::ContentFit::Contain); + picture.set_size_request(width, height); + picture.set_paintable(paintable.as_ref()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn touchpad_events_preserve_fractional_motion() { + let tiny = scroll_factor(-0.1, true).unwrap(); + assert!(tiny > 1.0 && tiny < 1.001); + assert!((tiny.powi(480) - ZOOM_STEP).abs() < 1e-10); + assert!((scroll_factor(-1.0, false).unwrap() - ZOOM_STEP).abs() < 1e-10); + assert!(scroll_factor(f64::NAN, true).is_none()); + assert!(scroll_factor(f64::INFINITY, false).is_none()); + } + #[test] + fn animation_is_monotonic_in_both_directions_and_stops_at_target() { + for (from, to) in [(0.5, 1.0), (1.0, 0.5)] { + let values: Vec<_> = + (0..=20).map(|n| animated_scale(from, to, n as f64 / 20.0)).collect(); + assert!((values[0] - from).abs() < 1e-10); + assert!((values[20] - to).abs() < 1e-10); + assert!(values.windows(2).all(|pair| if to > from { + pair[1] >= pair[0] + } else { + pair[1] <= pair[0] + })); + assert!((animated_scale(from, to, 1.5) - to).abs() < 1e-10); + } + } + #[test] + fn anchor_survives_scrollbar_viewport_change() { + let (value, _, _) = anchor_between_viewports(700.0, 0.0, 0.5, 0.75, 2000.0, 1000.0, 984.0); + assert_eq!(value, 350.0); + } + #[test] + fn rounding_matches_the_displayed_picture() { + let (value, _, size) = anchor_axis(500.0, 100.0, 0.333, 0.667, 3001.0, 900.0); + assert_eq!(size, 2002.0); + let source = 600.0 / 999.0; + assert!(((500.0 + value) / size - source).abs() < 1e-9); + } +} diff --git a/crates/bigiris/src/main.rs b/crates/bigiris/src/main.rs index f3787e2..6f03e38 100644 --- a/crates/bigiris/src/main.rs +++ b/crates/bigiris/src/main.rs @@ -16,14 +16,15 @@ #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; +mod codec; #[cfg(feature = "gui")] mod gui; use std::path::PathBuf; use bigimage_core::{ - AdjustOps, AnimateOptions, ConvertOutcome, CropRect, EncodeOptions, Filter, FlipAxis, Format, - LoopMode, OverwritePolicy, ResizeMode, Rotation, + AdjustOps, AnimateOptions, ConvertOutcome, CropRect, EncodeOptions, ExtentOptions, ExtentSize, + Filter, FlipAxis, Format, Gravity, LoopMode, OverwritePolicy, ResizeMode, Rotation, }; use clap::{Args, Parser, Subcommand, ValueEnum}; @@ -31,7 +32,7 @@ use clap::{Args, Parser, Subcommand, ValueEnum}; #[command( name = "bigiris", version, - about = "Visualizador e conversor de imagens moderno (família BigLinux)", + about = "Visualizador rápido e conversor de imagens para Linux", long_about = None, )] struct Cli { @@ -59,6 +60,7 @@ enum Dialog { Rotate, Flip, Crop, + Extent, Adjust, Metadata, Compare, @@ -222,6 +224,85 @@ impl std::str::FromStr for CropGeom { } } +/// Aspect ratio in the form `W:H` (e.g. `1:1`, `9:16`, `4:5`). Whitespace +/// around the separator is tolerated so users can quote `"9 : 16"` without +/// the parse failing. +#[derive(Debug, Clone, Copy)] +struct Aspect { + w: u32, + h: u32, +} + +impl std::str::FromStr for Aspect { + type Err = String; + fn from_str(s: &str) -> Result { + let trimmed = s.trim(); + let (a, b) = trimmed + .split_once(':') + .ok_or_else(|| format!("esperado W:H (ex. 1:1, 9:16), recebido: {s:?}"))?; + let w: u32 = a.trim().parse().map_err(|e| format!("razão W inválido: {e}"))?; + let h: u32 = b.trim().parse().map_err(|e| format!("razão H inválido: {e}"))?; + if w == 0 || h == 0 { + return Err(format!("razão com componente zero não faz sentido: {s:?}")); + } + Ok(Aspect { w, h }) + } +} + +#[derive(ValueEnum, Debug, Clone, Copy, Default)] +#[value(rename_all = "kebab-case")] +enum GravityArg { + NorthWest, + North, + NorthEast, + West, + #[default] + Center, + East, + SouthWest, + South, + SouthEast, +} + +impl From for Gravity { + fn from(v: GravityArg) -> Self { + match v { + GravityArg::NorthWest => Gravity::NorthWest, + GravityArg::North => Gravity::North, + GravityArg::NorthEast => Gravity::NorthEast, + GravityArg::West => Gravity::West, + GravityArg::Center => Gravity::Center, + GravityArg::East => Gravity::East, + GravityArg::SouthWest => Gravity::SouthWest, + GravityArg::South => Gravity::South, + GravityArg::SouthEast => Gravity::SouthEast, + } + } +} + +/// Required exactly-one selector between explicit size and aspect ratio. +#[derive(Args, Debug, Clone)] +#[group(required = true, multiple = false)] +struct ExtentSizeArgs { + /// Tamanho exato do canvas em pixels (WxH). + #[arg(long, value_name = "WxH")] + size: Option, + /// Razão alvo (W:H). O canvas é estendido o mínimo necessário para + /// caber a imagem nessa proporção. + #[arg(long, value_name = "W:H")] + aspect: Option, +} + +impl ExtentSizeArgs { + fn into_size(self) -> ExtentSize { + match self { + Self { size: Some(d), .. } => ExtentSize::Exact { width: d.width, height: d.height }, + Self { aspect: Some(a), .. } => ExtentSize::Aspect { w: a.w, h: a.h }, + _ => unreachable!("clap ArgGroup garante exatamente um modo selecionado"), + } + } +} + #[derive(Subcommand, Debug)] enum Command { /// Converter arquivos (headless) @@ -316,6 +397,26 @@ enum Command { #[arg(value_name = "FILE", required = true)] files: Vec, }, + /// Estender a tela em volta da imagem (letterbox). Exatamente um modo + /// entre --size e --aspect é obrigatório. O canvas é preenchido com + /// --bg e a imagem original é posicionada por --gravity. + Extent { + #[command(flatten)] + size: ExtentSizeArgs, + /// Cor de fundo em hexadecimal (#RRGGBB ou #RRGGBBAA). + #[arg(long, default_value = "#000000")] + bg: String, + /// Posição da imagem original no canvas estendido. + #[arg(long, value_enum, default_value_t = GravityArg::Center)] + gravity: GravityArg, + /// Formato de destino (opcional). + #[arg(long)] + to: Option, + #[arg(long = "overwrite", value_enum, default_value_t = OverwriteArg::Skip)] + overwrite: OverwriteArg, + #[arg(value_name = "FILE", required = true)] + files: Vec, + }, /// Ajustar cor/tom (brilho, contraste, saturação, gamma). Adjust { /// Brilho -100..100 (0 = sem mudança). @@ -376,7 +477,7 @@ enum Command { #[arg(value_name = "FRAME", required = true)] frames: Vec, }, - /// Processar lote a partir de um perfil TOML (headless) + /// Processar arquivos ou pastas em paralelo a partir de um perfil TOML Batch { /// Arquivo de perfil .toml #[arg(long)] @@ -409,6 +510,9 @@ enum Command { } fn main() -> color_eyre::Result<()> { + if std::env::args_os().nth(1).is_some_and(|arg| arg == "--codec-worker") { + return codec::worker(); + } color_eyre::install()?; tracing_subscriber::fmt() .with_env_filter( @@ -421,6 +525,8 @@ fn main() -> color_eyre::Result<()> { let cli = Cli::parse(); + codec::configure(); + if cli.self_test { return self_test(); } @@ -476,6 +582,14 @@ fn main() -> color_eyre::Result<()> { Some(Command::Crop { rect, to, overwrite, files }) => { run_crop(rect.rect, to.as_deref(), overwrite.into(), &files) } + Some(Command::Extent { size, bg, gravity, to, overwrite, files }) => run_extent( + size.into_size(), + &bg, + gravity.into(), + to.as_deref(), + overwrite.into(), + &files, + ), Some(Command::Adjust { brightness, contrast, saturation, gamma, to, overwrite, files }) => { run_adjust( AdjustOps { brightness, contrast, saturation, gamma }, @@ -507,15 +621,16 @@ fn main() -> color_eyre::Result<()> { /// silently returns source strings — acceptable graceful degrade for a /// dev `cargo run` where no catalogs are installed yet. fn init_i18n() { - gettextrs::setlocale(gettextrs::LocaleCategory::LcAll, ""); + // GTK initializes the user locale during startup, before GUI workers. let _ = gettextrs::bindtextdomain("bigiris", "/usr/share/locale"); let _ = gettextrs::bind_textdomain_codeset("bigiris", "UTF-8"); let _ = gettextrs::textdomain("bigiris"); } fn self_test() -> color_eyre::Result<()> { + codec::self_test()?; println!( - "bigiris self-test OK (bigiris={} core={} ai={} [onnx={}] integrations={})", + "bigiris self-test OK [sandbox + decode] (bigiris={} core={} ai={} [onnx={}] integrations={})", env!("CARGO_PKG_VERSION"), bigimage_core::version(), bigimage_ai::version(), @@ -574,6 +689,22 @@ fn run_crop( run_per_file(files, "crop", |f| bigimage_core::crop_file(f, rect, target, policy)) } +fn run_extent( + size: ExtentSize, + bg: &str, + gravity: Gravity, + to: Option<&str>, + policy: OverwritePolicy, + files: &[PathBuf], +) -> color_eyre::Result<()> { + let target = parse_optional_target(to)?; + let background = bigimage_core::parse_hex_color(bg) + .map_err(|e| color_eyre::eyre::eyre!("--bg inválido: {e}"))?; + let opts = ExtentOptions { size, background, gravity }; + tracing::info!(?opts, ?target, ?policy, count = files.len(), "extent"); + run_per_file(files, "extent", |f| bigimage_core::extent_file(f, opts, target, policy)) +} + fn run_adjust( ops: AdjustOps, to: Option<&str>, @@ -639,13 +770,14 @@ fn run_remove_bg(model: &str, files: &[PathBuf]) -> color_eyre::Result<()> { std::process::exit(2); } if model != "birefnet-lite" { - eprintln!("aviso: modelo '{model}' ainda não implementado; usando birefnet-lite."); + return Err(color_eyre::eyre::eyre!("modelo não suportado: {model}")); } let mut ok = 0usize; let mut fail = 0usize; + let mut session = bigimage_ai::background::BgSession::new(|_| {})?; for f in files { - match remove_bg_one(f) { + match remove_bg_one(f, &mut session) { Ok(out) => { ok += 1; println!("ok {} -> {}", f.display(), out.display()); @@ -663,17 +795,25 @@ fn run_remove_bg(model: &str, files: &[PathBuf]) -> color_eyre::Result<()> { Ok(()) } -fn remove_bg_one(path: &std::path::Path) -> color_eyre::Result { +fn remove_bg_one( + path: &std::path::Path, + session: &mut bigimage_ai::background::BgSession, +) -> color_eyre::Result { + bigimage_core::content::ensure_static(path)?; let img = bigimage_core::open_image(path).map_err(|e| color_eyre::eyre::eyre!("decode: {e}"))?; - let out_img = bigimage_ai::background::remove_background(&img) - .map_err(|e| color_eyre::eyre::eyre!("{e}"))?; + let out_img = session.process(&img).map_err(|e| color_eyre::eyre::eyre!("{e}"))?; let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("output"); let parent = path.parent().unwrap_or_else(|| std::path::Path::new(".")); let dest = parent.join(format!("{stem}_nobg.png")); - out_img - .save_with_format(&dest, image::ImageFormat::Png) - .map_err(|e| color_eyre::eyre::eyre!("encode: {e}"))?; + bigimage_core::save_image( + out_img, + &dest, + Format::Png, + &EncodeOptions::default(), + OverwritePolicy::Replace, + ) + .map_err(|e| color_eyre::eyre::eyre!("encode: {e}"))?; Ok(dest) } @@ -713,9 +853,156 @@ fn run_animate( Ok(()) } -fn run_batch(profile: &std::path::Path, files: &[PathBuf]) -> color_eyre::Result<()> { - tracing::info!(?profile, count = files.len(), "batch (stub)"); - println!("[stub] batch profile={} ({} files)", profile.display(), files.len()); +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct BatchProfile { + /// Target extension/name accepted by `Format::parse`. + format: String, + quality: Option, + #[serde(default)] + progressive: bool, + #[serde(default)] + optimize: bool, + #[serde(default = "default_batch_overwrite")] + overwrite: String, + output_dir: Option, +} + +fn default_batch_overwrite() -> String { + "skip".to_string() +} + +fn run_batch(profile: &std::path::Path, inputs: &[PathBuf]) -> color_eyre::Result<()> { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{mpsc, Arc}; + + let text = std::fs::read_to_string(profile) + .map_err(|e| color_eyre::eyre::eyre!("não foi possível ler {}: {e}", profile.display()))?; + let profile: BatchProfile = + toml::from_str(&text).map_err(|e| color_eyre::eyre::eyre!("perfil TOML inválido: {e}"))?; + if profile.quality.is_some_and(|quality| !(1..=100).contains(&quality)) { + return Err(color_eyre::eyre::eyre!("quality deve estar entre 1 e 100")); + } + let format = Format::parse(&profile.format) + .map_err(|e| color_eyre::eyre::eyre!("formato do perfil inválido: {e}"))?; + let policy = match profile.overwrite.trim().to_ascii_lowercase().as_str() { + "skip" => OverwritePolicy::Skip, + "replace" => OverwritePolicy::Replace, + "increment" => OverwritePolicy::Increment, + other => { + return Err(color_eyre::eyre::eyre!( + "overwrite inválido: {other:?}; use skip, replace ou increment" + )); + } + }; + if let Some(dir) = &profile.output_dir { + std::fs::create_dir_all(dir).map_err(|e| { + color_eyre::eyre::eyre!("não foi possível criar {}: {e}", dir.display()) + })?; + } + + let mut files = Vec::new(); + for input in inputs { + if input.is_dir() { + let entries = std::fs::read_dir(input).map_err(|e| { + color_eyre::eyre::eyre!("não foi possível ler {}: {e}", input.display()) + })?; + files.extend(entries.flatten().map(|entry| entry.path()).filter(|path| { + path.is_file() + && path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| Format::parse(ext).is_ok()) + })); + } else { + files.push(input.clone()); + } + } + files.sort(); + files.dedup(); + if files.is_empty() { + return Err(color_eyre::eyre::eyre!("nenhuma imagem encontrada para o lote")); + } + + let total = files.len(); + let opts = EncodeOptions { + quality: profile.quality, + progressive: profile.progressive, + optimize: profile.optimize, + ..EncodeOptions::default() + }; + let files = Arc::new(files); + let output_dir = Arc::new(profile.output_dir); + let next = Arc::new(AtomicUsize::new(0)); + let (tx, rx) = mpsc::channel(); + let worker_count = bigimage_core::resources::batch_worker_count(&files); + tracing::info!(?format, ?policy, total, worker_count, "batch"); + + let workers: Vec<_> = (0..worker_count) + .map(|_| { + let files = files.clone(); + let output_dir = output_dir.clone(); + let next = next.clone(); + let tx = tx.clone(); + std::thread::spawn(move || loop { + let idx = next.fetch_add(1, Ordering::Relaxed); + if idx >= files.len() { + break; + } + let input = files[idx].clone(); + let result = bigimage_core::convert_file_to( + &input, + output_dir.as_deref(), + format, + &opts, + policy, + ) + .map_err(|e| e.to_string()); + if tx.send((input, result)).is_err() { + break; + } + }) + }) + .collect(); + drop(tx); + + let mut ok = 0usize; + let mut skipped = 0usize; + let mut failed = 0usize; + let mut first_error = None; + for (done, (input, result)) in rx.into_iter().enumerate() { + match result { + Ok(ConvertOutcome::Written { output }) => { + ok += 1; + println!( + "[{}/{}] ok {} -> {}", + done + 1, + total, + input.display(), + output.display() + ); + } + Ok(ConvertOutcome::Skipped { output }) => { + skipped += 1; + println!("[{}/{}] skip {}", done + 1, total, output.display()); + } + Err(error) => { + failed += 1; + first_error.get_or_insert_with(|| format!("{}: {error}", input.display())); + eprintln!("[{}/{}] erro {}: {error}", done + 1, total, input.display()); + } + } + } + for worker in workers { + if worker.join().is_err() { + failed += 1; + first_error.get_or_insert_with(|| "um worker terminou inesperadamente".to_string()); + } + } + println!("batch: {ok} gravado(s), {skipped} ignorado(s), {failed} falha(s)"); + if let Some(error) = first_error { + return Err(color_eyre::eyre::eyre!(error)); + } Ok(()) } @@ -838,7 +1125,11 @@ fn print_install_report(report: &bigimage_integrations::Report, verb: &str) { let status = if outcome.error.is_some() { "FALHA" } else if outcome.files.is_empty() { - "vazio" + if outcome.detected { + "sem alterações" + } else { + "ignorado" + } } else { verb }; @@ -898,6 +1189,7 @@ fn dispatch_gui(dialog: Option, files: &[PathBuf]) -> color_eyre::Result Some(Dialog::Animate) => gui::run_animate_dialog(files.to_vec()), Some(Dialog::RemoveBg) => gui::run_remove_bg_dialog(files.to_vec()), Some(Dialog::Crop) => gui::run_crop_dialog(files.to_vec()), + Some(Dialog::Extent) => gui::run_extent_dialog(files.to_vec()), Some(Dialog::Upscale) => gui::run_upscale_dialog(files.to_vec()), Some(Dialog::Batch) => gui::run_batch_dialog(files.to_vec()), None => gui::run_viewer(files.to_vec()), diff --git a/crates/bigiris/tests/cli.rs b/crates/bigiris/tests/cli.rs new file mode 100644 index 0000000..27643c0 --- /dev/null +++ b/crates/bigiris/tests/cli.rs @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! End-to-end tests through the real executable, isolated in temporary folders. +use std::path::Path; +use std::process::{Command, Output}; + +fn run(dir: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_bigiris")).current_dir(dir).args(args).output().unwrap() +} +fn fixture(dir: &Path, name: &str) { + image::RgbImage::from_pixel(32, 24, image::Rgb([30, 70, 120])).save(dir.join(name)).unwrap(); +} +fn success(output: Output) { + assert!( + output.status.success(), + "stdout={}\nstderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} +#[test] +fn self_test_and_help_exit_successfully() { + let dir = tempfile::tempdir().unwrap(); + success(run(dir.path(), &["--self-test"])); + success(run(dir.path(), &["--help"])); +} +#[test] +fn conversion_transform_and_animation_commands_produce_readable_outputs() { + let dir = tempfile::tempdir().unwrap(); + fixture(dir.path(), "imagem com espaço ç.png"); + let file = "imagem com espaço ç.png"; + for args in [ + vec!["convert", "--to", "jpeg", file], + vec!["resize", "--max-edge", "16", file], + vec!["rotate", "--degrees", "90", file], + vec!["flip", "--axis", "horizontal", file], + vec!["crop", "--rect", "12x10+1+2", file], + vec!["extent", "--size", "40x40", file], + vec!["adjust", "--brightness", "10", file], + vec!["animate", "-o", "animation.gif", file, file], + ] { + success(run(dir.path(), &args)); + } + for entry in std::fs::read_dir(dir.path()).unwrap() { + let path = entry.unwrap().path(); + assert!(bigimage_core::open_image(&path).is_ok(), "unreadable output: {}", path.display()); + } + assert_eq!( + image::image_dimensions(dir.path().join("imagem com espaço ç_16.png")).unwrap(), + (16, 12) + ); + assert_eq!( + image::image_dimensions(dir.path().join("imagem com espaço ç_rot90.png")).unwrap(), + (24, 32) + ); +} +#[test] +fn corrupt_input_returns_failure_and_preserves_existing_output() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("bad.png"), b"not an image").unwrap(); + std::fs::write(dir.path().join("bad.jpg"), b"existing photo").unwrap(); + let output = run(dir.path(), &["convert", "--to", "jpeg", "--overwrite", "replace", "bad.png"]); + assert!(!output.status.success()); + assert_eq!(std::fs::read(dir.path().join("bad.jpg")).unwrap(), b"existing photo"); +} +#[test] +fn invalid_arguments_return_failure() { + let dir = tempfile::tempdir().unwrap(); + for args in [ + vec!["convert", "--to", "bogus", "missing.png"], + vec!["resize", "--percent", "NaN", "missing.png"], + vec!["crop", "--rect", "nonsense", "missing.png"], + vec!["adjust", "--gamma", "0", "missing.png"], + ] { + assert!(!run(dir.path(), &args).status.success(), "{args:?}"); + } +} + +#[cfg(feature = "ai")] +#[test] +fn low_memory_refuses_ai_before_download_and_preserves_inputs() { + let dir = tempfile::tempdir().unwrap(); + fixture(dir.path(), "source.png"); + let original = std::fs::read(dir.path().join("source.png")).unwrap(); + let cache = dir.path().join("isolated-data"); + let output = Command::new("/usr/bin/prlimit") + .args(["--as=4294967296", "--core=0", "--"]) + .arg(env!("CARGO_BIN_EXE_bigiris")) + .args(["remove-bg", "source.png"]) + .env("XDG_DATA_HOME", &cache) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(!output.status.success()); + let error = String::from_utf8_lossy(&output.stderr); + assert!(error.contains("8 GiB"), "missing memory explanation: {error}"); + assert!(!cache.exists(), "model download was attempted despite rejected budget"); + assert!(!dir.path().join("source_nobg.png").exists()); + assert_eq!(std::fs::read(dir.path().join("source.png")).unwrap(), original); +} +#[test] +fn batch_profile_converts_folder_and_skip_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("input")).unwrap(); + for i in 0..40 { + fixture(&dir.path().join("input"), &format!("image{i}.png")); + } + std::fs::write( + dir.path().join("profile.toml"), + "format = 'webp'\noverwrite = 'skip'\noutput_dir = 'output'\n", + ) + .unwrap(); + success(run(dir.path(), &["batch", "--profile", "profile.toml", "input"])); + let paths: Vec<_> = + std::fs::read_dir(dir.path().join("output")).unwrap().map(|e| e.unwrap().path()).collect(); + assert_eq!(paths.len(), 40); + let before: Vec<_> = paths.iter().map(|p| std::fs::read(p).unwrap()).collect(); + success(run(dir.path(), &["batch", "--profile", "profile.toml", "input"])); + for (path, bytes) in paths.iter().zip(before) { + assert_eq!(std::fs::read(path).unwrap(), bytes); + assert_eq!(bigimage_core::open_image(path).unwrap().width(), 32); + } +} diff --git a/data/com.biglinux.Iris.desktop b/data/com.biglinux.Iris.desktop index 9b67a48..eaa2a15 100644 --- a/data/com.biglinux.Iris.desktop +++ b/data/com.biglinux.Iris.desktop @@ -7,12 +7,11 @@ GenericName[pt_BR]=Visualizador de imagens Comment=Visualize e converta imagens com integração ao gerenciador de arquivos Comment[pt_BR]=Visualize e converta imagens com integração ao gerenciador de arquivos Comment[en_US]=View and convert images with file-manager integration -Exec=bigiris %U +Exec=bigiris %F Icon=com.biglinux.Iris Terminal=false StartupNotify=true StartupWMClass=com.biglinux.Iris Categories=Graphics;Viewer;2DGraphics;RasterGraphics;Photography; Keywords=image;viewer;converter;convert;resize;rotate;crop;photo;picture;iris;iris;imagem;conversor;redimensionar;girar;cortar;foto;íris; -MimeType=image/jpeg;image/png;image/webp;image/gif;image/bmp;image/tiff;image/avif;image/heic;image/heif;image/jxl;image/svg+xml;image/x-canon-cr2;image/x-canon-cr3;image/x-nikon-nef;image/x-sony-arw;image/x-adobe-dng; -X-GNOME-UsesNotifications=true +MimeType=image/jpeg;image/png;image/webp;image/gif;image/bmp;image/tiff;image/avif;image/vnd.microsoft.icon;image/x-portable-anymap;image/x-tga;image/qoi;image/vnd.radiance;image/x-exr; diff --git a/data/com.biglinux.Iris.gschema.xml b/data/com.biglinux.Iris.gschema.xml index a89cc50..b2f903f 100644 --- a/data/com.biglinux.Iris.gschema.xml +++ b/data/com.biglinux.Iris.gschema.xml @@ -30,60 +30,10 @@ Mostrar a tira de miniaturas na parte inferior - - true - Aplicar ICC do monitor ao renderizar - - - - true - Habilitar tone-mapping HDR quando o display suportar - - - - 'png' - - - - - - - - - - - Formato de saída padrão para conversões rápidas - - - - 'suffix' - - - - - - Política de sobrescrita (padrão: suffix, nunca replace silencioso) - - - - false - Remover GPS do EXIF por padrão ao exportar (LGPD) - - - - '' - Diretório de modelos de IA (vazio = $XDG_DATA_HOME/bigiris/models) - - - - 'auto' - - - - - - - Execution provider preferido do ONNX Runtime + + 5 + + Intervalo do slideshow em segundos diff --git a/data/com.biglinux.Iris.metainfo.xml b/data/com.biglinux.Iris.metainfo.xml index 409e790..1e94fa5 100644 --- a/data/com.biglinux.Iris.metainfo.xml +++ b/data/com.biglinux.Iris.metainfo.xml @@ -11,24 +11,23 @@

- BigIris is a modern image viewer and converter for Linux, part of the - BigLinux family. It combines a Loupe-grade viewer with a full - converter module (Prisma) accessible via right-click in the file - manager. + BigIris is a fast, compact image viewer and converter for Linux. Its + quiet viewer keeps common navigation immediately available, while the + Prisma workspace handles folders and advanced operations on demand.

- BigIris é um visualizador e conversor de imagens moderno para Linux, - parte da família BigLinux. Unifica um visualizador no nível do Loupe - com um conversor completo (módulo Prisma) acessível por clique direito - no gerenciador de arquivos. + BigIris é um visualizador e conversor de imagens rápido e compacto para + Linux. O visualizador mantém a navegação comum imediatamente acessível, + enquanto o ambiente Prisma processa pastas e operações avançadas sob + demanda.

Features:

Recursos:

    -
  • Fast viewing with GPU acceleration, color management (ICC) and HDR
  • -
  • Visualização rápida com aceleração GPU, color management (ICC) e HDR
  • -
  • Batch conversion: PNG, JPEG, WebP, AVIF, HEIC, JPEG XL, TIFF, BMP
  • -
  • Conversão em lote: PNG, JPEG, WebP, AVIF, HEIC, JPEG XL, TIFF, BMP
  • +
  • Responsive viewing with background decoding, folder navigation, thumbnails and histogram
  • +
  • Visualização responsiva com decodificação em segundo plano, navegação por pasta, miniaturas e histograma
  • +
  • Parallel batch conversion: PNG, JPEG, WebP, AVIF, TIFF and BMP
  • +
  • Conversão paralela em lote: PNG, JPEG, WebP, AVIF, TIFF e BMP
  • Resize, rotate, crop and adjust in batch or per image
  • Redimensionar, girar, cortar e ajustar em lote ou em uma imagem
  • Remove background and upscale with open AI models, downloaded on demand
  • @@ -44,10 +43,8 @@ https://github.com/xathay/bigiris https://github.com/xathay/bigiris/issues - https://biglinux.com.br/apoie - - - BigLinux + + Leonardo Athayde @@ -63,19 +60,11 @@ - - - Viewer Íris with a loaded photo - Visualizador Íris com uma foto carregada - https://raw.githubusercontent.com/xathay/bigiris/main/docs/screenshots/01-viewer.png - - - - + -

    Pre-release focused on safety and packaging readiness for the BigLinux testing repository.

    -

    Pré-release com foco em segurança e prontidão de empacotamento para o repositório `testing` do BigLinux.

    +

    First release focused on safety, responsive folder navigation and distribution packaging.

    +

    Primeira versão com foco em segurança, navegação responsiva por pastas e empacotamento para distribuições.

    • Viewer Íris (GTK4 + libadwaita): zoom cursor-anchored, drag pan, slideshow, film strip, histogram, theater mode
    • Visualizador Íris (GTK4 + libadwaita): zoom centrado no cursor, pan por arrasto, slideshow, film strip, histograma, modo teatro
    • @@ -95,7 +84,6 @@ keyboard pointing - touch @@ -103,6 +91,6 @@ - 512 + 1024 diff --git a/data/icons/hicolor/scalable/apps/com.biglinux.Iris.svg b/data/icons/hicolor/scalable/apps/com.biglinux.Iris.svg index 5743313..6d2aba2 100644 --- a/data/icons/hicolor/scalable/apps/com.biglinux.Iris.svg +++ b/data/icons/hicolor/scalable/apps/com.biglinux.Iris.svg @@ -1,4 +1,6 @@ + - diff --git a/data/nautilus/bigiris-menu.py b/data/nautilus/bigiris-menu.py index 0751228..03846df 100644 --- a/data/nautilus/bigiris-menu.py +++ b/data/nautilus/bigiris-menu.py @@ -24,7 +24,7 @@ # silenciosamente nesse caso — so tentamos fixar se o typelib existir e # ignoramos qualquer outro cenario. O import subsequente continua # funcionando porque nautilus-python injeta o modulo diretamente. -for _nautilus_version in ("4.0", "3.0"): +for _nautilus_version in ("4.1", "4.0", "3.0"): try: gi.require_version("Nautilus", _nautilus_version) break @@ -188,6 +188,16 @@ ("Otimizar PNG (compressão máxima)", [ "convert", "--to", "png", "--optimize", "--overwrite", "replace", ], _ICON_CONVERT), + ("Letterbox YouTube post (1:1)", [ + "extent", "--aspect", "1:1", "--bg", "#0d0e12", + ], _ICON_WEB), + ("Letterbox Instagram feed (4:5)", [ + "extent", "--aspect", "4:5", "--bg", "#0d0e12", + ], _ICON_WEB), + ("Letterbox Stories / Shorts (9:16)", [ + "extent", "--aspect", "9:16", "--bg", "#0d0e12", + ], _ICON_WEB), + ("Letterbox personalizado…", ["--dialog=extent"], _ICON_WEB), ], ), ( @@ -259,6 +269,9 @@ def _build_items(self, args): files = args[-1] if args else [] image_paths = self._paths_of_kind(files, "image") doc_paths = self._paths_of_kind(files, "doc") + folder_paths = self._paths_of_kind(files, "folder") + if folder_paths: + return self._folder_items(folder_paths + image_paths) if not image_paths and not doc_paths: return [] @@ -335,6 +348,8 @@ def _paths_of_kind(files, kind): pass elif kind == "doc" and mime in _DOC_MIMES: pass + elif kind == "folder" and mime == "inode/directory": + pass else: continue loc = f.get_location() @@ -342,10 +357,25 @@ def _paths_of_kind(files, kind): out.append(loc.get_path()) return out - def get_background_items(self, *_args): - # Nao oferecemos acoes quando o clique e no fundo do diretorio — - # BigIris trabalha sempre sobre uma selecao concreta de arquivos. - return [] + def _folder_items(self, paths): + if not paths: + return [] + item = Nautilus.MenuItem( + name="BigIrisExtension::BatchFolder", + label="BigIris — Processar esta pasta…", + tip="Converter imagens da pasta com o Prisma", + icon=_ICON_CONVERT, + ) + item.connect("activate", self._run_action, ["--dialog=batch"], list(paths)) + return [item] + + def get_background_items(self, *args): + try: + folder = args[-1] if args else None + return self._folder_items(self._paths_of_kind([folder], "folder")) + except Exception as exc: + sys.stderr.write(f"BigIris: erro em get_background_items: {exc!r}\n") + return [] def _run_action(self, menu_item, args, paths): try: diff --git a/data/po/bigiris.pot b/data/po/bigiris.pot index 6d928f6..3f14572 100644 --- a/data/po/bigiris.pot +++ b/data/po/bigiris.pot @@ -158,7 +158,7 @@ msgid "Se o arquivo de saída existir" msgstr "" #: crates/bigiris/src/gui.rs:2412 crates/bigiris/src/gui.rs:4625 -msgid "Qualidade (JPEG/lossy)" +msgid "Qualidade (JPEG/AVIF)" msgstr "" #: crates/bigiris/src/gui.rs:2417 crates/bigiris/src/gui.rs:4631 @@ -386,3 +386,10 @@ msgstr "" #: crates/bigiris/src/gui.rs:5629 msgid "Com erros" msgstr "" + +msgid "Otimização do encoder" +msgstr "" + +#: crates/bigiris/src/gui.rs +msgid "Remoção de fundo: requer 8 GiB de RAM livre para iniciar. Este recurso é opcional." +msgstr "" diff --git a/data/po/en.po b/data/po/en.po index 91be3b9..3993af6 100644 --- a/data/po/en.po +++ b/data/po/en.po @@ -157,8 +157,8 @@ msgid "Se o arquivo de saída existir" msgstr "If the output file exists" #: crates/bigiris/src/gui.rs:2412 crates/bigiris/src/gui.rs:4625 -msgid "Qualidade (JPEG/lossy)" -msgstr "Quality (JPEG/lossy)" +msgid "Qualidade (JPEG/AVIF)" +msgstr "Quality (JPEG/AVIF)" #: crates/bigiris/src/gui.rs:2417 crates/bigiris/src/gui.rs:4631 msgid "JPEG progressivo" @@ -387,3 +387,10 @@ msgstr "Done" #: crates/bigiris/src/gui.rs:5629 msgid "Com erros" msgstr "With errors" + +msgid "Otimização do encoder" +msgstr "Encoder optimization" + +#: crates/bigiris/src/gui.rs +msgid "Remoção de fundo: requer 8 GiB de RAM livre para iniciar. Este recurso é opcional." +msgstr "Background removal requires 8 GiB of available RAM to start. This feature is optional." diff --git a/docs/ADR-002-glycin-gui-purerust-cli.md b/docs/ADR-002-glycin-gui-purerust-cli.md index 509849c..da5e3fb 100644 --- a/docs/ADR-002-glycin-gui-purerust-cli.md +++ b/docs/ADR-002-glycin-gui-purerust-cli.md @@ -1,33 +1,44 @@ -# ADR-002 — glycin na GUI, pure-Rust no CLI +# ADR-002 — backend de decodificação -- **Status:** Aceito +- **Status:** Implementado com worker Bubblewrap; expansão de formatos adiada - **Data:** 2026-04-19 +- **Revisão:** 2026-10-11 ## Contexto -Decodificar imagens em Linux envolve um zoológico de libs C com histórico denso de CVEs (libjpeg, libpng, libheif, libtiff…). A GUI precisa de decodificação segura, com suporte a formatos modernos (AVIF/HEIC/JXL/RAW) e sem travar a UI. O CLI precisa rodar em CI mínimo (contêiner Arch headless, possivelmente sem bwrap) e em instalações `makepkg` enxutas. +O visualizador e a CLI precisam entregar os mesmos pixels, orientação EXIF e +limites contra imagens hostis. Usar backends diferentes antes de existir uma +camada comum aumentaria a chance de divergência e anunciaria formatos que o +produto ainda não abre. -## Decisão +## Decisão atual -**Dois caminhos, um core:** +GUI e CLI usam `bigimage_core::open_image`, baseado no crate `image`, com +detecção por conteúdo, orientação EXIF e limites de 1 GiB/256 MP. No viewer, a +decodificação roda em uma thread dedicada e pedidos sucessivos são coalescidos, +evitando bloquear o loop do GTK. -1. **GUI** usa [`glycin`](https://gitlab.gnome.org/sophie-h/glycin): cada loader de formato roda em processo separado em sandbox `bwrap` com syscalls restritas. Bug em libheif não compromete o app. É o backend do Loupe. Entrega AVIF/HEIC/JXL/RAW/HDR de graça. +No executável, o core recebe um backend que delega decode, dimensões, previews +e metadados a um worker Bubblewrap separado. Esse worker usa a mesma +implementação `image`, lê a entrada por descritor e entrega pixels por um +protocolo validado, com limites de processo e cancelamento supervisionado. +O aplicativo recusa a imagem se o sandbox não iniciar. A biblioteca standalone +continua local por padrão; encoders e inferência ONNX não foram isolados. +Detalhes e limites em [QUALITY.md](QUALITY.md). -2. **CLI** usa **pure-Rust** primeiro: `image` crate + `jxl-oxide` + `jpegxl-rs` (encode JXL) + `libheif-rs` (quando necessário) + `rawler` + `kamadak-exif`. Fallback opcional para **libvips** em batches gigantes (pipeline streaming, memória constante). +Os formatos efetivamente suportados são apenas os expostos por +`bigimage_core::Format`. HEIC, JPEG XL, SVG e RAW não têm feature flags vazias +nem aparecem nos metadados do desktop. -## Consequências +## Evolução considerada -**Positivas** -- Segurança em profundidade no caminho GUI (R2). -- CI roda mesmo em contêiner sem bwrap (caminho pure-Rust). -- libvips opcional cobre o extremo de performance em batches enormes sem forçar toda compilação a depender dele. +Glycin continua sendo uma opção para substituir ou ampliar os loaders atuais, +desde que: -**Negativas** -- Dois caminhos de decode significa duas APIs a manter em sincronia. Mitigado: ambos produzem o mesmo tipo `DecodedImage` do core. -- libheif e jpegxl-rs continuam dependendo de libs C no CLI. Aceitável porque rodam fora de sandbox apenas em contextos onde o usuário confia no input (CI, scripts próprios). +1. exista adaptação única para o tipo interno do core; +2. os mesmos testes de orientação, dimensão e limites cubram os dois backends; +3. cada formato só seja anunciado depois de passar por testes reais; +4. a dependência esteja disponível nas distribuições suportadas. -## Alternativas descartadas - -- **Só glycin em todo lugar**: glycin é Linux-only e depende de bwrap. Não roda em CI mínimo nem em Alpine. Quebra requisito de CI headless. -- **Só pure-Rust em todo lugar**: perde cobertura de HEIC/RAW/HDR avançado que glycin já entrega. -- **ImageMagick/GraphicsMagick via CLI**: overhead de processo, superfície CVE grande, resultado menos controlável. +Até esses critérios serem atendidos, o backend atual é a implementação oficial, +não um fallback temporário. diff --git a/docs/ADR-003-single-binary-multi-module.md b/docs/ADR-003-single-binary-multi-module.md index 55ae2f2..5b6cfb3 100644 --- a/docs/ADR-003-single-binary-multi-module.md +++ b/docs/ADR-003-single-binary-multi-module.md @@ -24,8 +24,8 @@ Duas arquiteturas foram consideradas: ## Justificativa -1. **Preview ao vivo gratuito (R7).** O canvas GPU do viewer é o mesmo usado nos diálogos de edição e IA. Em dois binários, todo esse pipeline (render GPU, color management, HDR, film strip) teria de ser duplicado. Num só, é herdado. -2. **Cache de modelo de IA em RAM.** BiRefNet carrega em ~1–3 s e ocupa centenas de MB. Um processo long-running reusa a `ort::Session` entre viewer (preview de remoção de fundo) e diálogo batch. Dois processos = duplo custo. +1. **Componentes compartilhados.** Viewer e diálogos usam o mesmo core de decode, transformação, preview e encode, sem duplicar regras de segurança e nomes de saída. +2. **Cache de modelo dentro de cada lote.** Uma sessão BiRefNet é reutilizada pelos arquivos processados no mesmo fluxo de IA; diálogos independentes continuam isolados em processos próprios. 3. **Velocidade de desenvolvimento.** Um codebase, um CI, um PKGBUILD, uma submissão BigCommunity/BigLinux/Flathub. 4. **UX sem fragmentação.** Usuário instala `bigiris` e tem tudo. Service menus disparam `bigiris --dialog=X` — mesma identidade, mesmo ícone, mesmo histórico, mesmas preferências. 5. **Precedente sólido.** LibreOffice (soffice) faz exatamente isso: um binário, múltiplos `.desktop`, várias identidades de app percebida. @@ -33,7 +33,7 @@ Duas arquiteturas foram consideradas: ## Trade-offs aceitos - **Inchaço**: binário carrega código de todos os módulos mesmo quando usuário só quer converter. Mitigado por lazy-load: widgets IA só instanciados quando o diálogo IA é aberto; feature `ai` opcional no Cargo.toml permite distro enxuta. -- **Raio de explosão de crash**: panic em módulo X derruba tudo. Mitigado por isolamento de panics na boundary do core (rayon catch) e por diálogos serem modais short-lived com reabertura custando < 5 ms. +- **Raio de explosão de crash**: um panic derruba o processo corrente. Os diálogos avançados são lançados como processos independentes, limitando o impacto sobre o viewer já aberto. - **Imagem de marca**: "Prisma" perde visibilidade como produto separado. Mitigado mantendo "Prisma" como nome do módulo de conversão nos títulos de diálogo e em material de marketing — a narrativa Íris/Prisma sobrevive sem custo arquitetural. ## Rota de reversão diff --git a/docs/QUALITY.md b/docs/QUALITY.md new file mode 100644 index 0000000..ed6295f --- /dev/null +++ b/docs/QUALITY.md @@ -0,0 +1,254 @@ +# Qualidade do BigIris para BigCommunity GNOME + +A campanha local de 11 de outubro de 2026 produziu uma candidata com correções, +testes reproduzíveis e pacote para homologação. Os resultados abaixo permitem +avaliar o checkout; a adoção como aplicativo oficial depende dos critérios +pendentes. Não há certificação de ausência universal de bugs ou vazamentos. + +## Ambiente e rastreabilidade + +BigCommunity GNOME/Wayland, x86_64, Ryzen 7 5800H, 16 CPUs lógicas, +Rust 1.98.1, GTK 4.22.4, libadwaita 1.9.3, dav1d 1.5.4 e ONNX Runtime 1.29.0. +MSRV do projeto: 1.92. Runtime GTK mínimo: 4.12; libadwaita: 1.5. + +Somente imagens sintéticas, fixtures de regressão e diretórios de teste foram +usados. Alterações preexistentes foram preservadas. Evidências locais ficam +em `target/quality/` e não integram o pacote distribuído: + +- `zoom-transition/gates/summary.json`: verificações da correção da transição de zoom. +- `zoom-transition/painted-frames.json`: quadros pintados e escalas intermediárias. +- `round3/package-fixed-gates/summary.json`: verificações finais, com logs por gate. +- `round2/acceptance/{benchmark,stress,soak}.log`: performance e estabilidade. +- `fuzz/summary.json`, `decode.log` e `icc.log`: última campanha instrumentada. +- `round2/fuzz-before-gif*`, `fuzz-before-avif*` e `fuzz-artifacts/`: descobertas anteriores. +- `round3/release-final/release-smoke.json`: binário release, SHA-256 e 40 verificações. +- `round3/ai-release/summary.json`: inferência ONNX real antes da correção do empacotamento Nautilus. +- `round3/ai-final/summary.json`: repetição no binário final; conferir o resultado e o orçamento disponível. +- `round3/ai-preflight-final/summary.json`: recusa sob 4 GiB e runtime opcional ausente, conferidos no release. +- `round2/nautilus-real.json`: menu construído com GI/Nautilus instalado. +- `long-soak/status.json`: estado explícito do ensaio prolongado. +- `candidate/`: pacote candidato, hashes e manifesto. +- `screenshots-final/` e `screenshots-light-final/`: inspeção das janelas sintéticas. +- `valgrind.log`: impedimento local do Memcheck. + +A rodada 3 adiciona proteção de RAM para IA e zoom ancorado. Performance, +fuzzing e soak da rodada 2 continuam referidos ao core, que não mudou nessa +rodada. Os gates iniciais da rodada 3 registram falhas de Clippy e sincronização +do zoom; foram corrigidas antes dos gates finais. + +Relatórios anteriores que falharam continuam registrados como falha. A execução +`round2/acceptance/summary.json` teve uma falha de Clippy headless, corrigida +antes dos gates finais; seus três ensaios de performance passaram. Resultados +históricos não foram reescritos para aparentar aprovação. + +## Cobertura executada + +| Área | Resultado | Alcance | +| --- | --- | --- | +| Rust, todos os recursos/targets | 216 testes passaram | Workspace, GUI compilada, core, IA e integrações | +| Rust, headless | 178 testes passaram | Sobrepõe parte dos 216; não somar as contagens | +| GTK, teste gráfico separado | Passou | 30 aberturas/fechamentos do viewer, 13 janelas de ferramentas/propriedades e seleção sintética de 10 mil arquivos; estado liberado e no máximo 41 miniaturas simultâneas; seis cenários de zoom conferidos quadro a quadro, escalas intermediárias e pixels renderizados | +| Nautilus | 6 contratos passaram; GI real criou menus | FileInfo simulado; clique em Nautilus e política da distribuição exigem homologação | +| Rustfmt e Clippy | Passaram, warnings como erros | Headless e todos os recursos | +| Distribuição | Desktop, AppStream offline, schemas, inglês e sintaxe passaram | Sem instalar ou alterar associações padrão no host | +| Fidelidade | Testes passaram | EXIF, ICC RGB→sRGB, alpha, PNG 16 bits, todos os 13 formatos; RGB não linear/float dentro do comportamento anunciado | +| Preservação de conteúdo | Testes passaram | Transformações estáticas recusam GIF/APNG/WebP animados e TIFF multipágina | +| Gravação e concorrência | Testes passaram | Publicação atômica, preservação do destino em falhas, Skip/Increment concorrentes | +| CLI e release | 40 verificações no release | Self-test de isolamento, conversão, resize e reabertura dos 13 formatos | +| Fuzzing ASan/LSan | Última campanha passou | 17.304 execuções decode e 33.331 ICC, 61 s por alvo; sem erro reportado nessa amostra | +| Stress | 64 conversões, 4 trabalhadores, todas reabertas | Passou; 0,593 s medidos pelo probe, pico de 51.008 KiB | +| Estabilidade curta | 300,079 s, 3.029 ciclos | RSS estável, descritores e threads estáveis | +| IA real | Passou antes da correção do empacotamento; 21,172 s para duas imagens | RGB/alpha preservados, sessão reutilizada; pico de 6.847.848 KiB (~6,53 GiB). Repetição no binário final recusada por RAM disponível abaixo de 8 GiB | +| RustSec | Gate final passou | Zero vulnerabilidades e zero avisos unsound na base consultada; um aviso de manutenção | +| Memcheck | Passou no CI em oito ciclos sintéticos do core | Zero erros, zero bytes definitivamente/indiretamente perdidos; 48 bytes possivelmente perdidos e 548 bytes ainda alcançáveis. Não cobre GUI ou ONNX; impedimento local do loader glibc permanece | +| Oito horas do core | Passou; 28.800,047 s e 298.657 ciclos | RSS inicial/final de 13.200/7.988 KiB, pico de 26.108 KiB; quatro descritores e uma thread antes/depois. Binário e estado final registrados em `long-soak/`; não cobre o produto completo | + +Os testes GTK executam loaders locais no binário de teste. O executável final +usa o worker confinado, exercitado pelos testes da CLI/release. Nenhum desses +ensaios substitui uma sessão prolongada do produto completo com GPU e Orca. + +O fuzzing é guiado por cobertura LLVM, instrumenta o código Rust do core e usa +corpus sintético dos formatos e perfis ICC. A entrada mutada é limitada a 16 KiB; +o decode completo, a um milhão de pixels. Cabeçalhos de imagens maiores continuam +inspecionados. As bibliotecas nativas externas não são instrumentadas. Limites +maiores têm regressões e contenção pelo sandbox; o resultado não cobre todo +arquivo possível ou todos os encoders, GTK, ONNX e argumentos CLI. + +Durante a campanha, GIF com frame maior que a tela lógica e AVIF com caixa além +do arquivo provocaram timeout/alocação excessiva. Foram acrescentadas inspeções +estruturais sem buffers proporcionais aos tamanhos declarados e regressões +reproduzíveis. Outro timeout vinha de um GIF válido com tela de 33 MP; o orçamento +de pixels do alvo instrumentado foi explicitado, sem reduzir a cobertura de +cabeçalhos ou fingir que esse caso era uma falha de memória corrigida. + +## Performance medida + +Imagem sintética de 1920 × 1080, release, cache aquecido, três amostras por +operação. Amostragem exploratória na sessão de desenvolvimento; não é uma +comparação controlada com outro visualizador nem mede abertura fria da GUI. + +| Operação | Mediana em ms | P95 em ms | +| --- | ---: | ---: | +| Decode PNG | 11,206 | 11,230 | +| Resize para aresta 1080 | 15,030 | 16,653 | +| Preview até 800 | 15,673 | 16,840 | +| PNG | 23,588 | 23,626 | +| JPEG | 66,197 | 66,403 | +| WebP lossless | 33,396 | 34,892 | +| AVIF | 7.388,941 | 7.559,215 | + +Resize/preview incluem decode; conversão inclui decode, preparação, encode, +gravação e sincronização. Pico do benchmark: 79.660 KiB. AVIF continua o gargalo; +limitar suas threads a duas contém a multiplicação de trabalhadores de lote, +mas esses números não demonstram uma melhoria percentual atribuível à mudança. + +No soak de cinco minutos, RSS após aquecimento: 13.136 KiB, antes e depois; +pico: 26.056 KiB; quatro descritores e uma thread antes e depois. O probe usa +allocator padrão; o executável usa mimalloc. As medições não abrangem GPU, +memória de ONNX ou um acervo grande. O binário do ensaio prolongado tem hash +registrado; um resultado só vale para aquela compilação e aquele roteiro. + +## Correções e arquitetura + +O executável delega decode, dimensões, metadados e previews a um worker +Bubblewrap sem rede/home/run/etc, com entrada por descritor somente leitura, +`/usr` e executável somente leitura e tmpfs de 64 MiB. `prlimit` limita espaço +virtual a 2 GiB, CPU a 30 s, descritores a 64, core dumps a zero e cada arquivo +de saída a 512 MiB mais cabeçalho. O supervisor encerra o filho após 60 s ou +cancelamento. O protocolo de pixels é validado antes da alocação no pai. +Se o isolamento falhar, a imagem é recusada. Não há fallback sem sandbox. + +Isso não é quota de RSS/cgroup nem filtro seccomp. Encoders, GTK, TLS e ONNX +continuam no pai. Consumidores autônomos da biblioteca core executam localmente +por padrão. O worker restringe entrada e saída; scratch interno dos codecs +não tem contabilização precisa por buffer. `/usr` inteiro é visível somente +leitura, em vez de uma allowlist mínima de bibliotecas. + +O pipeline limita entrada regular a 1 GiB, até 256 × 1024² pixels e 512 MiB por +buffer. Lotes usam estimativa de CPU/RAM e até quatro trabalhadores. Escrita +usa temporário único, flush/sync e publicação atômica. PNG preserva 16 bits; +ICO normaliza RGBA e limita dimensões. ICC RGB é convertido a sRGB preservando +alpha/precisão; perfis inválidos ou não RGB são recusados. PNG/JPEG recebem +perfil sRGB gerado sem campos identificadores da origem. Metadados pessoais +não são copiados na conversão. + +Descoberta de pastas, properties, carregamento dos diálogos, comparação e +preparo dos pixels de impressão saíram da thread GTK. Previews têm um trabalhador +coalescente, buffers limitados e apenas a revisão atual é apresentada. A fita +é paginada em até 41 widgets. Referências fracas e desconexão de sinais reduzem +retenções; testes verificam liberação. A impressão usa alpha premultiplicado, +com regressão específica; não foi enviado trabalho a uma impressora real. + +A IA exige HTTPS/TLS e SHA-256, URL de revisão imutável, publicação atômica do +modelo, shape validado e sigmoid dos logits, preservando alpha original. +Uma sessão é reutilizada por lote e usa até quatro threads de inferência. +Não há interrupção segura no meio de uma inferência. O release da rodada 3 atingiu 6.847.848 KiB (~6,53 GiB) nas duas imagens +sintéticas, em 21,172 s. A rodada 2 mediu 6,12 GiB e 33,195 s; a diferença +entre sessões não demonstra redução de tempo atribuível às mudanças. +A feature é opcional. A candidata exige 8 GiB de RAM livre antes de baixar/carregar +esse modelo; reavalia o orçamento antes de cada inferência e acrescenta a área +de trabalho da imagem. Considera MemAvailable, todos os limites finitos dos +ancestrais cgroup v2 e RLIMIT_AS, sem somar swap. Sessões reutilizadas creditam +crescimento residente observado nas chamadas nativas, com teto conservador. +Isso é admissão preventiva, não uma quota rígida de RSS: a disponibilidade pode +mudar após a conferência e cgroup v1 não é abrangido. + +Quatro GiB são recusados antes do download. O teste da CLI usa RLIMIT_AS de +4 GiB, verifica mensagem, ausência de cache/saída e preservação do original; +três testes puros cobrem capacidade, pressão, crédito e parsing do orçamento. +O release anterior à correção do empacotamento também recusou o orçamento de +4 GiB e tratou a ausência da biblioteca opcional ONNX como erro concluído, +sem saída nem alteração do original. No binário final, a recusa sob 4 GiB passou; +o teste de biblioteca ausente não alcançou a carga porque o host tinha menos de +3,3 GiB livres. A inferência foi recusada pela mesma política. Os resultados +não foram convertidos artificialmente em aprovação de inferência; repetição +no pacote final com RAM suficiente permanece pendente. +Isso não equivale a homologação em máquina física de 4 GB. Visualização e +conversão continuam disponíveis, sujeitas ao tamanho e aos limites das imagens. +O teste experimental sem arena do ORT sob limite de espaço virtual de 3 GiB +falhou na inferência; essa configuração não foi adotada no produto. + +O zoom foi extraído para `gui/zoom.rs`. Roda, atalhos e botões usam o ponto do +cursor; sem posição válida, usam o centro. A regressão original conferia apenas +a posição após o término. Um novo teste em `after-paint` reproduziu dois quadros +com deslocamento transitório de aproximadamente 185 pixels da imagem no eixo +horizontal. A posição e os limites de rolagem agora são configurados antes da +nova escala; a transição usa o relógio de quadros do GTK e interpolação gradual +em escala logarítmica. Eventos fracionários do touchpad preservam sua magnitude. +A preferência `gtk-enable-animations=false` aplica o zoom imediatamente. + +Comandos rápidos atualizam o destino da mesma animação, preservando a âncora +quando o cursor permanece no lugar. Arrastar, ajustar à janela, navegar e fechar +cancelam a animação. O zoom referencia a textura existente, sem redimensionar +pixels na thread gráfica. `1` corresponde a 100% e `0` restaura o ajuste à janela. +Quando a imagem cabe na janela ou alcança uma borda, a rolagem é limitada. + +O teste gráfico confere cada quadro pintado contra a projeção geométrica com +esses limites, com tolerância inferior a 1,5 pixel da tela por eixo, monotonicidade +da escala, presença de quadros intermediários e pixels renderizados no destino. +Inclui teclado, botões, ampliações rápidas, inversão, 100%, animações desativadas, +arrasto manual, navegação e fechamento. Essa verificação não certifica conforto +visual pessoal nem ausência de frames perdidos em todo hardware; a avaliação +com mouse e touchpad reais permanece no roteiro de homologação. + +O SVG do ícone também recebeu correção de cabeçalho: o comentário extenso foi +movido para dentro do elemento raiz. O desenho permaneceu igual, e o arquivo +passou a ser reconhecido pelo carregador GdkPixbuf usado no GNOME. + +A validação do pacote também revelou que a extensão Nautilus já instalada no +host fazia o gerador omiti-la do DESTDIR. O escopo agora é explícito, sem inferir +instalação pessoal por prefixo do caminho. Regressões verificam inclusão no +pacote apesar da extensão existente, preservação do arquivo do host e prevenção +de duplicação na instalação pessoal. O arquivo incompleto anterior foi mantido +em `round3/package-before-nautilus-fix/`, sem aprovação de distribuição. + +## Pendências para adoção oficial + +| Prioridade | Pendência | Aceitação | +| --- | --- | --- | +| Alta | Instalação limpa BigCommunity | Build da receita completa; instalar, atualizar, remover; MIME, schemas, idiomas e Nautilus reais | +| Alta | Acessibilidade e uso | Orca, teclado, foco, alto contraste, claro/escuro, escalas 100/150/200% e múltiplos monitores | +| Alta | Estabilidade prolongada | Resultado de oito horas do core mais sessão GNOME/slideshow com memória CPU/GPU monitorada | +| Alta | Memória nativa | Memcheck em ambiente compatível; CI remoto verde e revisão dos logs | +| Alta | Acervo e fidelidade | Comparação visual/colorimétrica de imagens reais autorizadas, ICC e 16 bits; definir política para HDR, animação e páginas | +| Alta | IA em hardware modesto | Medir RAM, tempo, qualidade da máscara e cancelamento; homologar a recusa do modelo atual e medir alternativas menores | +| Média | Performance do produto | Primeiro frame frio, navegação e latência comparados ao visualizador nativo no mesmo hardware/acervo | +| Média | Dívida arquitetural restante | Extrair estado/controllers/diálogos do `gui.rs`; ampliar testes dos fluxos interativos | +| Média | Dependência sem manutenção | Retirar `paste` transitivo via rav1e/ravif, RUSTSEC-2024-0436, com regressão AVIF | +| Média | Confinamento adicional | Avaliar encoders/ONNX fora do pai, seccomp/cgroup e allowlist de bibliotecas | + +O viewer mostra primeiro quadro/página; não há reprodução de animações, +navegação TIFF multipágina ou gestão HDR/tone mapping completa. A estabilidade +medida e a liberação de referências não provam ausência de leaks nativos. +Os logs GTK registraram aviso de tema e indisponibilidade do barramento AT-SPI; +Orca permanece pendente. O pacote é recomposto do release testado, não um build +limpo certificado. + +O primeiro [CI remoto do PR nº 1](https://github.com/xathay/bigiris/actions/runs/38161983238) +terminou com dez jobs aprovados e quatro falhas. O fuzzing selecionou stable +por causa de `rust-toolchain.toml`, antes de testar o corpus. Os dois jobs de +testes funcionais e o self-test do release foram bloqueados na configuração +de loopback do Bubblewrap no runner Ubuntu. O build release em si compilou. +A correção define `RUSTUP_TOOLCHAIN=nightly` apenas no fuzzing e carrega o +perfil de Bubblewrap fornecido pelo pacote `apparmor-profiles` nos três jobs +de runtime, com perfil dos filhos que nega capabilities. Um preflight exige +isolamento e capabilities efetivas zeradas antes de compilar. AppArmor e as +restrições globais permanecem ativos; o contêiner Arch não recebe privilégios +amplos. A nova configuração exige nova execução remota antes de aprovação. +O teste gráfico já inclui as transições de zoom e agora publica o relatório +dos quadros pintados como artefato do CI. + +## Referências e reprodução + +[GNOME HIG](https://developer.gnome.org/hig/index.html), +[acessibilidade](https://developer.gnome.org/hig/guidelines/accessibility.html), +[image::Limits](https://docs.rs/image/latest/image/struct.Limits.html), +[Bubblewrap](https://github.com/containers/bubblewrap), +[RustSec](https://github.com/RustSec/advisory-db), +[Valgrind](https://valgrind.org/docs/manual/manual-core.html) e +[restrições de namespaces do Ubuntu](https://documentation.ubuntu.com/release-notes/24.04/#unprivileged-user-namespace-restrictions) +orientam os critérios. Em runners Ubuntu, conferir o perfil de Bubblewrap da +distribuição; sandbox indisponível deve falhar, sem desativar proteções globais. + +Comandos em [CONTRIBUTING.md](../CONTRIBUTING.md#campanha-de-qualidade-no-gnome). +Entrega e roteiro de homologação em [RELEASE-CANDIDATE.md](RELEASE-CANDIDATE.md). diff --git a/docs/RELEASE-CANDIDATE.md b/docs/RELEASE-CANDIDATE.md new file mode 100644 index 0000000..ca49b90 --- /dev/null +++ b/docs/RELEASE-CANDIDATE.md @@ -0,0 +1,98 @@ +# Candidata local do BigIris para BigCommunity GNOME + +Esta candidata reúne as correções e os testes descritos em +[QUALITY.md](QUALITY.md). A adoção como aplicativo oficial depende da +homologação da distribuição; o pacote local permite começar essa avaliação. + +## Entrega e reprodução + +- Binário completo: `target/release/bigiris`, com GTK/libadwaita e IA. +- Pacote local: `target/quality/candidate/bigiris-0.1.0.local-3-x86_64.pkg.tar.zst`, acompanhado de SHA-256. +- Gates: `target/quality/zoom-transition/gates/summary.json`. +- Testes do release: `target/quality/zoom-transition/release/release-smoke.json`. +- Quadros do zoom: `target/quality/zoom-transition/painted-frames.json`. +- Fuzzing instrumentado: `target/quality/fuzz/summary.json`. +- Estabilidade prolongada: `target/quality/long-soak/status.json`. + +O pacote é recomposto com a receita `PKGBUILD.local` a partir do release +compilado e dos dados atuais, depois dos testes do checkout. Isso valida o +conteúdo e a etapa `package()`; não equivale a um build reproduzível em uma +instalação limpa. Os resultados executados prevalecem sobre comandos apenas +preparados. `running` no ensaio prolongado significa pendente. + +Para experimentar o pacote em uma instalação de homologação BigCommunity, +instale o arquivo `.pkg.tar.zst` com `sudo pacman -U caminho/do/pacote`. +O runtime exige `bubblewrap` e `util-linux`, namespaces disponíveis e as +bibliotecas declaradas no pacote. `bigiris --self-test` decodifica uma imagem +sintética pelo sandbox e verifica ausência dos diretórios privados e da +rede do host. Se falhar, a candidata não deve ser aprovada nessa máquina. + +## Roteiro para Leonardo e Tales + +1. Em instalação limpa, instalar, atualizar e remover o pacote. Conferir + desktop entry, ícone, schemas, tradução, Nautilus e resíduos da remoção. +2. Abrir pelo Nautilus uma imagem, seleção e pasta; testar navegação, + zoom sob o cursor com roda, botões, `+`/`-`, `1` (100%) e `0` (ajustar); + conferir a fluidez com roda e touchpad, inversão durante a transição e + arrasto manual; repetir com animações desativadas no GTK. Avaliar o conforto + visual pessoal antes do envio ao repositório. Exercitar + conversão dos formatos anunciados, cancelamento, erros e arquivos em + sincronização. Confirmar ausência de alteração dos originais em falhas. +3. Exercitar teclado e Orca, alto contraste, temas claro/escuro, escalas + 100%, 150% e 200% e múltiplos monitores. O teste automatizado de janelas + não substitui essa avaliação de acessibilidade e uso. +4. Comparar tempo até o primeiro frame, navegação, RAM/GPU e interação + com o visualizador atual, usando o mesmo acervo autorizado e o mesmo + hardware. Incluir máquina de pouca RAM e SSD lento. +5. Conferir orientação, cor, transparência e precisão com imagens reais. + ICC RGB é normalizado para sRGB; perfis não RGB são recusados. O viewer + mostra o primeiro quadro/página; edição estática recusa animações e + TIFF multipágina. HDR não dispõe de gestão completa ou tone mapping. +6. Avaliar remoção de fundo: download íntegro, inferência local, cancelamento + entre arquivos e reutilização do modelo. O ensaio inicial atingiu cerca + de 6 GiB; o ensaio da rodada 3, antes da correção de empacotamento, processou duas + imagens em 21,172 s, com pico de + aproximadamente 6,53 GiB. A candidata exige 8 GiB de RAM livre para iniciar esse modelo e recusa + um orçamento de 4 GiB antes do download. Visualização e conversões continuam + disponíveis. Homologar em hardware físico de pouca RAM; o limite é uma + conferência preventiva, sem quota rígida de RSS. +7. Exigir resultado final do ensaio prolongado e do Memcheck em ambiente + compatível. O soak iniciado exercita o core; adicionar sessão GNOME de + oito horas com slideshow e monitoramento de GPU antes da adoção oficial. + +## Limites desta candidata + +ASan/LSan, testes de ciclo de vida e estabilidade curta não provam ausência +universal de vazamentos. O isolamento cobre decodificação e inspeção de +metadados do executável; inferência ONNX e encoders continuam no processo +principal. O worker tem limites de espaço virtual, CPU, prazo, arquivos, +descritores e scratch, sem quota cgroup de RSS nem filtro seccomp. + +A repetição da inferência no binário final foi recusada pelo orçamento de RAM +do host, então permanece pendente com pelo menos 8 GiB disponíveis. As duas +inferências reais aprovadas precedem a correção de empacotamento Nautilus. +Os testes de recusa sob 4 GiB passaram também no binário final. + +O primeiro CI do PR nº 1 aprovou dez jobs, inclusive o empacotamento Arch e +o Memcheck de oito ciclos do core. Quatro jobs falharam por seleção de stable +no fuzzing e indisponibilidade do sandbox no runner Ubuntu. A configuração +foi corrigida, mas a confirmação exige nova execução remota. O ensaio local +de oito horas do core também terminou aprovado; GUI/GPU/ONNX não fazem parte +desse ensaio. Resultados e limites estão em [QUALITY.md](QUALITY.md). +Não foram concedidos privilégios amplos ao contêiner Arch. A publicação +no repositório da distribuição e a associação MIME padrão exigem decisão +de manutenção após a homologação. + +## Administração pelo Git + +Abra a pasta raiz do BigIris que contém `.git`, `Cargo.toml`, `crates/` e +`pkgbuild/`. Confira a branch selecionada antes de integrar alterações. Abrir +essa pasta +em um cliente Git permite revisar diffs e arquivos novos; não publica nem +atualiza automaticamente o repositório da distribuição. Confira os arquivos +incluídos no commit, inclusive novos módulos e testes. Os artefatos de +`target/quality/` são evidência local e não devem entrar no histórico. + +Envie a candidata primeiro para a branch de desenvolvimento e obtenha CI +remoto verde. A integração no repositório oficial deve seguir o roteiro de +homologação acima e as decisões de manutenção de Leonardo e Tales. diff --git a/docs/batch-profile.example.toml b/docs/batch-profile.example.toml new file mode 100644 index 0000000..4eedc23 --- /dev/null +++ b/docs/batch-profile.example.toml @@ -0,0 +1,9 @@ +# BigIris headless batch profile +format = "webp" +quality = 85 +progressive = false +optimize = false +overwrite = "increment" # skip | replace | increment + +# Optional. Without it, outputs are written beside each source image. +# output_dir = "/home/user/Pictures/export" diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock new file mode 100644 index 0000000..631f295 --- /dev/null +++ b/fuzz/Cargo.lock @@ -0,0 +1,1639 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "av-data" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fca67ba5d317924c02180c576157afd54babe48a76ebc66ce6d34bb8ba08308e" +dependencies = [ + "byte-slice-cast", + "bytes", + "num-derive", + "num-rational", + "num-traits", +] + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "bigimage-core" +version = "0.1.0" +dependencies = [ + "fast_image_resize", + "image", + "kamadak-exif", + "moxcms", + "rustix", + "tempfile", + "thiserror", + "tracing", +] + +[[package]] +name = "bigiris-fuzz" +version = "0.0.0" +dependencies = [ + "bigimage-core", + "image", + "libfuzzer-sys", + "tempfile", +] + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bitreader" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "886559b1e163d56c765bc3a985febb4eee8009f625244511d8ee3c432e08c066" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byte-slice-cast" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-expr" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "dav1d" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee89cb860616069c67520dcd66cacdb900b57c799f634a0eb6d91f6e2a82b61" +dependencies = [ + "av-data", + "bitflags", + "dav1d-sys", + "static_assertions", +] + +[[package]] +name = "dav1d-sys" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3c91aea6668645415331133ed6f8ddf0e7f40160cd97a12d59e68716a58704b" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fallible_collections" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88c69768c0a15262df21899142bc6df9b9b823546d4b4b9a7bc2d6c448ec6fd" +dependencies = [ + "hashbrown 0.13.2", +] + +[[package]] +name = "fast_image_resize" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12dd43e5011e8d8411a3215a0d57a2ec5c68282fb90eb5d7221fab0113442174" +dependencies = [ + "bytemuck", + "cfg-if", + "document-features", + "image", + "num-traits", + "thiserror", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "dav1d", + "exr", + "gif", + "image-webp", + "moxcms", + "mp4parse", + "num-traits", + "png", + "qoi", + "ravif", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "kamadak-exif" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837" +dependencies = [ + "mutate_once", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "mp4parse" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63a35203d3c6ce92d5251c77520acb2e57108c88728695aa883f70023624c570" +dependencies = [ + "bitreader", + "byteorder", + "fallible_collections", + "log", + "num-traits", + "static_assertions", +] + +[[package]] +name = "mutate_once" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand", + "rand_chacha", + "simd_helpers", + "thiserror", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rgb", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..af90d47 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "bigiris-fuzz" +version = "0.0.0" +edition = "2024" +publish = false + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +bigimage-core = { path = "../crates/bigimage-core" } +tempfile = "3" +image = { version = "0.25", default-features = false } + +[[bin]] +name = "decode" +path = "fuzz_targets/decode.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "icc" +path = "fuzz_targets/icc.rs" +test = false +doc = false +bench = false + +[workspace] diff --git a/fuzz/fuzz_targets/decode.rs b/fuzz/fuzz_targets/decode.rs new file mode 100644 index 0000000..6347149 --- /dev/null +++ b/fuzz/fuzz_targets/decode.rs @@ -0,0 +1,21 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use std::io::Write; +fuzz_target!(|data: &[u8]| { + if data.len() > 1024 * 1024 { + return; + } + let tga = data.len() >= 18 + && [1, 2, 3, 9, 10, 11].contains(&data[2]) + && [8, 16, 24, 32].contains(&data[16]); + let mut input = + tempfile::Builder::new().suffix(if tga { ".tga" } else { ".png" }).tempfile().unwrap(); + input.write_all(data).unwrap(); + let dimensions = bigimage_core::backend::dimensions_in_process(input.path()); + // Keep full pixel work bounded under ASan; oversized headers remain parsed. + if dimensions.is_ok_and(|(w, h)| u64::from(w) * u64::from(h) <= 1_000_000) { + let _ = bigimage_core::backend::decode_in_process(input.path()); + } + let _ = bigimage_core::metadata::read(input.path()); + let _ = bigimage_core::content::ensure_static_local(input.path()); +}); diff --git a/fuzz/fuzz_targets/icc.rs b/fuzz/fuzz_targets/icc.rs new file mode 100644 index 0000000..0cace7e --- /dev/null +++ b/fuzz/fuzz_targets/icc.rs @@ -0,0 +1,9 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +fuzz_target!(|data: &[u8]| { + if data.len() > 4 * 1024 * 1024 { + return; + } + let mut image = image::DynamicImage::new_rgba8(16, 16); + let _ = bigimage_core::color::normalize_srgb(&mut image, data); +}); diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..58c132f --- /dev/null +++ b/install.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# install.sh — build + instala o BigIris para o usuário atual (~/.local), sem root. +# +# Espelha o que o PKGBUILD faz system-wide, mas com prefixo do usuário: +# binário, .desktop, ícone, gschema, traduções e service menus dos +# gerenciadores de arquivos. Compila SEMPRE com `--features "gui ai"` +# (visualizador GTK4 + remover-fundo por IA), que é o ponto fácil de errar +# ao rodar `cargo build` na mão. +# +# Uso: +# ./install.sh # build + instala em ~/.local +# PREFIX=~/.local ./install.sh # idem (prefixo customizável) +# ./install.sh --no-build # pula o cargo build; instala o target já compilado +# ./install.sh --bin-only # só o binário (sem .desktop/ícone/integrações) +# ./install.sh --help +# +# Para instalação system-wide, use o pacote da distribuição (há receitas +# para OpenMandriva e Arch em packaging/ e pkgbuild/). + +set -euo pipefail + +# ── Localização do projeto (funciona de qualquer cwd; tolera espaços no path) ── +SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PREFIX="${PREFIX:-$HOME/.local}" +BUILD=1 +BIN_ONLY=0 + +for arg in "$@"; do + case "$arg" in + --no-build) BUILD=0 ;; + --bin-only) BIN_ONLY=1 ;; + -h|--help) + sed -n '2,/^set -euo/p' "$0" | sed 's/^# \{0,1\}//; /^set -euo/d' + exit 0 ;; + *) echo "Argumento desconhecido: $arg (use --help)" >&2; exit 2 ;; + esac +done + +say() { printf '\033[1;36m▸ %s\033[0m\n' "$*"; } +ok() { printf '\033[1;32m✓ %s\033[0m\n' "$*"; } +warn() { printf '\033[1;33m! %s\033[0m\n' "$*" >&2; } +die() { printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2; exit 1; } + +# ── Pré-requisitos ──────────────────────────────────────────────────────────── +say "Verificando dependências" +command -v bwrap >/dev/null || die "bubblewrap ausente — instale o pacote bubblewrap" +command -v prlimit >/dev/null || die "prlimit ausente — instale o pacote util-linux" +command -v cargo >/dev/null || die "cargo não encontrado — instale Rust e Cargo pela sua distribuição" +command -v msgfmt >/dev/null || warn "msgfmt (gettext) ausente — traduções serão puladas" + +# Dependências de runtime para gui+ai. Aviso brando: nem toda distro é Arch. +if [ -f /etc/openmandriva-release ] && command -v dnf >/dev/null; then + miss=() + for p in rust cargo lib64gtk4.0-devel lib64adwaita-devel onnxruntime-devel gettext; do + rpm -q "$p" >/dev/null 2>&1 || miss+=("$p") + done + if [ "${#miss[@]}" -gt 0 ]; then + warn "Pacotes de compilação possivelmente faltando: ${miss[*]}" + warn "No OpenMandriva: sudo dnf install ${miss[*]}" + fi +elif command -v pacman >/dev/null; then + miss=() + for p in gtk4 libadwaita onnxruntime; do + pacman -Qq "$p" >/dev/null 2>&1 || pacman -Qq "${p}-cpu" >/dev/null 2>&1 || miss+=("$p") + done + if [ "${#miss[@]}" -gt 0 ]; then + warn "Pacotes possivelmente faltando: ${miss[*]}" + warn "Instale com: sudo pacman -S ${miss[*]} (onnxruntime pode ser onnxruntime-cpu)" + fi +else + warn "pacman ausente — confirme manualmente: gtk4, libadwaita, onnxruntime instalados." +fi +ok "Checagem de dependências concluída" + +# ── Build ───────────────────────────────────────────────────────────────────── +# O target-dir não é necessariamente `$SRC/target`: o ~/.cargo/config.toml +# desta máquina aponta para fora da pasta sincronizada, porque o `target/` +# do Rust reescreve milhares de arquivos por build e afogava o Nextcloud. +# Perguntar ao cargo é o único jeito que respeita config.toml, CARGO_TARGET_DIR +# e o padrão, sem duplicar a regra aqui. +TARGET_DIR="" +if command -v python3 >/dev/null; then + TARGET_DIR="$( + cd "$SRC" && cargo metadata --no-deps --format-version 1 2>/dev/null | + python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])' 2>/dev/null + )" +fi +[ -n "$TARGET_DIR" ] || TARGET_DIR="${CARGO_TARGET_DIR:-$SRC/target}" +[ -n "$TARGET_DIR" ] || TARGET_DIR="$SRC/target" +BIN="$TARGET_DIR/release/bigiris" +if [ "$BUILD" -eq 1 ]; then + say "Compilando bigiris (release, features: gui ai) — pode levar alguns minutos" + ( cd "$SRC" && cargo build --locked --release --features "gui ai" --package bigiris ) + ok "Build concluído" +else + say "--no-build: usando binário já compilado" +fi +[ -x "$BIN" ] || die "Binário não encontrado em $BIN (target-dir: $TARGET_DIR; rode sem --no-build)" + +# Confirma que saiu com gui+ai linkados (evita reinstalar uma build headless). +if ! ldd "$BIN" 2>/dev/null | grep -qi gtk-4; then + die "O binário NÃO está linkado ao GTK4 — build headless. Recompile com --features \"gui ai\"." +fi + +# ── Instalação do binário ───────────────────────────────────────────────────── +say "Instalando binário em $PREFIX/bin" +install -Dm755 "$BIN" "$PREFIX/bin/bigiris" +ok "bigiris → $PREFIX/bin/bigiris" + +if [ "$BIN_ONLY" -eq 0 ]; then + DATA="$SRC/data" + SHARE="$PREFIX/share" + + # .desktop, metainfo, ícone + say "Instalando .desktop, metainfo e ícone" + install -Dm644 "$DATA/com.biglinux.Iris.desktop" "$SHARE/applications/com.biglinux.Iris.desktop" + install -Dm644 "$DATA/com.biglinux.Iris.metainfo.xml" "$SHARE/metainfo/com.biglinux.Iris.metainfo.xml" + install -Dm644 "$DATA/icons/hicolor/scalable/apps/com.biglinux.Iris.svg" \ + "$SHARE/icons/hicolor/scalable/apps/com.biglinux.Iris.svg" + + # GSettings schema (necessário para o app GUI ler/gravar preferências) + say "Instalando e compilando gschema" + install -Dm644 "$DATA/com.biglinux.Iris.gschema.xml" "$SHARE/glib-2.0/schemas/com.biglinux.Iris.gschema.xml" + if command -v glib-compile-schemas >/dev/null; then + glib-compile-schemas "$SHARE/glib-2.0/schemas" && ok "schemas compilados" + else + warn "glib-compile-schemas ausente — o app pode reclamar de schema na 1ª execução" + fi + + # Traduções (.po → .mo) conforme data/po/LINGUAS + if command -v msgfmt >/dev/null && [ -f "$DATA/po/LINGUAS" ]; then + say "Compilando traduções" + I18N_TMP="$(mktemp -d -t bigiris-i18n.XXXXXX)" + trap 'rm -rf -- "$I18N_TMP"' EXIT + while read -r lang; do + [ -z "$lang" ] && continue + case "$lang" in \#*) continue ;; esac + if [ -f "$DATA/po/${lang}.po" ]; then + msgfmt -o "$I18N_TMP/${lang}.mo" "$DATA/po/${lang}.po" + install -Dm644 "$I18N_TMP/${lang}.mo" "$SHARE/locale/${lang}/LC_MESSAGES/bigiris.mo" + ok "locale: $lang" + fi + done < "$DATA/po/LINGUAS" + fi + + # Service menus dos gerenciadores de arquivos (Nautilus, Dolphin, Nemo, etc.) + say "Instalando integrações dos gerenciadores de arquivos (--user)" + "$PREFIX/bin/bigiris" install-integrations --user && ok "integrações instaladas" + + # Atualiza caches do desktop (best-effort) + command -v update-desktop-database >/dev/null && \ + update-desktop-database "$SHARE/applications" >/dev/null 2>&1 || true + command -v gtk4-update-icon-cache >/dev/null && \ + gtk4-update-icon-cache -q -f --ignore-theme-index "$SHARE/icons/hicolor" >/dev/null 2>&1 || true + command -v kbuildsycoca6 >/dev/null && \ + kbuildsycoca6 --noincremental >/dev/null 2>&1 || true +fi + +# ── Verificação final ───────────────────────────────────────────────────────── +say "Self-test" +"$PREFIX/bin/bigiris" --self-test + +echo +ok "BigIris instalado em $PREFIX" +case ":$PATH:" in + *":$PREFIX/bin:"*) : ;; + *) warn "$PREFIX/bin não está no PATH — adicione: export PATH=\"$PREFIX/bin:\$PATH\"" ;; +esac diff --git a/navegacao-por-pasta.patch b/navegacao-por-pasta.patch new file mode 100644 index 0000000..10ffb1b --- /dev/null +++ b/navegacao-por-pasta.patch @@ -0,0 +1,301 @@ +--- /tmp/claude-1000/-home-athayde/f6348541-d1ea-438f-b33d-79f67a1958a8/scratchpad/gui.rs.antes 2026-09-09 19:21:29.103975516 -0300 ++++ crates/bigiris/src/gui.rs 2026-09-09 19:23:22.036020430 -0300 +@@ -61,6 +61,10 @@ + + /// Start the viewer event loop. Returns the exit code GTK produced. + pub fn run_viewer(files: Vec) -> i32 { ++ // Um clique no gerenciador de arquivos entrega um caminho só. Sem isto, ++ // `files.len() == 1` desliga setas, film strip e slideshow, e o ++ // visualizador vira uma janela de imagem única. ++ let (files, start_idx) = expand_to_folder(files); + // NON_UNIQUE: sem isso o GApplication tenta single-instance via D-Bus + // e, se uma janela do BigIris tá se fechando no momento em que outra + // tenta abrir (ex.: dashboard spawna novo processo e fecha), a nova +@@ -74,7 +78,7 @@ + let files = Rc::new(files); + app.connect_activate(move |app| { + install_theater_css(); +- build_window(app, files.clone()).present(); ++ build_window(app, files.clone(), start_idx).present(); + }); + + // Empty argv: clap already consumed the interesting bits and we don't want +@@ -83,6 +87,142 @@ + i32::from(u8::from(code)) + } + ++/// Quando o visualizador recebe um arquivo só, varre a pasta dele e ++/// devolve todas as imagens irmãs em ordem natural, junto do índice do ++/// arquivo pedido. É o que faz o clique no gerenciador de arquivos abrir ++/// a coleção inteira em vez de uma imagem sozinha: setas, `Home`/`End`, ++/// film strip e slideshow já operam sobre `files`, então basta entregar a ++/// lista certa. ++/// ++/// Com dois ou mais arquivos na entrada nada muda: quem passou uma seleção ++/// explícita na linha de comando quer exatamente aquela seleção. ++/// ++/// Qualquer tropeço na varredura (pasta ilegível, arquivo pedido fora da ++/// lista de formatos) cai de volta na entrada original. Abrir a imagem que ++/// o usuário pediu importa mais do que oferecer navegação. ++fn expand_to_folder(files: Vec) -> (Vec, usize) { ++ if files.len() != 1 { ++ return (files, 0); ++ } ++ ++ let target = &files[0]; ++ if !target.is_file() { ++ return (files, 0); ++ } ++ ++ // `parent()` de um caminho sem diretório ("foto.png") devolve "", que ++ // não é legível por read_dir; nesse caso a pasta é a atual. ++ let dir = match target.parent() { ++ Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(), ++ Some(_) => PathBuf::from("."), ++ None => return (files, 0), ++ }; ++ ++ let Ok(entries) = std::fs::read_dir(&dir) else { ++ return (files, 0); ++ }; ++ ++ let mut siblings: Vec = entries ++ .filter_map(|entry| entry.ok()) ++ .map(|entry| entry.path()) ++ .filter(|path| path.is_file() && is_viewable(path)) ++ .collect(); ++ ++ if siblings.len() < 2 { ++ return (files, 0); ++ } ++ ++ siblings.sort_by(|a, b| { ++ let an = a.file_name().unwrap_or_default().to_string_lossy().into_owned(); ++ let bn = b.file_name().unwrap_or_default().to_string_lossy().into_owned(); ++ // Desempate pelo nome cru para a ordem não depender da ordem de ++ // entrega do read_dir quando dois nomes só diferem em maiúsculas. ++ natural_cmp(&an, &bn).then_with(|| an.cmp(&bn)) ++ }); ++ ++ // O gerenciador de arquivos pode entregar symlink ou caminho relativo ++ // diferente do que o read_dir devolve, então o caminho canônico decide. ++ let canonical_target = target.canonicalize().ok(); ++ let found = siblings.iter().position(|path| { ++ path == target ++ || (canonical_target.is_some() && path.canonicalize().ok() == canonical_target) ++ }); ++ ++ match found { ++ Some(idx) => (siblings, idx), ++ None => (files, 0), ++ } ++} ++ ++/// `true` quando a extensão do arquivo é um formato que o BigIris abre. ++/// Usa a mesma tabela do resto do programa ([`bigimage_core::Format`]), ++/// para a varredura da pasta não divergir do que o visualizador aceita. ++/// ++/// A checagem é por extensão de propósito: farejar o conteúdo de cada ++/// arquivo custaria uma leitura por item da pasta só para montar a lista. ++/// Um arquivo com extensão mentirosa continua abrindo normalmente quando é ++/// ele o alvo do clique, porque aí quem decodifica é ++/// [`bigimage_core::open_image`], que fareja o conteúdo. ++fn is_viewable(path: &Path) -> bool { ++ path.extension() ++ .and_then(|ext| ext.to_str()) ++ .is_some_and(|ext| bigimage_core::Format::parse(ext).is_ok()) ++} ++ ++/// Compara nomes tratando sequências de dígitos como número, e não como ++/// texto. É o que põe `2.png` antes de `10.png`; a ordem lexicográfica pura ++/// devolveria `1, 10, 100, 11, …`, que é justamente o padrão das coleções ++/// numeradas de wallpaper e das fotos saídas de câmera (`IMG_2.jpg`). ++/// ++/// Entre dois números de mesmo valor, vence o que tem menos zeros à ++/// esquerda, então `7` vem antes de `007` e a ordem fica estável. ++fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering { ++ use std::cmp::Ordering; ++ ++ let a: Vec = a.chars().collect(); ++ let b: Vec = b.chars().collect(); ++ let (mut i, mut j) = (0usize, 0usize); ++ ++ while i < a.len() && j < b.len() { ++ if a[i].is_ascii_digit() && b[j].is_ascii_digit() { ++ let start_a = i; ++ while i < a.len() && a[i].is_ascii_digit() { ++ i += 1; ++ } ++ let start_b = j; ++ while j < b.len() && b[j].is_ascii_digit() { ++ j += 1; ++ } ++ ++ let num_a: String = a[start_a..i].iter().collect(); ++ let num_b: String = b[start_b..j].iter().collect(); ++ let trim_a = num_a.trim_start_matches('0'); ++ let trim_b = num_b.trim_start_matches('0'); ++ ++ // Sem zeros à esquerda, mais dígitos significa número maior. ++ let ord = trim_a.len().cmp(&trim_b.len()).then_with(|| trim_a.cmp(trim_b)); ++ if ord != Ordering::Equal { ++ return ord; ++ } ++ let ord = num_a.len().cmp(&num_b.len()); ++ if ord != Ordering::Equal { ++ return ord; ++ } ++ } else { ++ let ca = a[i].to_lowercase().next().unwrap_or(a[i]); ++ let cb = b[j].to_lowercase().next().unwrap_or(b[j]); ++ let ord = ca.cmp(&cb); ++ if ord != Ordering::Equal { ++ return ord; ++ } ++ i += 1; ++ j += 1; ++ } ++ } ++ ++ (a.len() - i).cmp(&(b.len() - j)) ++} ++ + /// Mutable viewer state shared between the window and its controllers. + /// + /// The UI is edge-triggered: cursor near the top reveals the header, +@@ -179,7 +319,11 @@ + } + } + +-fn build_window(app: &adw::Application, files: Rc>) -> adw::ApplicationWindow { ++fn build_window( ++ app: &adw::Application, ++ files: Rc>, ++ start_idx: usize, ++) -> adw::ApplicationWindow { + let window = adw::ApplicationWindow::builder() + .application(app) + .default_width(1024) +@@ -315,7 +459,7 @@ + + let state = Rc::new(RefCell::new(ViewerState { + files: files.clone(), +- idx: 0, ++ idx: start_idx.min(files.len().saturating_sub(1)), + picture: picture.clone(), + scroller: scroller.clone(), + title: title.clone(), +@@ -692,6 +836,16 @@ + } + s.strip_revealer.set_reveal_child(true); + } ++ // A faixa nasce rolada no começo. Como o visualizador agora pode abrir ++ // no meio da pasta (clique num arquivo qualquer), a miniatura ativa ++ // ficaria fora de vista na primeira vez que o strip aparece. Recentrar ++ // fica para o idle seguinte porque, enquanto o revealer não alocou a ++ // faixa, `compute_bounds` devolve largura zero e a conta de centragem ++ // não teria em que se apoiar. ++ { ++ let clone = state.clone(); ++ glib::idle_add_local_once(move || refresh_film_strip_active(&clone)); ++ } + cancel_bottom_hide(state); + if state.borrow().ui_pinned { + return; +@@ -6360,3 +6514,96 @@ + assert_eq!(p, PathBuf::from("solo_nobg.png")); + } + } ++ ++#[cfg(test)] ++mod folder_nav_tests { ++ use super::{expand_to_folder, is_viewable, natural_cmp}; ++ use std::cmp::Ordering; ++ use std::path::{Path, PathBuf}; ++ ++ fn touch(dir: &Path, name: &str) -> PathBuf { ++ let path = dir.join(name); ++ std::fs::write(&path, b"").expect("escrever arquivo de teste"); ++ path ++ } ++ ++ #[test] ++ fn digits_compare_as_numbers_not_text() { ++ assert_eq!(natural_cmp("2.png", "10.png"), Ordering::Less); ++ assert_eq!(natural_cmp("10.png", "2.png"), Ordering::Greater); ++ assert_eq!(natural_cmp("100.png", "99.png"), Ordering::Greater); ++ } ++ ++ #[test] ++ fn equal_values_order_by_leading_zeros() { ++ assert_eq!(natural_cmp("7.png", "007.png"), Ordering::Less); ++ assert_eq!(natural_cmp("7.png", "7.png"), Ordering::Equal); ++ } ++ ++ #[test] ++ fn text_compares_case_insensitively() { ++ assert_eq!(natural_cmp("alpha.png", "Beta.png"), Ordering::Less); ++ assert_eq!(natural_cmp("IMG_2.jpg", "img_10.jpg"), Ordering::Less); ++ } ++ ++ #[test] ++ fn viewable_follows_the_core_format_table() { ++ assert!(is_viewable(Path::new("a.png"))); ++ assert!(is_viewable(Path::new("a.JPEG"))); ++ assert!(is_viewable(Path::new("a.avif"))); ++ assert!(!is_viewable(Path::new("a.txt"))); ++ assert!(!is_viewable(Path::new("sem-extensao"))); ++ } ++ ++ #[test] ++ fn single_file_expands_to_its_folder_in_natural_order() { ++ let dir = tempfile::tempdir().expect("tempdir"); ++ for name in ["10.png", "2.png", "1.png", "notas.txt"] { ++ touch(dir.path(), name); ++ } ++ let clicked = dir.path().join("2.png"); ++ ++ let (files, idx) = expand_to_folder(vec![clicked.clone()]); ++ ++ let names: Vec = ++ files.iter().map(|p| p.file_name().unwrap().to_string_lossy().into_owned()).collect(); ++ assert_eq!(names, vec!["1.png", "2.png", "10.png"], "o .txt fica de fora"); ++ assert_eq!(idx, 1, "abre posicionado no arquivo clicado"); ++ } ++ ++ #[test] ++ fn explicit_selection_is_left_alone() { ++ let dir = tempfile::tempdir().expect("tempdir"); ++ let a = touch(dir.path(), "1.png"); ++ let b = touch(dir.path(), "2.png"); ++ touch(dir.path(), "3.png"); ++ ++ let (files, idx) = expand_to_folder(vec![a.clone(), b.clone()]); ++ ++ assert_eq!(files, vec![a, b], "seleção da linha de comando é respeitada"); ++ assert_eq!(idx, 0); ++ } ++ ++ #[test] ++ fn lone_image_in_folder_stays_alone() { ++ let dir = tempfile::tempdir().expect("tempdir"); ++ let only = touch(dir.path(), "unica.png"); ++ touch(dir.path(), "leia-me.txt"); ++ ++ let (files, idx) = expand_to_folder(vec![only.clone()]); ++ ++ assert_eq!(files, vec![only]); ++ assert_eq!(idx, 0); ++ } ++ ++ #[test] ++ fn missing_file_falls_back_to_the_input() { ++ let dir = tempfile::tempdir().expect("tempdir"); ++ let ghost = dir.path().join("nao-existe.png"); ++ ++ let (files, idx) = expand_to_folder(vec![ghost.clone()]); ++ ++ assert_eq!(files, vec![ghost]); ++ assert_eq!(idx, 0); ++ } ++} diff --git a/packaging/openmandriva/README.md b/packaging/openmandriva/README.md new file mode 100644 index 0000000..e30d945 --- /dev/null +++ b/packaging/openmandriva/README.md @@ -0,0 +1,17 @@ +# OpenMandriva / ABF packaging + +The spec builds the full GTK/AI application offline. Before submitting a new +version to ABF: + +1. Create and push the matching Git tag, for example `v0.1.0`. +2. Run `bash packaging/openmandriva/make-vendor-tarball.sh` with the same Rust + toolchain used for `Cargo.lock`. +3. Upload the generated `bigiris--vendor.tar.xz` as `Source1` in ABF. +4. Add the GitHub tag archive as `Source0`, then build the spec for Cooker. + +The vendor archive contains only third-party Cargo sources plus Cargo's source +replacement configuration. It should not be committed to Git. + +For local validation, use `rpmbuild -ba packaging/openmandriva/bigiris.spec` +with both source archives in `%{_sourcedir}`. The `%check` section runs the +workspace tests, CLI self-test, desktop entry, AppStream and GSettings checks. diff --git a/packaging/openmandriva/bigiris.spec b/packaging/openmandriva/bigiris.spec new file mode 100644 index 0000000..6b1a705 --- /dev/null +++ b/packaging/openmandriva/bigiris.spec @@ -0,0 +1,99 @@ +Name: bigiris +Version: 0.1.0 +Release: 1 +Summary: Fast image viewer and batch converter for Linux +License: GPL-3.0-or-later +URL: https://github.com/xathay/bigiris +Source0: %{url}/archive/refs/tags/v%{version}.tar.gz +# Generated with packaging/openmandriva/make-vendor-tarball.sh. Keeping +# crates vendored makes ABF builds reproducible and independent of crates.io. +Source1: %{name}-%{version}-vendor.tar.xz + +BuildRequires: cargo >= 1.92 +BuildRequires: rust >= 1.92 +BuildRequires: gettext +BuildRequires: desktop-file-utils +BuildRequires: appstream +BuildRequires: pkgconfig(gtk4) >= 4.12 +BuildRequires: pkgconfig(libadwaita-1) >= 1.5 +BuildRequires: pkgconfig(dav1d) +BuildRequires: pkgconfig(openssl) +BuildRequires: onnxruntime-devel +Requires: bubblewrap +Requires: util-linux +Requires: gtk4.0 +Requires: lib64adwaita +Requires: onnxruntime +Requires: hicolor-icon-theme + +%description +BigIris combines a compact, responsive image viewer with Prisma, an optional +workspace for conversions, resize, crop, colour adjustment, animation and +parallel folder processing. It includes native Dolphin ServiceMenus and a CLI. + +%prep +%autosetup -p1 -a1 + +%build +export CARGO_HOME="%{_builddir}/cargo-home" +export CARGO_TARGET_DIR="%{_builddir}/target" +export CARGO_NET_OFFLINE=true +export RUSTFLAGS="-C debuginfo=2" +cargo build --locked --offline --release --features "gui ai" --package bigiris + +while read -r lang; do + [ -z "$lang" ] && continue + case "$lang" in \#*) continue ;; esac + msgfmt -o "data/po/${lang}.mo" "data/po/${lang}.po" +done < data/po/LINGUAS + +%check +export CARGO_HOME="%{_builddir}/cargo-home" +export CARGO_TARGET_DIR="%{_builddir}/target" +export CARGO_NET_OFFLINE=true +cargo test --locked --offline --release --workspace --all-features +"%{_builddir}/target/release/bigiris" --self-test +desktop-file-validate data/com.biglinux.Iris.desktop +appstreamcli validate --no-net data/com.biglinux.Iris.metainfo.xml +glib-compile-schemas --strict --dry-run data + +%install +install -Dm755 "%{_builddir}/target/release/bigiris" \ + "%{buildroot}%{_bindir}/bigiris" +install -Dm644 data/com.biglinux.Iris.desktop \ + "%{buildroot}%{_datadir}/applications/com.biglinux.Iris.desktop" +install -Dm644 data/com.biglinux.Iris.metainfo.xml \ + "%{buildroot}%{_datadir}/metainfo/com.biglinux.Iris.metainfo.xml" +install -Dm644 data/com.biglinux.Iris.gschema.xml \ + "%{buildroot}%{_datadir}/glib-2.0/schemas/com.biglinux.Iris.gschema.xml" +install -Dm644 data/icons/hicolor/scalable/apps/com.biglinux.Iris.svg \ + "%{buildroot}%{_datadir}/icons/hicolor/scalable/apps/com.biglinux.Iris.svg" +while read -r lang; do + [ -z "$lang" ] && continue + case "$lang" in \#*) continue ;; esac + install -Dm644 "data/po/${lang}.mo" \ + "%{buildroot}%{_datadir}/locale/${lang}/LC_MESSAGES/bigiris.mo" +done < data/po/LINGUAS + +"%{_builddir}/target/release/bigiris" install-integrations --system \ + --destdir="%{buildroot}" + +%files +%license LICENSE +%doc README.md +%{_bindir}/bigiris +%{_datadir}/applications/com.biglinux.Iris.desktop +%{_datadir}/metainfo/com.biglinux.Iris.metainfo.xml +%{_datadir}/glib-2.0/schemas/com.biglinux.Iris.gschema.xml +%{_datadir}/icons/hicolor/scalable/apps/com.biglinux.Iris.svg +%{_datadir}/locale/*/LC_MESSAGES/bigiris.mo +%{_datadir}/kio/servicemenus/bigiris-*.desktop +%{_datadir}/nemo/actions/bigiris-*.nemo_action +%{_datadir}/file-manager/actions/bigiris-*.desktop +%{_datadir}/contractor/bigiris-*.contract +%{_datadir}/nautilus-python/extensions/bigiris-menu.py +%config(noreplace) %{_sysconfdir}/xdg/Thunar/uca.xml + +%changelog +* Sat Sep 12 2026 Leonardo Athayde 0.1.0-1 +- Initial OpenMandriva package diff --git a/packaging/openmandriva/make-vendor-tarball.sh b/packaging/openmandriva/make-vendor-tarball.sh new file mode 100644 index 0000000..25a6879 --- /dev/null +++ b/packaging/openmandriva/make-vendor-tarball.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Build the deterministic Rust dependency source used by ABF Source1. +set -euo pipefail + +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +package_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' "$project_root/Cargo.toml" | head -n1)" +[ -n "$package_version" ] || { echo "Could not read workspace version" >&2; exit 1; } + +vendor_tmp="$(mktemp -d -t bigiris-vendor.XXXXXX)" +trap 'rm -rf -- "$vendor_tmp"' EXIT +mkdir -p "$vendor_tmp/.cargo" + +( cd "$project_root" && cargo vendor --locked "$vendor_tmp/vendor" ) \ + > "$vendor_tmp/.cargo/config.toml" + +archive="$project_root/packaging/openmandriva/bigiris-${package_version}-vendor.tar.xz" +tar --sort=name --mtime='UTC 1970-01-01' --owner=0 --group=0 --numeric-owner \ + -C "$vendor_tmp" -cJf "$archive" .cargo vendor +sha256sum "$archive" + diff --git a/pkgbuild/PKGBUILD b/pkgbuild/PKGBUILD index a82a426..fee60b9 100644 --- a/pkgbuild/PKGBUILD +++ b/pkgbuild/PKGBUILD @@ -3,25 +3,24 @@ pkgname=bigiris pkgver=0.1.0 -pkgrel=1 +pkgrel=3 arch=('x86_64') license=('GPL-3.0-or-later') url="https://github.com/xathay/bigiris" pkgdesc="Visualizador e conversor de imagens moderno, com integração ao gerenciador de arquivos" depends=( + 'bubblewrap' + 'util-linux' 'gcc-libs' 'glibc' - 'gtk4' - 'libadwaita' + 'gtk4>=4.12' + 'libadwaita>=1.5' 'dav1d' # decodificador AVIF 'openssl' # TLS para download de modelos IA (ureq + native-tls) 'onnxruntime' # IA (remove-bg via BiRefNet); embutida no binario 'hicolor-icon-theme' ) optdepends=( - 'libvips: backend de batch para milhares de arquivos' - 'libheif: suporte a HEIC/HEIF (Tier 2, quando habilitado)' - 'libjxl: suporte a JPEG XL (Tier 2, quando habilitado)' 'nautilus-python: integração top-level no Nautilus' 'dolphin: integração no gerenciador Dolphin (KDE)' 'nautilus: integração no gerenciador Nautilus (GNOME)' @@ -70,7 +69,7 @@ check() { cd "$pkgname" export RUSTUP_TOOLCHAIN=stable export CARGO_TARGET_DIR="$srcdir/target" - cargo test --locked --release --workspace + cargo test --locked --release --workspace --all-features --all-targets "$CARGO_TARGET_DIR/release/bigiris" --self-test } diff --git a/pkgbuild/PKGBUILD.local b/pkgbuild/PKGBUILD.local index 6e94480..204283b 100644 --- a/pkgbuild/PKGBUILD.local +++ b/pkgbuild/PKGBUILD.local @@ -14,25 +14,24 @@ pkgname=bigiris pkgver=0.1.0.local -pkgrel=1 +pkgrel=3 arch=('x86_64') license=('GPL-3.0-or-later') url="https://github.com/xathay/bigiris" pkgdesc="Visualizador e conversor de imagens moderno (build local para testes)" depends=( + 'bubblewrap' + 'util-linux' 'gcc-libs' 'glibc' - 'gtk4' - 'libadwaita' + 'gtk4>=4.12' + 'libadwaita>=1.5' 'dav1d' 'openssl' 'hicolor-icon-theme' ) optdepends=( - 'libvips: backend de batch para milhares de arquivos' - 'onnxruntime: IA local — remover fundo (BiRefNet), upscale (Real-ESRGAN)' - 'libheif: suporte a HEIC/HEIF (Tier 2, quando habilitado)' - 'libjxl: suporte a JPEG XL (Tier 2, quando habilitado)' + 'onnxruntime: IA local — remover fundo (BiRefNet)' 'nautilus-python: integração top-level no Nautilus' 'dolphin: integração no gerenciador Dolphin (KDE)' 'nautilus: integração no gerenciador Nautilus (GNOME)' @@ -65,6 +64,9 @@ prepare() { cd "$src" tar --exclude='./target' \ --exclude='./.git' \ + --exclude='./.claude*' \ + --exclude='./.codex' \ + --exclude='./.agents' \ --exclude='./pkgbuild/bigiris' \ --exclude='./pkgbuild/pkg' \ --exclude='./pkgbuild/src' \ @@ -93,7 +95,7 @@ check() { cd "$srcdir/bigiris" export RUSTUP_TOOLCHAIN=stable export CARGO_TARGET_DIR="$srcdir/target" - cargo test --locked --release --workspace + cargo test --locked --release --workspace --all-features --all-targets "$CARGO_TARGET_DIR/release/bigiris" --self-test } diff --git a/scripts/ai_smoke.py b/scripts/ai_smoke.py new file mode 100644 index 0000000..a3a2e14 --- /dev/null +++ b/scripts/ai_smoke.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Exercise real ONNX inference using synthetic RGBA images and isolated data.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import resource +import struct +import subprocess +import time +import zlib + +ROOT = Path(__file__).resolve().parents[1] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--binary', type=Path, default=ROOT / 'target/release/bigiris') + parser.add_argument('--output', type=Path, default=ROOT / 'target/quality/round2/ai-release') + parser.add_argument('--allow-download', action='store_true') + args = parser.parse_args() + binary = args.binary.resolve() + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=True) + cache = ROOT / 'target/quality/ai' + if not args.allow_download and not any(cache.glob('*/models/*.onnx')): + parser.error('isolated model cache missing; explicitly use --allow-download') + env = dict(os.environ, XDG_DATA_HOME=str(cache)) + originals = [] + + def chunk(kind, data): + return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data)) + + for index, (width, height) in enumerate([(32, 24), (24, 32)]): + pixels = bytes(component for y in range(height) for x in range(width) + for component in (x * 7 % 256, y * 9 % 256, 120, 0 if x == 0 else 123)) + rows = b''.join(b'\0' + pixels[y*width*4:(y+1)*width*4] for y in range(height)) + path = output / f'synthetic-{index}.png' + path.write_bytes(b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>2I5B', width, height, 8, 6, 0, 0, 0)) + + chunk(b'IDAT', zlib.compress(rows)) + chunk(b'IEND', b'')) + originals.append((path, width, height, pixels)) + report = dict(passed=False, binary_sha256=hashlib.sha256(binary.read_bytes()).hexdigest(), + images=len(originals), model_cache=str(cache)) + started = time.monotonic() + try: + result = subprocess.run([str(binary), 'remove-bg', *[str(item[0]) for item in originals]], + env=env, capture_output=True, timeout=300) + (output / 'inference.log').write_bytes(result.stdout + result.stderr) + report.update(exit_code=result.returncode, seconds=round(time.monotonic()-started, 3), + max_rss_kib=resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss) + if result.returncode: + raise RuntimeError('inference failed; see inference.log') + alpha_ranges = [] + for path, width, height, source in originals: + destination = path.with_name(path.stem + '_nobg.png') + # Trusted synthetic output only: inspect raw pixels without GTK. + raw = subprocess.check_output([str(binary), '--codec-worker', 'pixels', str(destination)], timeout=30) + assert raw[:8] == b'BIGIRIS2' and raw[8] == 4 + assert struct.unpack('I',len(data))+kind+data+struct.pack('>I',zlib.crc32(kind+data)) +source=out/'fixture.png' +source.write_bytes(b'\x89PNG\r\n\x1a\n'+chunk(b'IHDR',struct.pack('>2I5B',16,16,8,2,0,0,0))+chunk(b'IDAT',zlib.compress((b'\0'+bytes([30,70,120])*16)*16))+chunk(b'IEND',b'')) +checks=[] +def run(name,args): + start=time.monotonic();p=subprocess.run([str(binary),*map(str,args)],capture_output=True,timeout=90) + checks.append({'name':name,'exit_code':p.returncode,'seconds':round(time.monotonic()-start,3)}) + if p.returncode: raise RuntimeError(p.stderr.decode(errors='replace')) + return p.stdout +run('self-test',['--self-test']) +for f in ['png','jpeg','webp','avif','tiff','bmp','gif','ico','pnm','tga','qoi','hdr','exr']: + run('encode-'+f,['convert','--to',f,'--overwrite','replace',source]) + ext={'jpeg':'jpg','tiff':'tiff','pnm':'ppm'}.get(f,f) + path=out/('fixture.'+ext) + assert path.is_file(), (f,path) + run('resize-'+f,['resize','--max-edge','8','--overwrite','replace',path]) + resized=out/('fixture_8.'+ext) + assert resized.is_file(), (f,resized) + dimensions=struct.unpack('