diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bec29abaf5..f2c7a2207c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -39,6 +39,7 @@ jobs:
# platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile.
# macOS is tested only by release.yml.
platforms:
+ timeout-minutes: 10
name: select platforms
runs-on: ubuntu-latest
outputs:
@@ -65,7 +66,7 @@ jobs:
# The browser build (a fat-LTO wasm build) runs only when something it compiles or
# packages changed: the web app and its packaging, crate sources and manifests (the web
# build compiles nearly every crate), the workspace manifests and this workflow.
- if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/photocraft-web/|packaging/web/|packaging/env\.sh$|crates/[^/]+/(src/|Cargo\.toml$|build\.rs$)|Cargo\.(toml|lock)$|rust-toolchain(\.toml)?$|\.github/workflows/ci\.yml$)'; then
+ if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/photocraft-web(-boot)?/|packaging/web/|packaging/env\.sh$|crates/[^/]+/(src/|Cargo\.toml$|build\.rs$)|Cargo\.(toml|lock)$|rust-toolchain(\.toml)?$|\.github/workflows/ci\.yml$)'; then
echo "web=true" >> "$GITHUB_OUTPUT"
else
echo "web=false" >> "$GITHUB_OUTPUT"
@@ -77,6 +78,7 @@ jobs:
fi
test:
+ timeout-minutes: 100
name: test (${{ matrix.os }})
needs: platforms
runs-on: ${{ matrix.os }}
@@ -111,10 +113,24 @@ jobs:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
+ # The cheap checks run first: a format, lint, layering or stale-scorecard failure is red
+ # after a few minutes instead of after the test passes (#6119). Clippy restores main's cache.
- name: Format
if: runner.os == 'Linux'
run: cargo fmt --all -- --check
+ - name: Clippy
+ run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
+
+ - name: Dependency layering
+ run: cargo xtask layers
+
+ # docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors,
+ # prefs audit). Static: it reads committed files only, no benchmarks run here.
+ - name: Scorecard up to date
+ if: runner.os == 'Linux'
+ run: cargo xtask scorecard --check
+
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
- name: Test
run: cargo test --workspace --locked --features heif
@@ -145,19 +161,8 @@ jobs:
CRAFT_FONTS_REQUIRED: "1"
run: cargo test --workspace --locked --features heif
- - name: Clippy
- run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
-
- - name: Dependency layering
- run: cargo xtask layers
-
- # docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors,
- # prefs audit). Static: it reads committed files only, no benchmarks run here.
- - name: Scorecard up to date
- if: runner.os == 'Linux'
- run: cargo xtask scorecard --check
-
corpus:
+ timeout-minutes: 30
# Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are
# pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache
# restore and a download; a missing corpus fails the tests. The cache key changes only when a
@@ -190,6 +195,7 @@ jobs:
run: cargo xtask test-corpus
wasm:
+ timeout-minutes: 30
name: wasm32 check
runs-on: ubuntu-latest
steps:
@@ -206,6 +212,7 @@ jobs:
run: cargo xtask wasm
web:
+ timeout-minutes: 60
name: web build and wasm size gate
# PRs run it only when web-relevant files change (see `platforms`); main always does.
needs: platforms
@@ -229,8 +236,8 @@ jobs:
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xz -C "$HOME/.cargo/bin" trunk
trunk --version
- # package.sh runs Trunk's release build, validates relative URLs, checks the 24 MiB
- # wasm limit and exercises the same packaging step used by release.yml.
+ # package.sh runs Trunk's release builds, validates relative URLs, cuts the wasm into
+ # parts, checks the 24 MiB per-file limit and exercises the packaging step release.yml uses.
- name: Build and verify browser release
run: packaging/web/package.sh
- name: Report wasm size
@@ -241,15 +248,19 @@ jobs:
echo
echo "| File | Bytes | MiB | Limit |"
echo "| --- | ---: | ---: | ---: |"
+ row() { echo "| $1 | $2 | $(awk -v n="$2" 'BEGIN { printf "%.2f", n / 1048576 }') | $3 |"; }
+ # The app's wasm as built; it ships gzipped in parts, so only the download grows with it.
for wasm in dist/web/*.wasm; do
[ -f "$wasm" ] || continue
- bytes=$(wc -c < "$wasm" | tr -d ' ')
- mib=$(awk -v n="$bytes" 'BEGIN { printf "%.2f", n / 1048576 }')
- echo "| $(basename "$wasm") | $bytes | $mib | 24 MiB |"
+ row "$(basename "$wasm")" "$(wc -c < "$wasm" | tr -d ' ')" "none"
done
+ # What a host sees: the largest file of the packaged site.
+ largest=$(find target/web-package -type f -printf '%s %f\n' | sort -n | tail -1)
+ row "largest file in the site: ${largest#* }" "${largest%% *}" "24 MiB"
} >> "$GITHUB_STEP_SUMMARY"
fuzz:
+ timeout-minutes: 60
name: fuzz (${{ matrix.target.crate }}/${{ matrix.target.name }})
# Placeholder: manual trigger only until a nightly schedule is enabled.
if: github.event_name == 'workflow_dispatch' && inputs.fuzz
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 1d6c4c1f56..67e6b31894 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -27,6 +27,7 @@ concurrency:
jobs:
build:
+ timeout-minutes: 15
name: mdBook build
runs-on: ubuntu-latest
steps:
diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml
index 272715dc81..1d110e28f2 100644
--- a/.github/workflows/packaging-lint.yml
+++ b/.github/workflows/packaging-lint.yml
@@ -23,6 +23,7 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
verified-appimage-inputs:
+ timeout-minutes: 10
name: Verified AppImage inputs
runs-on: ubuntu-latest
steps:
diff --git a/AGENTS.md b/AGENTS.md
index 6adc5923db..97b5ccdbc0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -39,6 +39,7 @@ apps/
photocraft desktop app (eframe/wgpu), TCP control server
photocraft-cli headless CLI (convert/info/run/batch/commands/mcp)
photocraft-web the same app in the browser (trunk + wasm-bindgen)
+ photocraft-web-boot starts the packaged browser app from compressed parts of its wasm (no file over a host's size cap)
xtask/ cargo xtask layers | wasm | ci | stats | corpus | test-corpus | parity | perf | scorecard
```
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 2b56055a26..dc2ad10e6c 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -115,6 +115,6 @@ next to the files:
| `corpus/exr/` (5 files, 2.3 MB) | The deep OpenEXR test images `11`, `42`, `64`, `multivariate` and `objectid.deep.exr` | Contributors to the OpenEXR Project | [openexr `src/test/bin/test_images`](https://github.com/AcademySoftwareFoundation/openexr/tree/main/src/test/bin/test_images) | BSD-3-Clause |
| `corpus/affinity/` (60 files) | Public Affinity documents and PNG references: `vector-art/` (9 documents), `afdesignload/` (9), `jac21/` (2), `asset-store-template/` (1), and `affinity-samples/` (18 CC0 documents plus exported references) | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo); satoshoe-dev | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate), [affinity-samples](https://github.com/satoshoe-dev/affinity-samples/tree/96aca6bd86bbe7a677f1cf0630bd832d23943be8) | CC0-1.0 (vector-art and affinity-samples); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI |
| `corpus/pngsuite/` | PngSuite | Willem van Schaik | | Public domain |
-| `corpus/pixls/` (7 files, 226 MB) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
+| `corpus/pixls/` (11 files) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF and Z6 paired 12/14-bit lossy and packed NEFs, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF), [DSC_0750.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0750.NEF), [DSC_0751.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0751.NEF), [DSC_0753.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0753.NEF), [DSC_0754.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0754.NEF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0.
diff --git a/Cargo.lock b/Cargo.lock
index 54085cf447..138ca3d814 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5073,6 +5073,17 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "photocraft-web-boot"
+version = "0.9.0"
+dependencies = [
+ "flate2",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
[[package]]
name = "pico-args"
version = "0.5.0"
@@ -6102,9 +6113,9 @@ dependencies = [
[[package]]
name = "ruzstd"
-version = "0.9.0"
+version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
+checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
dependencies = [
"twox-hash",
]
diff --git a/apps/photocraft-web-boot/Cargo.toml b/apps/photocraft-web-boot/Cargo.toml
new file mode 100644
index 0000000000..dd12341af2
--- /dev/null
+++ b/apps/photocraft-web-boot/Cargo.toml
@@ -0,0 +1,24 @@
+[package]
+name = "photocraft-web-boot"
+description = "Starts Photocraft in the browser from compressed parts of its WebAssembly"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+publish = false
+
+[lints]
+workspace = true
+
+[[bin]]
+name = "photocraft-web-boot"
+path = "src/main.rs"
+
+[dependencies]
+# Inflates the parts in browsers without `DecompressionStream`.
+flate2 = { workspace = true }
+
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+wasm-bindgen = "0.2"
+wasm-bindgen-futures = "0.4"
+js-sys = "0.3"
+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "WritableStream", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] }
diff --git a/apps/photocraft-web-boot/Trunk.toml b/apps/photocraft-web-boot/Trunk.toml
new file mode 100644
index 0000000000..56192a9921
--- /dev/null
+++ b/apps/photocraft-web-boot/Trunk.toml
@@ -0,0 +1,6 @@
+# Built by packaging/web/package.sh: trunk build --release (from apps/photocraft-web-boot)
+[build]
+target = "index.html"
+dist = "../../dist/web-boot"
+public_url = "./"
+filehash = true
diff --git a/apps/photocraft-web-boot/index.html b/apps/photocraft-web-boot/index.html
new file mode 100644
index 0000000000..8ed9a6961e
--- /dev/null
+++ b/apps/photocraft-web-boot/index.html
@@ -0,0 +1,30 @@
+
+
+
+
+
+ PhotoCraft
+
+
+
+
+
+
+
Loading PhotoCraft…
+
This is taking longer than usual. If PhotoCraft doesn't start, its files may have failed to
+ download or come from a different version of this page. Reload to try again; the browser
+ console lists what failed.
+
+
+
diff --git a/apps/photocraft-web-boot/src/main.rs b/apps/photocraft-web-boot/src/main.rs
new file mode 100644
index 0000000000..56c2bf1517
--- /dev/null
+++ b/apps/photocraft-web-boot/src/main.rs
@@ -0,0 +1,356 @@
+//! Starts Photocraft in the browser from compressed parts of its WebAssembly.
+//!
+//! Static hosts cap the size of one file (Cloudflare Pages: 25 MiB), and the app's wasm is at
+//! that size. `packaging/web/package.sh` gzips it and cuts it into parts listed in
+//! `app/parts.txt`. This small module is what the packaged page loads: it fetches the parts in
+//! order, joins and inflates them as they arrive, and hands the stream to the app's own generated
+//! loader, so the browser still compiles while downloading. No file on the host is larger than
+//! one part, however large the app grows.
+//!
+//! The browser inflates the parts (`DecompressionStream`: Safari 16.4, Firefox 113, Chrome 80).
+//! In one that can't, this module inflates them itself, a little slower.
+//!
+//! A download that fails is tried again, the later tries past the browser's cache (a bad copy
+//! there would otherwise fail forever). When every try fails the page says so and offers a reload.
+//!
+//! `trunk build` in `apps/photocraft-web` still gives the app as one `.wasm`, for development and
+//! for hosts without a per-file cap; only the packaged site starts through this module.
+
+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+
+/// First line of `parts.txt`. Some hosts answer a missing file with the home page and HTTP 200;
+/// without this an HTML page would be read as a list of parts.
+#[cfg(any(target_arch = "wasm32", test))]
+const MARKER: &str = "photocraft-parts 1";
+
+/// The part names in a `parts.txt`, in order, or `None` when the text isn't one. A name is a
+/// file next to `parts.txt`: anything that could point elsewhere is refused.
+#[cfg(any(target_arch = "wasm32", test))]
+fn parts(list: &str) -> Option> {
+ let mut lines = list.lines().map(str::trim).filter(|l| !l.is_empty());
+ if lines.next() != Some(MARKER) {
+ return None;
+ }
+ let parts: Vec = lines.map(str::to_owned).collect();
+ let plain = |p: &String| !p.starts_with('.') && !p.contains(['/', '\\', '?', '#', ':', '%']);
+ (!parts.is_empty() && parts.iter().all(plain)).then_some(parts)
+}
+
+/// Gzip inflated a chunk at a time, for browsers that can't do it themselves.
+#[cfg(any(target_arch = "wasm32", test))]
+struct Inflater(flate2::write::GzDecoder>);
+
+#[cfg(any(target_arch = "wasm32", test))]
+impl Inflater {
+ fn new() -> Self {
+ Inflater(flate2::write::GzDecoder::new(Vec::new()))
+ }
+
+ /// What the next `chunk` of the gzip stream inflates to (often nothing: the decoder holds on
+ /// to input until it has a whole block).
+ fn push(&mut self, chunk: &[u8]) -> std::io::Result> {
+ std::io::Write::write_all(&mut self.0, chunk)?;
+ Ok(std::mem::take(self.0.get_mut()))
+ }
+
+ /// The rest, once the stream has ended. An error when it ended early or its checksum is
+ /// wrong, the same as the browser's inflater gives.
+ fn finish(&mut self) -> std::io::Result> {
+ self.0.try_finish()?;
+ Ok(std::mem::take(self.0.get_mut()))
+ }
+}
+
+#[cfg(target_arch = "wasm32")]
+mod boot {
+ use std::cell::RefCell;
+ use std::rc::Rc;
+
+ use wasm_bindgen::prelude::*;
+ use wasm_bindgen_futures::{JsFuture, spawn_local};
+ use web_sys::{
+ Headers, ReadableStream, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions, TransformStream,
+ TransformStreamDefaultController, Transformer, WritableStream,
+ };
+
+ use super::Inflater;
+
+ /// Where packaging puts the app, next to this module's own files.
+ const PARTS: &str = "./app/parts.txt";
+ /// Seconds to wait before each further try; one more than this many tries are made.
+ const RETRY_AFTER: [u32; 2] = [1, 3];
+
+ // The app's `wasm-bindgen` loader. Its default export instantiates the module it is given and
+ // runs the app's `main`. The path is fixed at compile time, so packaging gives the file this
+ // name (Trunk's own name for it carries a content hash).
+ #[wasm_bindgen(raw_module = "./app/photocraft-web.js")]
+ extern "C" {
+ #[wasm_bindgen(js_name = default, catch)]
+ async fn start_app(module: JsValue) -> Result;
+ }
+
+ // The browser's own inflater. `web-sys` has it only behind its unstable-API flag, which would
+ // have to be set for the whole build, so the three members used are declared here.
+ #[wasm_bindgen]
+ extern "C" {
+ type DecompressionStream;
+ #[wasm_bindgen(constructor, catch)]
+ fn new(format: &str) -> Result;
+ #[wasm_bindgen(method, getter)]
+ fn readable(this: &DecompressionStream) -> ReadableStream;
+ #[wasm_bindgen(method, getter)]
+ fn writable(this: &DecompressionStream) -> WritableStream;
+ }
+
+ fn window() -> Result {
+ web_sys::window().ok_or_else(|| JsValue::from_str("no window"))
+ }
+
+ /// `fresh` skips the browser's cache.
+ async fn fetch(url: &str, fresh: bool) -> Result {
+ let init = RequestInit::new();
+ if fresh {
+ init.set_cache(RequestCache::Reload);
+ }
+ let response: Response = JsFuture::from(window()?.fetch_with_str_and_init(url, &init)).await?.dyn_into()?;
+ if !response.ok() {
+ return Err(JsValue::from_str(&format!("{url}: HTTP {}", response.status())));
+ }
+ if response.headers().get("Content-Type")?.is_some_and(|t| t.starts_with("text/html")) {
+ return Err(JsValue::from_str(&format!("{url}: the server sent a web page, not the file")));
+ }
+ Ok(response)
+ }
+
+ /// Copies each part into `sink` in order, then closes it.
+ async fn feed(parts: Vec, sink: WritableStream, fresh: bool) -> Result<(), JsValue> {
+ let keep_open = StreamPipeOptions::new();
+ keep_open.set_prevent_close(true);
+ for part in &parts {
+ let body = fetch(&format!("./app/{part}"), fresh).await?.body().ok_or_else(|| JsValue::from_str("part without a body"))?;
+ JsFuture::from(body.pipe_to_with_options(&sink, &keep_open)).await?;
+ }
+ JsFuture::from(sink.close()).await?;
+ Ok(())
+ }
+
+ /// Passes inflated bytes on to the stream's reader.
+ fn emit(inflated: std::io::Result>, out: &TransformStreamDefaultController) -> Result<(), JsValue> {
+ let inflated = inflated.map_err(|e| JsValue::from_str(&format!("the download is damaged ({e})")))?;
+ if inflated.is_empty() { Ok(()) } else { out.enqueue_with_chunk(&js_sys::Uint8Array::from(inflated.as_slice())) }
+ }
+
+ /// A gzip inflater with the shape of `DecompressionStream`, running in this module.
+ fn own_inflater() -> Result {
+ let inflater = Rc::new(RefCell::new(Inflater::new()));
+ let last = inflater.clone();
+ let busy = || JsValue::from_str("the inflater is in use");
+ let transform = Closure:: Result<(), JsValue>>::new(
+ move |chunk: JsValue, out: TransformStreamDefaultController| {
+ let chunk = js_sys::Uint8Array::new(&chunk).to_vec();
+ emit(inflater.try_borrow_mut().map_err(|_| busy())?.push(&chunk), &out)
+ },
+ );
+ let flush = Closure:: Result<(), JsValue>>::new(move |out: TransformStreamDefaultController| {
+ emit(last.try_borrow_mut().map_err(|_| busy())?.finish(), &out)
+ });
+ let transformer = Transformer::new();
+ transformer.set_transform(&transform.into_js_value().unchecked_into());
+ transformer.set_flush(&flush.into_js_value().unchecked_into());
+ TransformStream::new_with_transformer(&transformer)
+ }
+
+ /// `compressed` inflated: by the browser where it can, otherwise here.
+ fn inflated(compressed: &ReadableStream) -> Result {
+ let pair = if js_sys::Reflect::has(&js_sys::global(), &JsValue::from_str("DecompressionStream"))? {
+ let inflater = DecompressionStream::new("gzip")?;
+ ReadableWritablePair::new(&inflater.readable(), &inflater.writable())
+ } else {
+ let inflater = own_inflater()?;
+ ReadableWritablePair::new(&inflater.readable(), &inflater.writable())
+ };
+ Ok(compressed.pipe_through(&pair))
+ }
+
+ async fn run(fresh: bool) -> Result<(), JsValue> {
+ let list = JsFuture::from(fetch(PARTS, fresh).await?.text()?).await?.as_string().unwrap_or_default();
+ let parts = super::parts(&list).ok_or_else(|| JsValue::from_str("app/parts.txt is not a parts list"))?;
+ let joined = TransformStream::new()?;
+ let wasm = inflated(&joined.readable())?;
+ // Why the download stopped: the app's loader only reports that its stream broke.
+ let cause = Rc::new(RefCell::new(None));
+ let failed = cause.clone();
+ let sink = joined.writable();
+ spawn_local(async move {
+ if let Err(e) = feed(parts, sink.clone(), fresh).await {
+ let _ = sink.abort_with_reason(&e);
+ failed.replace(Some(e));
+ }
+ });
+ let headers = Headers::new()?;
+ headers.set("Content-Type", "application/wasm")?;
+ let init = ResponseInit::new();
+ init.set_headers(&headers);
+ let response = Response::new_with_opt_readable_stream_and_init(Some(&wasm), &init)?;
+ let arg = js_sys::Object::new();
+ js_sys::Reflect::set(&arg, &JsValue::from_str("module_or_path"), &response)?;
+ match start_app(arg.into()).await {
+ Ok(_) => Ok(()),
+ Err(e) => Err(cause.take().unwrap_or(e)),
+ }
+ }
+
+ async fn sleep(seconds: u32) {
+ let wait = js_sys::Promise::new(&mut |done, _| {
+ let ms = i32::try_from(seconds.saturating_mul(1000)).unwrap_or(i32::MAX);
+ if window().and_then(|w| w.set_timeout_with_callback_and_timeout_and_arguments_0(&done, ms)).is_err() {
+ let _ = done.call0(&JsValue::NULL);
+ }
+ });
+ let _ = JsFuture::from(wait).await;
+ }
+
+ /// Replaces the page's "Loading" text. `reload` adds a link that reloads the page.
+ fn say(text: &str, reload: bool) {
+ let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
+ let Some(loading) = document.get_element_by_id("photocraft_loading") else { return };
+ loading.set_text_content(None);
+ let Ok(line) = document.create_element("p") else { return };
+ line.set_text_content(Some(text));
+ if reload && let Ok(link) = document.create_element("a") {
+ link.set_text_content(Some("Reload"));
+ let _ = link.set_attribute("href", "");
+ let _ = line.append_child(&link);
+ }
+ let _ = loading.append_child(&line);
+ }
+
+ pub fn start() {
+ spawn_local(async {
+ let mut attempt = 0;
+ loop {
+ let error = match run(attempt > 0).await {
+ Ok(()) => return,
+ Err(e) => e,
+ };
+ web_sys::console::error_2(&JsValue::from_str("photocraft-web-boot: could not start PhotoCraft:"), &error);
+ let Some(&wait) = RETRY_AFTER.get(attempt) else {
+ say("PhotoCraft couldn't be downloaded. Check your connection, then try again: ", true);
+ return;
+ };
+ say("Loading PhotoCraft\u{2026} (the download failed, trying again)", false);
+ sleep(wait).await;
+ attempt += 1;
+ }
+ });
+ }
+}
+
+#[cfg(target_arch = "wasm32")]
+fn main() {
+ boot::start();
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn main() {
+ eprintln!("photocraft-web-boot only runs in the browser: packaging/web/package.sh builds it with `trunk build --release` in apps/photocraft-web-boot");
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{Inflater, MARKER, parts};
+
+ fn gzip(bytes: &[u8]) -> Vec {
+ let mut z = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
+ std::io::Write::write_all(&mut z, bytes).expect("gzip");
+ z.finish().expect("gzip")
+ }
+
+ /// Bytes that don't compress to nothing, so the gzip stream spans many chunks.
+ fn sample() -> Vec {
+ let mut x = 0x2545_f491_4f6c_dd1d_u64;
+ (0..200_000)
+ .map(|i| {
+ x ^= x << 13;
+ x ^= x >> 7;
+ x ^= x << 17;
+ if i % 3 == 0 { x as u8 } else { (i / 7) as u8 }
+ })
+ .collect()
+ }
+
+ /// The parts arrive in whatever pieces the network gives; the inflater must not care where
+ /// they are cut.
+ #[test]
+ fn the_inflater_gives_back_the_bytes_however_the_stream_is_cut() {
+ let (plain, packed) = (sample(), gzip(&sample()));
+ for cut in [1, 7, 4096, 65_536, packed.len()] {
+ let mut inflater = Inflater::new();
+ let mut out = Vec::new();
+ for chunk in packed.chunks(cut) {
+ out.extend(inflater.push(chunk).expect("push"));
+ }
+ out.extend(inflater.finish().expect("finish"));
+ assert_eq!(out, plain, "chunks of {cut}");
+ }
+ }
+
+ /// A download that stops early, or whose bytes changed on the way, must fail (the boot module
+ /// then tries again), never start a damaged app.
+ #[test]
+ fn the_inflater_refuses_a_short_or_damaged_stream() {
+ let packed = gzip(&sample());
+ let run = |bytes: &[u8]| -> std::io::Result> {
+ let mut inflater = Inflater::new();
+ let mut out = inflater.push(bytes)?;
+ out.extend(inflater.finish()?);
+ Ok(out)
+ };
+ assert!(run(&packed).is_ok());
+ assert!(run(&packed[..packed.len() / 2]).is_err(), "cut short");
+ assert!(run(&packed[..packed.len() - 4]).is_err(), "trailer cut");
+ let mut damaged = packed.clone();
+ let middle = damaged.len() / 2;
+ damaged[middle] ^= 0x55;
+ assert!(run(&damaged).is_err(), "a changed byte");
+ assert!(run(b"not gzip").is_err(), "a web page");
+ assert!(run(b"").is_err(), "nothing at all");
+ }
+
+ #[test]
+ fn a_parts_list_gives_its_names_in_order() {
+ let list = format!("{MARKER}\nphotocraft-web-0123abcd.part-aa\r\n\n photocraft-web-0123abcd.part-ab \n");
+ assert_eq!(parts(&list), Some(vec!["photocraft-web-0123abcd.part-aa".to_owned(), "photocraft-web-0123abcd.part-ab".to_owned()]));
+ }
+
+ /// A host that answers a missing file with its home page must not have that page read as a
+ /// list of parts.
+ #[test]
+ fn a_web_page_is_not_a_parts_list() {
+ assert_eq!(parts(include_str!("../index.html")), None);
+ assert_eq!(parts(""), None);
+ assert_eq!(parts("photocraft-web.part-aa\n"), None, "no marker");
+ assert_eq!(parts(&format!("{MARKER}\n")), None, "no parts");
+ assert_eq!(parts("photocraft-parts 2\na.part-aa\n"), None, "a later layout this build doesn't know");
+ }
+
+ /// Part names are joined onto `./app/`, so one that could leave that folder, or name another
+ /// origin, refuses the whole list.
+ #[test]
+ fn a_part_name_cannot_point_outside_the_app_folder() {
+ for bad in ["../index.html", "a/b.part-aa", "a\\b", "https://example.com/x", "//example.com/x", "x?y", "x#y", ".hidden", "%2e%2e"] {
+ assert_eq!(parts(&format!("{MARKER}\ngood.part-aa\n{bad}\n")), None, "{bad}");
+ }
+ }
+
+ /// The packaged site shows this crate's page, the development build the app's own. They are
+ /// the same page apart from which program the `` line builds.
+ #[test]
+ fn the_page_is_the_apps_page() {
+ let trunk = |html: &str| html.lines().filter(|l| !l.contains("data-trunk")).collect::>().join("\n");
+ let (boot, app) = (include_str!("../index.html"), include_str!("../../photocraft-web/index.html"));
+ assert_eq!(trunk(boot), trunk(app));
+ assert!(boot.contains(r#"data-bin="photocraft-web-boot""#) && boot.contains(r#"data-cargo-profile="wasm-release""#), "{boot}");
+ assert!(boot.contains(r#"id="photocraft_loading""#), "the element the failure message replaces");
+ }
+}
diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs
index d5004334c1..eb350d17eb 100644
--- a/apps/photocraft-web/src/web.rs
+++ b/apps/photocraft-web/src/web.rs
@@ -18,8 +18,8 @@ type Inbox = Arc)>>>;
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
- "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf",
- "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
+ "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef",
+ "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
const CANVAS_ID: &str = "photocraft_canvas";
diff --git a/apps/photocraft/src/app_icon.rs b/apps/photocraft/src/app_icon.rs
index 90ce94ea04..54eea2d6b5 100644
--- a/apps/photocraft/src/app_icon.rs
+++ b/apps/photocraft/src/app_icon.rs
@@ -26,6 +26,25 @@ pub fn window_icon() -> egui::IconData {
}
}
+/// Whether the window gets [`window_icon`] at runtime: everywhere except inside a macOS app
+/// bundle. There the Dock shows the bundle's `PhotoCraft.icns` (`CFBundleIconFile`), which macOS
+/// renders in its own style (Liquid Glass and its tinted and dark variants); a runtime PNG
+/// replaced it with a flat picture (#5849). `exe` is the running executable.
+pub fn sets_runtime_icon(exe: Option<&std::path::Path>) -> bool {
+ !(cfg!(target_os = "macos") && exe.is_some_and(in_app_bundle))
+}
+
+/// Whether `exe` is an app bundle's executable: `…/Name.app/Contents/MacOS/binary`.
+fn in_app_bundle(exe: &std::path::Path) -> bool {
+ let name = |p: Option<&std::path::Path>| p.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned());
+ let macos = exe.parent();
+ let contents = macos.and_then(std::path::Path::parent);
+ let bundle = contents.and_then(std::path::Path::parent);
+ name(macos).as_deref() == Some("MacOS")
+ && name(contents).as_deref() == Some("Contents")
+ && name(bundle).is_some_and(|n| n.len() > 4 && n.to_ascii_lowercase().ends_with(".app"))
+}
+
#[cfg(target_os = "macos")]
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/photocraft-1024.png");
#[cfg(not(target_os = "macos"))]
@@ -45,6 +64,22 @@ mod tests {
u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize
}
+ /// #5849: a macOS app bundle keeps its .icns in the Dock; unbundled runs (cargo run, the
+ /// Linux and Windows builds) still set the runtime icon.
+ #[test]
+ fn only_a_macos_app_bundle_skips_the_runtime_icon() {
+ use std::path::Path;
+ assert!(in_app_bundle(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft")));
+ assert!(in_app_bundle(Path::new("/Users/a/Downloads/PhotoCraft Beta.APP/Contents/MacOS/photocraft")));
+ assert!(!in_app_bundle(Path::new("/Users/a/src/photocraft/target/release/photocraft")));
+ assert!(!in_app_bundle(Path::new("/opt/Contents/MacOS/photocraft")), "not inside a .app");
+ assert!(!in_app_bundle(Path::new("/.app/Contents/MacOS/photocraft")), "a bare `.app` is no bundle name");
+ assert!(!in_app_bundle(Path::new("photocraft")));
+ assert!(sets_runtime_icon(None));
+ assert!(sets_runtime_icon(Some(Path::new("/usr/bin/photocraft"))));
+ assert_eq!(sets_runtime_icon(Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"))), !cfg!(target_os = "macos"));
+ }
+
#[test]
fn window_icon_decodes() {
let icon = window_icon();
diff --git a/apps/photocraft/src/control_server.rs b/apps/photocraft/src/control_server.rs
index 258d89e1ec..30ae610b70 100644
--- a/apps/photocraft/src/control_server.rs
+++ b/apps/photocraft/src/control_server.rs
@@ -5,11 +5,12 @@ use std::io::{BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender, channel};
-use std::time::Instant;
+use std::time::{Duration, Instant};
use photocraft_automation::budgets::write_reply;
use photocraft_automation::security::{
- ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line, repeat_auth_reply,
+ AUTH_DEADLINE, AuthDeadlineStream, ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream,
+ read_bounded_line, repeat_auth_reply,
};
use photocraft_ui_egui::ControlRequest;
use photocraft_ui_egui::control::REPLY_DEADLINE;
@@ -47,11 +48,17 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver, ctx: egui::Context) {
+ serve_with_auth_deadline(stream, token, tx, ctx, AUTH_DEADLINE);
+}
+
+/// [`serve`] for a connection that must complete `auth` within `auth_deadline` of connecting,
+/// however much it sends meanwhile (#4965); after that only the idle timeout applies.
+fn serve_with_auth_deadline(stream: TcpStream, token: &str, tx: Sender, ctx: egui::Context, auth_deadline: Duration) {
if configure_stream(&stream).is_err() {
return;
}
let Ok(read) = stream.try_clone() else { return };
- let mut reader = BufReader::new(read);
+ let mut reader = BufReader::new(AuthDeadlineStream::new(read, auth_deadline));
let mut out = stream;
let mut line = String::new();
let mut authenticated = false;
@@ -74,6 +81,9 @@ fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui::
if !authenticated {
let (reply, ok) = authentication_reply(&line, token);
authenticated = ok;
+ if ok && reader.get_mut().authenticated().is_err() {
+ break;
+ }
if write_reply(&mut out, &reply).is_err() || out.flush().is_err() || !authenticated {
break;
}
@@ -120,7 +130,34 @@ fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui::
mod tests {
use super::*;
use std::io::BufRead;
- use std::time::Duration;
+
+ /// #4965: a connection that keeps sending without authenticating is closed at the deadline
+ /// (releasing its connection permit), and nothing it sent is dispatched.
+ #[test]
+ fn an_unauthenticated_connection_is_closed_at_the_deadline_whatever_it_sends() {
+ let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+ let addr = listener.local_addr().unwrap();
+ let (tx, rx) = channel::();
+ let server = std::thread::spawn(move || {
+ let (stream, _) = listener.accept().unwrap();
+ let started = Instant::now();
+ serve_with_auth_deadline(stream, "unused", tx, egui::Context::default(), Duration::from_millis(300));
+ started.elapsed()
+ });
+ // Blank lines and partial frames, one byte every 20 ms: each read succeeds long before
+ // the idle timeout.
+ let mut stream = TcpStream::connect(addr).unwrap();
+ for i in 0..150 {
+ let byte: &[u8] = if i % 5 == 0 { b"\n" } else { b" " };
+ if stream.write_all(byte).is_err() {
+ break;
+ }
+ std::thread::sleep(Duration::from_millis(20));
+ }
+ let served = server.join().unwrap();
+ assert!(served < Duration::from_secs(3), "the trickle did not extend the deadline: {served:?}");
+ assert!(rx.try_recv().is_err(), "nothing was dispatched");
+ }
#[test]
fn oversized_reply_preserves_framing_id_and_the_next_control_request() {
diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs
index 1739568159..de1155edbd 100644
--- a/apps/photocraft/src/main.rs
+++ b/apps/photocraft/src/main.rs
@@ -78,9 +78,11 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool {
/// different offset every launch (#419). A later launch restores the saved position instead
/// (`main` turns `centered` off, #4826). Wayland compositors place windows themselves.
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
+ let viewport = egui::ViewportBuilder::default();
+ // Not inside a macOS app bundle: its Dock icon is the bundle's .icns (`app_icon`, #5849).
+ let viewport = if app_icon::sets_runtime_icon(std::env::current_exe().ok().as_deref()) { viewport.with_icon(app_icon::window_icon()) } else { viewport };
eframe::NativeOptions {
- viewport: egui::ViewportBuilder::default()
- .with_icon(app_icon::window_icon())
+ viewport: viewport
.with_app_id(APP_ID)
.with_title("PhotoCraft")
.with_inner_size([1440.0, 900.0])
diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs
index 36c7c2ccf5..9de1c475ea 100644
--- a/apps/photocraft/src/services.rs
+++ b/apps/photocraft/src/services.rs
@@ -21,8 +21,8 @@ mod recovery_tests;
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
- "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw",
- "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
+ "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw",
+ "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
@@ -53,6 +53,7 @@ fn open_filters() -> Vec<(&'static str, Vec)> {
/// keeps defaulting to PSD.
const SAVE_FILTERS: &[(&str, &[&str])] = &[
("PSD Document", &["psd", "psb"]),
+ ("PDF", &["pdf"]),
("PhotoCraft", &["pcraft"]),
("OpenRaster", &["ora"]),
("PNG", &["png"]),
@@ -725,7 +726,8 @@ mod tests {
/// Every flat format Save As offers is one PhotoCraft writes, and ICO is among them (#4357).
#[test]
fn save_as_offers_only_writable_formats_including_ico() {
- let layered = ["psd", "psb", "pcraft", "ora"];
+ // Layered formats, and PDF, are written by `photocraft-io`, not a codec.
+ let layered = ["psd", "psb", "pcraft", "ora", "pdf"];
for (name, exts) in SAVE_FILTERS {
for ext in exts.iter().filter(|e| !layered.contains(e)) {
let format = photocraft_codecs::from_extension(ext).unwrap_or_else(|| panic!("{name}: .{ext} is no known format"));
diff --git a/contributors/people.toml b/contributors/people.toml
index 6f630a0c12..8694c72698 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -158,3 +158,7 @@ sync_github_name = false
[people.yeniufuklar-com]
real_name = "Ufuk Efe"
sync_github_name = true
+
+[people.sidney]
+real_name = "Sidney Markowitz"
+sync_github_name = true
diff --git a/crates/affinity/Cargo.toml b/crates/affinity/Cargo.toml
index e686e737ef..49d859c2f8 100644
--- a/crates/affinity/Cargo.toml
+++ b/crates/affinity/Cargo.toml
@@ -16,7 +16,7 @@ synth = []
[dependencies]
crc32fast = "1.5"
flate2 = { workspace = true }
-ruzstd = "0.9"
+ruzstd = "0.9.1"
[dev-dependencies]
proptest = { workspace = true }
diff --git a/crates/algo/src/content_aware.rs b/crates/algo/src/content_aware.rs
index ecdb308624..76880cd5ad 100644
--- a/crates/algo/src/content_aware.rs
+++ b/crates/algo/src/content_aware.rs
@@ -474,8 +474,10 @@ pub fn auto_sampling_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bo
let mut cost_fg = vec![0.0f32; sw * sh];
let mut cost_bg = vec![0.0f32; sw * sh];
let mut fixed = vec![FREE; sw * sh];
+ // Always the hole's own edge, and around a thin selection each part's surroundings.
+ let keep = thin_band(sw, sh, &shole);
for i in 0..sw * sh {
- if shole[i] || dist[i] <= 1.5 {
+ if shole[i] || dist[i] <= keep {
fixed[i] = HARD_FG;
continue;
}
@@ -508,6 +510,22 @@ pub fn auto_sampling_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bo
Ok(out)
}
+/// How far around a thin selection (strings, wires, and any small parts selected with them, all
+/// at most a quarter as thick as the selection is long) the Auto area always reaches, in cells of
+/// a `w × h` grid: as far as the selection is thick, so that each part, however far from the
+/// rest, keeps its own surroundings to copy from. 1.5 cells for any other selection.
+fn thin_band(w: usize, h: usize, hole: &[bool]) -> f32 {
+ let inside = crate::selection::edt(&hole.iter().map(|h| !h).collect::>(), w, h);
+ let thickness = 2.0 * inside.iter().zip(hole).filter(|(_, h)| **h).map(|(d, _)| *d).fold(0.0f32, f32::max);
+ let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
+ for (i, _) in hole.iter().enumerate().filter(|(_, h)| **h) {
+ let (x, y) = (i % w, i / w);
+ (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1));
+ }
+ let extent = x1.saturating_sub(x0).max(y1.saturating_sub(y0)) as f32;
+ if 4.0 * thickness <= extent { thickness.max(1.5) } else { 1.5 }
+}
+
/// The Patch Tool's and the Content-Aware Move Tool's Color option (0–10) as the colour adaptation
/// of the copied content. Measured on Photoshop 25.4 with content brighter than its new place: 0
/// keeps it as it is; 3 moves it about 0.12 (32 of 42 levels, and 31 of 102: a limit, not a
@@ -751,6 +769,31 @@ mod tests {
}
}
+ #[test]
+ fn auto_sampling_keeps_the_surroundings_of_every_part_of_a_thin_selection() {
+ // A long thin selection across a grey ramp, and a small knob selected with it in a red
+ // corner. Each part keeps its own surroundings; a compact selection is unchanged.
+ let (w, h) = (900, 600);
+ let tex = noise(w, h, 4);
+ let img: Vec = (0..w * h * 3)
+ .map(|i| {
+ let (x, y, c) = ((i / 3) % w, (i / 3) / w, i % 3);
+ let base = if x > 690 && y > 420 { [0.7, 0.25, 0.2][c] } else { 0.2 + 0.5 * x as f32 / w as f32 };
+ base + (tex[i] - 0.5) * 0.04
+ })
+ .collect();
+ let line = |i: usize| (30..870).contains(&(i % w)) && (180..192).contains(&(i / w));
+ let knob = |i: usize| ((i % w) as f32 - 795.0).hypot((i / w) as f32 - 510.0) < 10.0;
+ let hole: Vec = (0..w * h).map(|i| line(i) || knob(i)).collect();
+ let area = auto_sampling(w, h, 3, &img, &hole);
+ let around_knob = (0..w * h).filter(|&i| !hole[i] && ((i % w) as f32 - 795.0).hypot((i / w) as f32 - 510.0) < 24.0);
+ let (n, kept) = around_knob.fold((0, 0), |(n, k), i| (n + 1, k + usize::from(area[i])));
+ assert!(kept as f32 > 0.9 * n as f32, "{kept} of {n} pixels around the knob");
+ assert!(thin_band(w, h, &hole) >= 20.0, "{}", thin_band(w, h, &hole));
+ let disc: Vec = (0..w * h).map(|i| ((i % w) as f32 - 450.0).hypot((i / w) as f32 - 300.0) < 90.0).collect();
+ assert_eq!(thin_band(w, h, &disc), 1.5);
+ }
+
#[test]
fn without_colour_adaptation_the_fill_copies_pixels_exactly() {
// A smooth texture with fine noise, so that every value is unique: a filled pixel equal to
@@ -798,6 +841,116 @@ mod tests {
assert!((shift(10) - 0.4).abs() < 0.01, "{}", shift(10));
}
+ /// A soft background (gradients, broad out-of-focus blobs of colour, fine noise) with thin, long bright
+ /// objects over it: three coiled strings 3 to 7 px wide and two straighter ones, each with an
+ /// anti-aliased edge and a faint halo. Returns the image; what removing the coils should give
+ /// (the background and the straighter strings, which stay); and the selection a user would draw
+ /// around the coils, `margin` px wide.
+ fn strings_scene(w: usize, h: usize, margin: f32) -> (Vec, Vec, Vec) {
+ let fine = noise(w, h, 11);
+ // Out-of-focus colour: broad blobs over two gradients.
+ let blob = noise(3, 3, 5);
+ let bg: Vec = (0..w * h * 3)
+ .map(|i| {
+ let (x, y, c) = (((i / 3) % w) as f32 / w as f32, ((i / 3) / w) as f32 / h as f32, i % 3);
+ let mut v = [0.18 + 0.25 * x, 0.22 + 0.1 * y, 0.3 - 0.1 * x][c];
+ for b in blob.as_chunks::<3>().0 {
+ let (bx, by, s) = (b[0] * 1.6 - 0.3, b[1] * 1.6 - 0.3, 0.1 + 0.25 * b[2]);
+ v += (b[(c + 1) % 3] - 0.2) * 0.5 * (-((x - bx).powi(2) + (y - by).powi(2) * 0.56) / (2.0 * s * s)).exp();
+ }
+ v + (fine[i] - 0.5) * 0.03
+ })
+ .collect();
+ // Signed distance to each object's surface, stamped from points along it.
+ let stamp = |sd: &mut [f32], pts: &mut dyn Iterator, half: f32| {
+ for (px, py) in pts {
+ let reach = (half + 4.0) as i32 + 1;
+ for dy in -reach..=reach {
+ for dx in -reach..=reach {
+ let (x, y) = (px as i32 + dx, py as i32 + dy);
+ if x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h {
+ let i = y as usize * w + x as usize;
+ sd[i] = sd[i].min((x as f32 - px).hypot(y as f32 - py) - half);
+ }
+ }
+ }
+ }
+ };
+ let mut coils = vec![f32::INFINITY; w * h];
+ for (k, half) in [1.5f32, 2.5, 3.5].into_iter().enumerate() {
+ let (y0, r) = ((0.22 + 0.28 * k as f32) * h as f32, 10.0 + 2.0 * k as f32);
+ let mut pts = (0..4000).map(|j| {
+ let t = j as f32 / 4000.0;
+ let a = 8.0 * std::f32::consts::TAU * t;
+ (18.0 + t * (w as f32 - 60.0) + r * a.cos(), y0 + 6.0 * (6.0 * t).sin() + 0.7 * r * a.sin())
+ });
+ stamp(&mut coils, &mut pts, half);
+ }
+ let mut straight = vec![f32::INFINITY; w * h];
+ for y in [0.07, 0.93] {
+ stamp(&mut straight, &mut (0..2000).map(|j| (j as f32 / 2000.0 * w as f32, y * h as f32 + 3.0 * (j as f32 / 300.0).sin())), 2.0);
+ }
+ let draw = |base: &[f32], sd: &[f32]| -> Vec {
+ let mut img = base.to_vec();
+ for i in 0..w * h {
+ let cover = (0.5 - sd[i]).clamp(0.0, 1.0);
+ let halo = if sd[i] > 0.0 { 0.08 * (-sd[i] * sd[i] / 2.9).exp() } else { 0.0 };
+ for (c, metal) in [0.9f32, 0.85, 0.7].into_iter().enumerate() {
+ let v = &mut img[i * 3 + c];
+ *v = *v * (1.0 - cover) + metal * cover + halo * (1.0 - cover);
+ }
+ }
+ img
+ };
+ let clean = draw(&bg, &straight);
+ let both: Vec = coils.iter().zip(&straight).map(|(a, b)| a.min(*b)).collect();
+ let img = draw(&bg, &both);
+ let hole = coils.iter().map(|d| *d <= margin).collect();
+ (img, clean, hole)
+ }
+
+ /// RMS over the hole of the error averaged over 7 × 7 pixels (in levels of 255): the noise
+ /// cancels out, a ghost of the removed outline or a blotch of another shade does not.
+ fn blotch_error(w: usize, h: usize, out: &[f32], clean: &[f32], hole: &[bool]) -> f32 {
+ let (mut sum, mut n) = (0.0f64, 0usize);
+ for y in 3..h - 3 {
+ for x in 3..w - 3 {
+ if !hole[y * w + x] {
+ continue;
+ }
+ let mut e = [0.0f32; 3];
+ for j in (y - 3..=y + 3).flat_map(|yy| (x - 3..=x + 3).map(move |xx| yy * w + xx)) {
+ for (c, ec) in e.iter_mut().enumerate() {
+ *ec += out[j * 3 + c] - clean[j * 3 + c];
+ }
+ }
+ sum += f64::from(e.iter().map(|v| (v / 49.0).powi(2)).sum::() / 3.0);
+ n += 1;
+ }
+ }
+ ((sum / n.max(1) as f64).sqrt() * 255.0) as f32
+ }
+
+ /// Removing thin, long objects (guitar strings, wires) from a soft background (#1366): the
+ /// fill is the background, with no ghost of their outline and nothing of their colour copied
+ /// back in. Their selection is long but only a few pixels thick, so the fill is worked out at
+ /// a scale where it is still a few pixels thick rather than one where the strings' surroundings
+ /// have merged into a blur.
+ #[test]
+ fn thin_objects_leave_no_ghost_and_none_of_their_colour() {
+ let (w, h) = (360, 270);
+ let (img, clean, hole) = strings_scene(w, h, 2.0);
+ let source = auto_sampling(w, h, 3, &img, &hole);
+ let out = fill(w, h, 3, &img, &hole, &source, &FillOptions::default());
+ let blotch = blotch_error(w, h, &out, &clean, &hole);
+ let luma = |v: &[f32], i: usize| (v[i * 3] + v[i * 3 + 1] + v[i * 3 + 2]) / 3.0;
+ let bright = (0..w * h).filter(|&i| hole[i] && luma(&out, i) > luma(&clean, i) + 0.06).count();
+ // Before #1366 the outlines stayed as darker and lighter blotches (13 levels of 255) and
+ // 1121 pixels took the strings' colour.
+ assert!(blotch < 2.5, "ghosts and blotches: {blotch:.2} levels of 255");
+ assert_eq!(bright, 0, "filled pixels with the strings' colour");
+ }
+
#[test]
fn variant_geometry() {
let img = vec![0.5f32; 10 * 6];
diff --git a/crates/algo/src/inpaint.rs b/crates/algo/src/inpaint.rs
index d7d43c032f..ee472b2296 100644
--- a/crates/algo/src/inpaint.rs
+++ b/crates/algo/src/inpaint.rs
@@ -16,6 +16,15 @@
//! source (the usual case), the vote copies the source pixels exactly; only along the seams
//! between copied pieces does it blend.
//!
+//! A thin hole (a selected wire, string or hair: long, but only a few patches thick) is
+//! measured by its thickness, not its bounds (#1366): the pyramid stops where it is still about
+//! two patches thick. That level is big, so the onion peel starts it only where its exhaustive
+//! search stays affordable; otherwise a membrane across the hole does, which over so short a
+//! distance is the surroundings themselves. Shrunk to where its bounds are four patches across,
+//! a long thin hole swallows its surroundings, the matches found there copy whatever happens to
+//! look like the blur (a dark blotch, or a piece of another string), and the finer levels,
+//! which only refine those matches, keep them: the removed object's outline stays visible.
+//!
//! This is what Photoshop's Content-Aware Fill does, measured as a black box on Photoshop 25.4
//! (see `content_aware`): with colour adaptation off its fill is pieces of the image copied pixel
//! for pixel, typically 15–40 px across, joined by seams a few pixels wide; with colour
@@ -278,6 +287,38 @@ fn hole_bbox(w: usize, h: usize, hole: &[bool]) -> Option<(usize, usize, usize,
b
}
+/// A hole at most `1 / THIN_RATIO` as thick as its bounds are long counts as thin (a wire, a
+/// string, a hair, a long brush stroke): see the module docs.
+const THIN_RATIO: usize = 4;
+
+/// How thick the hole within bounds `hb` of a `w`-wide role grid is: twice the largest distance
+/// from a hole pixel to the nearest pixel that is not one (a long, thin hole is thin however long
+/// its bounds).
+fn hole_thickness(w: usize, hb: Bounds, px: &[Px]) -> usize {
+ // The bounds and a ring of non-hole cells around them (also past the grid's edge).
+ let (gw, gh) = (hb.width() + 2, hb.height() + 2);
+ let outside: Vec = par::map(gw * gh, gw * gh >= PAR_MIN, |i| {
+ let (gx, gy) = (i % gw, i / gw);
+ if gx == 0 || gy == 0 || gx > hb.width() || gy > hb.height() {
+ return true;
+ }
+ px.get((hb.y0 + gy - 1) * w + hb.x0 + gx - 1) != Some(&Px::Hole)
+ });
+ let deepest = crate::selection::edt(&outside, gw, gh).into_iter().fold(0.0f32, f32::max);
+ (2.0 * deepest).ceil() as usize
+}
+
+/// Most target pixels × sources the onion peel of a thin hole may compare (its search is
+/// exhaustive, though most comparisons stop early: about a second at this many).
+const PEEL_BUDGET: usize = 1 << 28;
+
+/// Whether the onion peel of the hole cells within `hb` of a `w`-wide role grid from `sources`
+/// source centres stays within [`PEEL_BUDGET`].
+fn peel_affordable(w: usize, px: &[Px], hb: Bounds, sources: usize) -> bool {
+ let holes: usize = (hb.y0..hb.y1).map(|y| px.get(y * w + hb.x0..y * w + hb.x1).map_or(0, |row| row.iter().filter(|k| **k == Px::Hole).count())).sum();
+ holes.saturating_mul(sources) <= PEEL_BUDGET
+}
+
/// Bounds of the hole pixels of a role grid.
fn hole_bounds(w: usize, h: usize, px: &[Px]) -> Option {
// Per row the first and last hole column; the union of the rows' spans.
@@ -1575,14 +1616,20 @@ fn complete_cow<'a>(
let r = p.patch_radius.max(1);
let psz = 2 * r + 1;
// Pyramid: shrink until the hole is about four patches across (a deeper pyramid decides the
- // structure where the texture has averaged out, and fills flat).
+ // structure where the texture has averaged out, and fills flat). A thin hole stops while it
+ // is still about two patches thick: through it, not along it, is what counts (#1366).
let mut levels = vec![Level { w, h, img, px: Cow::Borrowed(px) }];
let mut boxes = vec![hb0];
let mut ext = hb0.width().max(hb0.height());
+ let thickness = hole_thickness(w, hb0, px);
+ let thin = thickness.saturating_mul(THIN_RATIO) <= ext;
+ if thin {
+ ext = thickness;
+ }
let mut exts = vec![ext];
while let Some(l) = levels.last() {
ctl.check()?;
- if ext <= 4 * psz || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz {
+ if ext <= if thin { 2 * psz } else { 4 * psz } || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz {
break;
}
let d = downsample(l, ch);
@@ -1638,9 +1685,21 @@ fn complete_cow<'a>(
}
}
}
- None if sources.len() > 0 => onion_peel(lw, lh, ch, ri, &mut limg, &lpx, hb, &sources.all(&valid, lw), ctl)?,
+ // A thin hole, when peeling it would take too long, takes the membrane below: a few
+ // patches thick here, a membrane across it is its surroundings.
+ None if sources.len() > 0 && (!thin || peel_affordable(lw, &lpx, hb, sources.len())) => {
+ onion_peel(lw, lh, ch, ri, &mut limg, &lpx, hb, &sources.all(&valid, lw), ctl)?
+ }
None => {
- let unknown: Vec = lpx.iter().map(|k| matches!(k, Px::Hole | Px::Void)).collect();
+ let mut unknown: Vec = lpx.iter().map(|k| matches!(k, Px::Hole | Px::Void)).collect();
+ // Across a thin hole the membrane starts a patch radius out, past the
+ // anti-aliased fringe or halo that a selection drawn close to the object leaves.
+ if thin {
+ let hole: Vec = lpx.iter().map(|k| *k == Px::Hole).collect();
+ for (u, d) in unknown.iter_mut().zip(crate::selection::edt(&hole, lw, lh)) {
+ *u |= d <= r as f32;
+ }
+ }
let m = membrane_fill(lw, lh, ch, &limg, &unknown);
for (i, k) in lpx.iter().enumerate() {
if *k == Px::Hole {
@@ -2114,6 +2173,43 @@ mod tests {
.collect()
}
+ #[test]
+ fn a_hole_is_as_thick_as_it_is_through_not_along() {
+ let (w, h) = (240, 120);
+ let all = Bounds { x0: 0, y0: 0, x1: w, y1: h };
+ let thickness = |hole: &[bool]| {
+ let px: Vec = hole.iter().map(|&o| if o { Px::Hole } else { Px::Source }).collect();
+ hole_thickness(w, hole_bounds(w, h, &px).unwrap_or(all), &px)
+ };
+ // A band 200 px long and 9 px thick; a disc of radius 20; the band touching the edge
+ // counts the outside of the image as not hole.
+ let band: Vec = (0..w * h).map(|i| (20..220).contains(&(i % w)) && (50..59).contains(&(i / w))).collect();
+ assert!((9..=11).contains(&thickness(&band)), "{}", thickness(&band));
+ assert!((39..=42).contains(&thickness(&disc(w, h, 120.0, 60.0, 20.0))));
+ let edge: Vec = (0..w * h).map(|i| i / w < 9).collect();
+ assert!((9..=11).contains(&thickness(&edge)), "{}", thickness(&edge));
+ }
+
+ /// A long thin hole goes the thin way (a shallow pyramid that starts from a membrane): it is
+ /// filled, only it changes, and the result does not depend on the thread count either.
+ #[test]
+ #[cfg(not(target_arch = "wasm32"))]
+ fn thin_holes_are_filled_the_same_on_any_number_of_threads() {
+ let (w, h) = (300, 200);
+ let img = busy(w, h, 3, 5);
+ // A wavy line 6 px thick across the image.
+ let hole: Vec = (0..w * h).map(|i| (((i % w) as f32 * 0.05).sin() * 30.0 + 100.0 - (i / w) as f32).abs() < 3.0).collect();
+ let px = roles(w, h, &hole);
+ let run = |threads: usize| {
+ let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap();
+ pool.install(|| complete_masked(w, h, 3, &img, &px, &CompleteParams::default(), &photocraft_raster::Interrupt::NONE)).unwrap().unwrap()
+ };
+ let one = run(1);
+ assert_eq!(bits(&one), bits(&run(7)));
+ assert!((0..w * h).all(|i| hole[i] || (0..3).all(|c| one[i * 3 + c].to_bits() == img[i * 3 + c].to_bits())));
+ assert!((0..w * h).filter(|&i| hole[i]).all(|i| (0..3).all(|c| one[i * 3 + c].is_finite())));
+ }
+
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn results_do_not_depend_on_the_thread_count() {
diff --git a/crates/algo/src/segment/mod.rs b/crates/algo/src/segment/mod.rs
index 5c190e643a..b30ce909b7 100644
--- a/crates/algo/src/segment/mod.rs
+++ b/crates/algo/src/segment/mod.rs
@@ -448,6 +448,51 @@ pub fn clean_mask(mask: &[bool], w: usize, h: usize, frac: f32, hole_frac: f32)
out
}
+/// Fills the holes of `mask` (4-connected background areas not touching the border) by what
+/// they look like: a hole up to `speck_frac` of the mask's area always fills; a larger one, up
+/// to `hole_frac`, only when its pixels fit the foreground model `fg` better than `bg` on
+/// average, so an enclosed patch of backdrop stays open.
+pub fn fill_holes_by_model(mask: &[bool], img: &RgbImage, fg: &Gmm, bg: &Gmm, speck_frac: f32, hole_frac: f32) -> Vec {
+ let (w, h) = (img.w, img.h);
+ let mut out = mask.to_vec();
+ let area = out.iter().filter(|v| **v).count();
+ let max_speck = (area as f32 * speck_frac) as usize;
+ let max_hole = (area as f32 * hole_frac) as usize;
+ let inv: Vec = out.iter().map(|v| !v).collect();
+ let (hl, hs) = components4(&inv, w, h);
+ let mut touches = vec![false; hs.len()];
+ for x in 0..w {
+ touches[hl[x] as usize] = true;
+ touches[hl[(h - 1) * w + x] as usize] = true;
+ }
+ for y in 0..h {
+ touches[hl[y * w] as usize] = true;
+ touches[hl[y * w + w - 1] as usize] = true;
+ }
+ // Summed data costs per hole; only holes small enough to be candidates are evaluated.
+ let mut cost = vec![(0.0f64, 0.0f64); hs.len()];
+ for (i, l) in hl.iter().enumerate() {
+ let l = *l as usize;
+ if l == 0 || touches[l] || hs[l] > max_hole || hs[l] <= max_speck {
+ continue;
+ }
+ if let Some(p) = img.px.get(i) {
+ cost[l].0 += f64::from(fg.neg_log(*p));
+ cost[l].1 += f64::from(bg.neg_log(*p));
+ }
+ }
+ for (o, l) in out.iter_mut().zip(&hl) {
+ let l = *l as usize;
+ if l == 0 || touches[l] || hs[l] > max_hole {
+ continue;
+ }
+ if hs[l] <= max_speck || cost[l].0 <= cost[l].1 {
+ *o = true;
+ }
+ }
+ out
+}
+
fn components4(mask: &[bool], w: usize, h: usize) -> (Vec, Vec) {
let mut lab = vec![0u32; w * h];
let mut sizes = vec![0usize];
diff --git a/crates/algo/src/segment/subject.rs b/crates/algo/src/segment/subject.rs
index b117bb5ef6..75a1a47a56 100644
--- a/crates/algo/src/segment/subject.rs
+++ b/crates/algo/src/segment/subject.rs
@@ -13,8 +13,17 @@
//! S. Süsstrunk, "Frequency-tuned Salient Region Detection", CVPR 2009): distance of the
//! lightly blurred CIELAB image from its mean colour.
//! 4. Otsu's threshold on the saliency map seeds a GrabCut trimap (low-saliency border pixels
-//! are definite background); the result is cleaned (small islands dropped, small holes
-//! filled) and re-cut at full resolution.
+//! are definite background); the result is cleaned (small islands dropped).
+//! 5. A second GrabCut whose background model only knows the backdrop: a colour model fitted
+//! to the image's frame marks every pixel it explains as probable background, and the rest
+//! (the blob, plus whatever the backdrop cannot account for) starts as probable foreground.
+//! Parts of the subject whose colour is close to the backdrop (a cream shirt on a near-white
+//! wall) have low saliency and in step 4 land in the background model, which then explains
+//! them away; here they start as foreground and the cut keeps them (#2788). Only what
+//! touches the first pass's result survives, so the pass cannot pull in unrelated things.
+//! 6. Holes are filled only when their colour fits the foreground model better than the
+//! background's, so a patch of backdrop enclosed by the subject (between an arm and the
+//! torso) stays open; specks still fill. Then the mask is re-cut at full resolution.
//!
//! Works well for a distinct object on a comparatively plain background; it is not a semantic
//! segmenter.
@@ -25,6 +34,7 @@ use std::collections::BinaryHeap;
use photocraft_geom::Rect;
use photocraft_raster::{Cancelled, Interrupt};
+use super::gmm::Gmm;
use super::grabcut::{self, BG, PR_BG, PR_FG};
use super::slic::{rgb_to_lab, slic_lab};
use super::{Region, Sampler};
@@ -72,13 +82,65 @@ pub fn select_subject_with(sampler: &dyn Sampler, canvas: Rect, ctl: &Interrupt)
}
let Some((fg, bg)) = grabcut::grabcut_with(&img, &mut trimap, 6, ctl)? else { return Ok(None) };
let low: Vec = trimap.iter().map(|v| *v == PR_FG || *v == grabcut::FG).collect();
- let low = super::clean_mask(&low, w, h, 0.2, 0.05);
+ let low = super::clean_mask(&low, w, h, 0.2, 0.0);
if !low.iter().any(|v| *v) {
return Ok(None);
}
+ let (low, fg, bg) = backdrop_pass(&img, &low, ctl)?.unwrap_or((low, fg, bg));
+ ctl.check()?;
+ let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.002, 0.05);
super::finish_region_with(sampler, canvas, step, &low, w, h, Some((&fg, &bg)), ctl)
}
+/// Step 5: GrabCut again with a background model that only knows the backdrop. A colour model
+/// is fitted to the image's frame (the outer pixels, minus a margin around `blob` in case the
+/// subject runs out of the picture); the frame is definite background, every other pixel it
+/// explains well is probable background, and the rest (the blob, and anything the backdrop
+/// cannot account for, such as a cream shirt on a near-white wall) starts as probable
+/// foreground. `Ok(None)` when the frame cannot be modelled or the cut keeps nothing that
+/// touches the blob; `Err(Cancelled)` once `ctl` is cancelled, as in the first pass.
+fn backdrop_pass(img: &super::RgbImage, blob: &[bool], ctl: &Interrupt) -> Result