diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000000..898584b7b4
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,18 @@
+.git
+.github
+.agents
+.codex
+.aws
+**/target
+dist
+corpus
+log
+plan
+secrets
+temp
+craft-fonts
+book/output
+**/.DS_Store
+**/.env
+**/.env.*
+*.log
diff --git a/.gitignore b/.gitignore
index 99cf666151..f310f32921 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,4 @@ target/
/book/output
/craft-fonts
*.log
+__pycache__/
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 9cdf9aa6e0..22a4c60916 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -26,6 +26,7 @@ next to them.
| `assets/dict/en_US-scowl-50.txt.gz` | English word list for Check Spelling (SCOWL 2020.12.07, size 50, en_US; 72,403 words) | Kevin Atkinson and the SCOWL contributors | | SCOWL license (permissive, keep the notice), [`assets/dict/LICENSE-SCOWL.txt`](assets/dict/LICENSE-SCOWL.txt), which also has the build recipe |
| `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft Coated CMYK (synthetic) ICC profile | PhotoCraft contributors (generated by `photocraft-cms`, `src/synth.rs`) | Original work, see [`crates/cms/README.md`](crates/cms/README.md) | CC0-1.0 |
| `assets/app-icon/` (all files) | PhotoCraft app icon (nine-tailed kitsune) | The project owner (drawn in ArtCraft, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) |
+| `packaging/macos/dmg/` (all files) | PhotoCraft DMG window background and layout | @XusBadia, from the PhotoCraft app icon | Original work, see [`packaging/macos/dmg/README.md`](packaging/macos/dmg/README.md) | MIT OR Apache-2.0, like the code |
### Optional build input: craft-fonts (not files in this repo)
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000..66734f255a
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,68 @@
+# syntax=docker/dockerfile:1
+# Community-maintained self-hosting recipe (see packaging/web/README.md#docker); the official
+# web build is the `web` job in .github/workflows/release.yml (packaging/web/package.sh).
+#
+# Source of truth for the toolchain: release.yml and ci.yml build with the latest *stable* Rust
+# (dtolnay/rust-toolchain@stable), not a pinned version. This default is the stable release CI
+# used when it was last bumped; raise it when CI's stable moves on. It must stay >= the
+# workspace `rust-version` in Cargo.toml.
+ARG RUST_VERSION=1.99
+ARG NGINX_VERSION=1.28-alpine
+
+FROM rust:${RUST_VERSION}-slim-bookworm AS builder
+# Must match `TRUNK_VERSION` in the `web` job of .github/workflows/release.yml.
+ARG TRUNK_VERSION=0.21.14
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends ca-certificates curl \
+ && rm -rf /var/lib/apt/lists/* \
+ && rustup target add wasm32-unknown-unknown
+RUN set -eu; \
+ case "$(uname -m)" in \
+ x86_64) arch=x86_64 ;; \
+ aarch64) arch=aarch64 ;; \
+ *) echo 'The web builder supports amd64 and arm64' >&2; exit 1 ;; \
+ esac; \
+ asset="trunk-${arch}-unknown-linux-musl.tar.gz"; \
+ url="https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}"; \
+ cd /tmp; \
+ curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset" -o "$asset"; \
+ curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset.sha256" -o "$asset.sha256"; \
+ printf '%s %s\n' "$(cat "$asset.sha256")" "$asset" | sha256sum -c -; \
+ tar -xzf "$asset" -C /usr/local/bin trunk; \
+ rm "$asset" "$asset.sha256"; \
+ trunk --version
+
+WORKDIR /src
+COPY . .
+WORKDIR /src/apps/photocraft-web
+# Docker serving has no static-host per-file size cap. Thin LTO uses less build
+# memory than the release zip's fat LTO; retain the profile's other optimizations.
+ARG CARGO_PROFILE_WASM_RELEASE_LTO=thin
+ARG CARGO_BUILD_JOBS=1
+# Optional build provenance shown in About and `--version` (crates/engine/src/build_info.rs).
+# .git is not in the build context, so pass it in, e.g.
+# --build-arg PHOTOCRAFT_BUILD_SHA=$(git rev-parse HEAD) --build-arg PHOTOCRAFT_BUILD_DATE=$(date -u +%F)
+# Left empty, the build reports itself as a dev build.
+ARG PHOTOCRAFT_BUILD_SHA=
+ARG PHOTOCRAFT_BUILD_DATE=
+# index.html selects the wasm-release profile, HEIF support and wasm-opt -Oz.
+RUN --mount=type=cache,target=/usr/local/cargo/registry \
+ --mount=type=cache,target=/src/target \
+ --mount=type=cache,target=/root/.cache/trunk \
+ trunk build --release --locked \
+ && test -s /src/dist/web/index.html \
+ && gzip -9 -k /src/dist/web/*.wasm /src/dist/web/*.js /src/dist/web/index.html
+
+FROM nginx:${NGINX_VERSION} AS runtime
+COPY packaging/web/nginx.conf /etc/nginx/nginx.conf
+COPY --from=builder /src/dist/web/ /usr/share/nginx/html/
+COPY LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md /usr/share/nginx/html/
+COPY assets/fonts/OFL-*.txt assets/icons/LICENSE-*.txt assets/dict/LICENSE-*.txt \
+ crates/ui-egui/src/i18n/LICENSE-translations.txt /usr/share/nginx/html/licenses/
+# Bypass the stock entrypoint: this configuration needs no runtime rewriting.
+USER nginx
+EXPOSE 8080
+ENTRYPOINT []
+CMD ["nginx", "-g", "daemon off;"]
+HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
+ CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1
diff --git a/README.md b/README.md
index 21642ee617..787433909c 100644
--- a/README.md
+++ b/README.md
@@ -304,6 +304,8 @@ Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are
Developer, architecture, automation, format, and security documentation is maintained in the [PhotoCraft documentation book](book/).
+To self-host the browser version with Docker, see [Docker web hosting](packaging/web/README.md#docker).
+
## Security
Security architecture, threat modeling, parser hardening, fuzzing, and vulnerability reporting are covered in the [security documentation](book/src/security/) and the repository [security policy](SECURITY.md).
diff --git a/crates/algo/src/resample.rs b/crates/algo/src/resample.rs
index 6ac1c3b35e..918eb899a0 100644
--- a/crates/algo/src/resample.rs
+++ b/crates/algo/src/resample.rs
@@ -294,15 +294,23 @@ fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Opt
unpremultiply(&mut res, n, alpha);
(Rect::new(dst.x0, dst.y0 + b0 as i32, dst.x1, dst.y0 + b1 as i32), res)
};
+ // Bands go into the output a group at a time, so the `f32` rows held at once are bounded by
+ // the group, not by the whole destination (#1544).
#[cfg(not(target_arch = "wasm32"))]
- let parts: Vec<(Rect, Vec)> = {
- use rayon::prelude::*;
- bands.par_iter().map(run).collect()
- };
+ let group = rayon::current_num_threads().max(1) * 2;
#[cfg(target_arch = "wasm32")]
- let parts: Vec<(Rect, Vec)> = bands.iter().map(run).collect();
- for (r, d) in parts {
- out.write_region(r, &d);
+ let group = 1;
+ for chunk in bands.chunks(group) {
+ #[cfg(not(target_arch = "wasm32"))]
+ let parts: Vec<(Rect, Vec)> = {
+ use rayon::prelude::*;
+ chunk.par_iter().map(run).collect()
+ };
+ #[cfg(target_arch = "wasm32")]
+ let parts: Vec<(Rect, Vec)> = chunk.iter().map(run).collect();
+ for (r, d) in parts {
+ out.write_region(r, &d);
+ }
}
if filter == Resample::PreserveDetails && (sx > 1.0 || sy > 1.0) {
let p = crate::FilterParams::UnsharpMask { amount: 30.0, radius: 0.6 * sx.max(sy) as f32, threshold: 0.0 };
diff --git a/crates/codecs/README.md b/crates/codecs/README.md
index 3e2d7d3e37..8d6ae1f803 100644
--- a/crates/codecs/README.md
+++ b/crates/codecs/README.md
@@ -114,8 +114,12 @@ the same.
* CMYK is always written 4:4:4 (subsampled CMYK is not portable) as Adobe-inverted CMYK with an
APP14 marker.
* Text is not written because there is no COM-segment support.
- * EXIF and XMP must each fit in one 64 KiB APP1 segment, otherwise the encoder returns an error.
- Extended XMP is not written.
+ * EXIF and XMP must each fit in one APP1 segment (65 533 bytes with its header). Metadata that
+ doesn't fit is dropped and the export still succeeds, with a
+ `MetadataTooLarge` warning ("EXIF (… bytes) is too large for the format; it will be dropped").
+ Before dropping XMP, the encoder first removes the layered-document properties
+ (`DocumentAncestors`, `TextLayers`) that mean nothing in a flat JPEG. Extended XMP is not
+ written.
* **TIFF decode**
* Classic TIFF and BigTIFF (version 43, 8-byte offsets), both byte orders, strips or tiles,
planar configuration 1 or 2.
@@ -140,7 +144,11 @@ the same.
* **EXR**
* Reads the first valid layer at full resolution, from its data window.
* Channel names are matched by suffix, so `layer.R` counts as `R`.
- * Subsampled channels and deep data are unsupported.
+ * Subsampled channels are unsupported.
+ * Deep data (scanlines or single-level tiles, compressed NONE, RLE or ZIPS) opens as a flat
+ image: each pixel's samples are composited into one, with
+ `DecodeWarning::DeepFlattened` because per-pixel depth is not kept. Deep ZIP (16-line
+ blocks) and deep mip-map or rip-map tiles return `Unsupported`.
* Only lossless compressions are offered for writing: None, RLE, ZIP1, ZIP16 and PIZ.
* **Netpbm writing** picks the subtype from the image: float gives PFM, gray gives P5, RGB gives
P6, and anything with alpha or CMYK gives P7 (PAM). Writing a `.pbm` file therefore produces
diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs
index 6fcfe9ecf0..dd589aef6c 100644
--- a/crates/compose/src/effects.rs
+++ b/crates/compose/src/effects.rs
@@ -706,10 +706,17 @@ fn paint_glow(dst: &mut Buffer, m: &Map, g: &Glow, shape_bounds: Rect, anchor: (
paint(dst, &strength, |i| prepared.sample(1.0 - m.v.get(i).copied().unwrap_or(0.0).clamp(0.0, 1.0)), g.common.blend, g.common.opacity);
}
+/// A box width as the blurs use it: at least 1, at most [`MAX_REACH`] (an effect never reaches
+/// further, see [`margin`]), and 1 for NaN. A size or softness from a command or a file can be
+/// anything, and an unbounded width sizes the kernel and its buffers (#1543).
+fn box_width(w: f32) -> f32 {
+ if w.is_nan() { 1.0 } else { w.clamp(1.0, MAX_REACH) }
+}
+
/// Normalised weights of a centred box of (fractional) width `w`: tap `i` gets the overlap of
/// `[i - 0.5, i + 0.5]` with `[-w/2, w/2]`.
fn box_weights(w: f32) -> Vec {
- let w = w.max(1.0);
+ let w = box_width(w);
let half = w / 2.0;
let r = (half - 0.5).ceil().max(0.0) as i64;
let v: Vec = (-r..=r).map(|i| ((i as f32 + 0.5).min(half) - (i as f32 - 0.5).max(-half)).max(0.0)).collect();
@@ -735,10 +742,11 @@ pub fn tent_kernel(w: f32) -> (i32, Vec) {
/// Box geometry for a (fractional) width: (`r`, end-tap weight `f`, 1 / width) — the
/// [`box_weights`] taps are `r - 1` full ones each side of the centre plus the two end taps at `f`.
fn box_geom(bw: f32) -> (i64, f64, f64) {
- let half = bw.max(1.0) / 2.0;
+ let bw = box_width(bw);
+ let half = bw / 2.0;
let r = (half - 0.5).ceil().max(0.0) as i64;
let f = f64::from((half - (r as f32 - 0.5)).clamp(0.0, 1.0));
- (r, f, 1.0 / f64::from(bw.max(1.0)))
+ (r, f, 1.0 / f64::from(bw))
}
/// One box pass over `src` (zero outside it) evaluated at `x0 .. x0 + dst.len()`, as a running
@@ -1691,6 +1699,11 @@ mod tests {
assert_eq!(r, 4);
assert!(k[0] > 0.0 && k[0] < 1.0 / 25.0);
assert_eq!(tent_kernel(1.0), (0, vec![1.0]));
+ // #1543: a huge, infinite or NaN width is capped, not turned into a kernel of 2^62 taps.
+ for w in [1e30, f32::INFINITY, f32::MAX] {
+ assert_eq!(tent_kernel(w), tent_kernel(MAX_REACH), "{w}");
+ }
+ assert_eq!(tent_kernel(f32::NAN), (0, vec![1.0]));
}
fn no_tex() -> TextureCtx<'static> {
diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs
index a19901d7ec..1daec9973d 100644
--- a/crates/engine/src/image_cmds.rs
+++ b/crates/engine/src/image_cmds.rs
@@ -108,6 +108,9 @@ fn parse_resample(s: &str) -> Option {
})
}
+/// The largest raster Image Size resamples to: the decoders' default allocation budget.
+const MAX_RESAMPLE_BYTES: u64 = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 };
+
/// Image → Image Size.
fn image_size(s: &mut Session, p: &Value) -> Result {
let d = s.active().ok_or(EngineError::NoDocument)?;
@@ -123,6 +126,20 @@ fn image_size(s: &mut Session, p: &Value) -> Result {
(None, None) => (ow as f64, oh as f64),
};
let (nw, nh) = (nw.round().clamp(1.0, 300_000.0) as u32, nh.round().clamp(1.0, 300_000.0) as u32);
+ // Each side may reach 300000 px, but resampling writes real pixels: refuse a canvas whose
+ // raster alone would pass the budget, before allocating any of it (#1544).
+ let bytes = u64::from(nw).saturating_mul(u64::from(nh)).saturating_mul(d.doc.pixel_format().bytes_per_pixel() as u64);
+ if resample.is_some() && (nw, nh) != (ow, oh) && bytes > MAX_RESAMPLE_BYTES {
+ let mp = |w: u32, h: u32| u64::from(w) * u64::from(h) / 1_000_000;
+ let max_mp = MAX_RESAMPLE_BYTES / d.doc.pixel_format().bytes_per_pixel().max(1) as u64 / 1_000_000;
+ return Err(bad(
+ "image.imageSize",
+ format!(
+ "{nw} x {nh} px is {} megapixels; resampling at this bit depth is limited to {max_mp} megapixels. Choose a smaller size, or turn Resample off to change only the resolution",
+ mp(nw, nh)
+ ),
+ ));
+ }
let dpi = p.get("resolution").and_then(Value::as_f64).map(|v| v as f32);
s.edit("Image Size", |doc, _| {
if let Some(r) = dpi {
@@ -574,6 +591,22 @@ mod tests {
/// The canvas border stays opaque after Image Size (it used to fade into transparency, so a
/// Background or a 200 % export got a translucent frame), and a full selection stays full.
+ /// #1544: 64 x 48 to 300000 x 300000 (90 billion pixels) started allocating resampled bands
+ /// without a budget. It is refused before any allocation and the document is untouched; a
+ /// change of resolution only, and an ordinary resize, still work.
+ #[test]
+ fn image_size_refuses_a_raster_past_the_budget() {
+ let mut s = Session::new();
+ s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
+ let before = (doc(&s).size, s.active().unwrap().history.past_len());
+ let err = s.execute("image.imageSize", json!({"width": 300_000, "height": 300_000})).unwrap_err().to_string();
+ assert!(err.contains("megapixels"), "{err}");
+ assert_eq!((doc(&s).size, s.active().unwrap().history.past_len()), before);
+ s.execute("image.imageSize", json!({"width": 300_000, "height": 300_000, "resample": "none", "resolution": 300})).unwrap();
+ s.execute("image.imageSize", json!({"width": 128, "height": 96})).unwrap();
+ assert_eq!(doc(&s).size, Size::new(128, 96));
+ }
+
#[test]
fn image_size_keeps_canvas_edges_opaque() {
for depth in [8, 16, 32] {
diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs
index 07e944c0be..15d4a88d63 100644
--- a/crates/engine/src/layer_style.rs
+++ b/crates/engine/src/layer_style.rs
@@ -753,6 +753,27 @@ mod tests {
}
}
+ /// #1543: a Satin or Bevel & Emboss size or softness far past Photoshop's range rendered a
+ /// blur kernel of 2^62 taps ("capacity overflow"), or ran for minutes at 1e6. The blur width
+ /// is capped at the effect reach, so the document still renders, quickly.
+ #[test]
+ fn oversized_satin_and_bevel_sizes_render() {
+ for (cmd, p) in [
+ ("satin", json!({"size": 1e308})),
+ ("satin", json!({"size": 1_000_000})),
+ ("bevelEmboss", json!({"size": 1e308})),
+ ("bevelEmboss", json!({"soften": 1e308})),
+ ("bevelEmboss", json!({"size": 1_000_000, "soften": 1_000_000})),
+ ] {
+ let mut s = session();
+ s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
+ s.execute(&format!("layer.layerStyle.{cmd}"), p.clone()).unwrap();
+ let start = std::time::Instant::now();
+ s.execute("document.pixel", json!({"x": 0, "y": 0})).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
+ assert!(start.elapsed().as_secs() < 20, "{cmd} {p}: {:?}", start.elapsed());
+ }
+ }
+
#[test]
fn params_are_applied_and_replace_or_add() {
let mut s = session();
diff --git a/docs/architecture.md b/docs/architecture.md
index 63a3bc599b..81bd240652 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -434,9 +434,11 @@ psd/src/
## 12. Automation and extensibility
-- **`automation`:** an MCP server built on `rmcp`. It exposes:
- - `session.list`, `doc.open`, `doc.save`, `doc.export`, `doc.inspect` (layer tree as JSON), `doc.render_preview` (PNG).
- - `command.list` and `command.run(id, params)`, both generated from the registry.
+- **`automation`:** an MCP server built on `rmcp`. Its tool names use underscores (the dotted
+ names such as `doc.open` belong to the JSON control protocol, `docs/control-protocol.md`). It exposes:
+ - `session_list`, `doc_open`, `doc_new`, `doc_save`, `doc_export`, `doc_inspect` (layer tree as JSON), `doc_render_preview` (PNG), `doc_select`, `doc_close`.
+ - `command_list`, `command_run` and `command_batch`, generated from the registry; `jobs_list` and `jobs_cancel` for background jobs.
+ - Bridge only (a running app): `ui_inspect`, `ui_screenshot`, `ui_pointer`, `ui_menu_invoke`, `ui_set`, `control_call`. The full list is in `docs/development.md` › MCP.
- Stdio for agent CLIs, and optionally loopback TCP with a token so it can attach to a running GUI.
- `AuthorizedWorkspace`, which holds independent read and write directory capabilities. Remote
paths are validated relative names; engine commands that still require ambient filesystem
diff --git a/docs/development.md b/docs/development.md
index 3c7303310d..5b3f6f2390 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -143,6 +143,7 @@ Tools:
- `session_list`
- `doc_open`, `doc_new`, `doc_save`, `doc_export`, `doc_inspect`, `doc_render_preview` (returns a PNG image), `doc_select`, `doc_close`
- `command_list`, `command_run`, `command_batch` (several commands per call)
+- `jobs_list` (running background jobs with progress, then recently finished ones with their result or error), `jobs_cancel` (one job by id, or every running job)
- bridge only: `ui_inspect`, `ui_screenshot`, `ui_pointer`, `ui_menu_invoke`, `ui_set`, `control_call`
Claude Code (`.mcp.json` in the repo root, or `claude mcp add`):
@@ -303,6 +304,16 @@ Any static file server works for `dist/web`, for example `python3 -m http.server
URL flags: `?webgl` forces the WebGL2 backend, and `?cpu` forces the CPU canvas path.
+To build and serve the web app entirely in Docker, run from the repository root:
+
+```sh
+docker build --load -t photocraft-web:local .
+docker run --rm -p 8080:8080 photocraft-web:local
+```
+
+Open http://localhost:8080/. See [Docker hosting](../packaging/web/README.md#docker) for
+HTTPS/reverse-proxy deployment and browser limitations.
+
How the web shell (`apps/photocraft-web/src/web.rs`) differs from desktop:
- **Open** uses `rfd::AsyncFileDialog`. The bytes arrive asynchronously in `Services::inbox`, which the app drains every frame.
diff --git a/docs/releasing.md b/docs/releasing.md
index 1122b5251c..3e67c7b549 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -85,6 +85,9 @@ in `ci.yml`. Rules: `../craftrules/standards/fonts.md`; build option: `docs/deve
ticket is stapled to the app. The app goes on a DMG (`hdiutil`, with an `Applications` link
to drag onto). The DMG is signed, notarized and stapled too. The script checks the results
with `codesign --verify --strict`, `stapler validate` and `spctl -a -vvv`.
+ Its Finder window (background, icon size and positions) comes from
+ [`packaging/macos/dmg/`](../packaging/macos/dmg/README.md), and its volume is named `PhotoCraft`
+ without the version, which the window's background needs; the DMG file name keeps the version.
- **CLI:** the universal `photocraft-cli` is signed with the same Developer ID, the hardened
runtime and a secure timestamp (identifier `ai.storyteller.photocraft-cli`), zipped, and the zip
is sent to `notarytool`. Only `.app`, `.dmg` and `.pkg` can hold a stapled ticket, not a bare
diff --git a/packaging/macos/dmg/README.md b/packaging/macos/dmg/README.md
new file mode 100644
index 0000000000..0cdb20a915
--- /dev/null
+++ b/packaging/macos/dmg/README.md
@@ -0,0 +1,44 @@
+# macOS DMG window
+
+What Finder shows when the DMG opens: a 660 × 400 pt background with the app icon and the
+`Applications` link side by side. `package.sh` copies these files into the image; nothing here is
+generated at build time, so the DMG still builds with `hdiutil makehybrid` (no mounted device, no
+Finder scripting on CI, nothing extra installed in the signing job).
+
+| File | What |
+|---|---|
+| `background.svg` | Source of the background: the app icon (`assets/app-icon/photocraft-small.svg`, linked, not copied) cropped as a cover on the PhotoCraft colour field (`#2f7bf5`), Ink and Paper. Its text (Inter, JetBrains Mono) is outlined, so rendering it needs no fonts. |
+| `background.tiff` | The background at 1x (660 × 400 px, 72 dpi) and 2x (1320 × 800 px, 144 dpi) in one HiDPI TIFF (16-colour palette, Deflate, sRGB; about 90 KB). Goes to `.background/background.tiff`. |
+| `dmg-layout.DS_Store` | Finder's view settings for the volume: window size, icon size 128, PhotoCraft.app at (326, 205), `Applications` at (574, 205), and the background. Goes to `.DS_Store` in the image; named so it isn't mistaken for (or ignored like) a Finder-generated `.DS_Store`. |
+| `generate.py` | Writes `background.tiff` and `dmg-layout.DS_Store` from the SVG and the layout above. |
+
+## The volume name has no version
+
+The mounted volume is called `PhotoCraft`, not `PhotoCraft `. The layout points at the background
+through an alias that includes the volume name, and Finder resolves it by that name: with a
+versioned name the window keeps its size and icon positions but shows no background (tested).
+The DMG file name (`photocraft--macos-.dmg`) still carries the version.
+
+## Rules
+
+- **Finder draws the icon labels in black in light and dark mode** when a window has a background,
+ so the area under both icons stays light (Paper).
+- **Nothing goes inside the icon boxes:** artwork keeps 10 pt clear of each 128 pt icon box and of
+ the label strip under it.
+
+## Regenerate
+
+Edit `background.svg` (or the layout constants in `generate.py`), then run, on any OS:
+
+```sh
+pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3
+python3 packaging/macos/dmg/generate.py # needs resvg on PATH
+```
+
+Both outputs are byte-for-byte reproducible and carry nothing from the machine that ran it: the
+SVG renders without any fonts, and `dmg-layout.DS_Store` is written from scratch, its background
+alias holding only the volume name and `/.background/background.tiff`. If you add text to the SVG,
+outline it (`usvg` from resvg converts text to paths). The window is 660 × 432 with Finder's 32 pt
+title bar; the content area is 660 × 400.
+
+This follows VectorCraft's `packaging/macos/dmg/` (storytold/vectorcraft#493).
diff --git a/packaging/macos/dmg/background.svg b/packaging/macos/dmg/background.svg
new file mode 100644
index 0000000000..d93ae37c5e
--- /dev/null
+++ b/packaging/macos/dmg/background.svg
@@ -0,0 +1,28 @@
+
diff --git a/packaging/macos/dmg/background.tiff b/packaging/macos/dmg/background.tiff
new file mode 100644
index 0000000000..d9d675c6b2
Binary files /dev/null and b/packaging/macos/dmg/background.tiff differ
diff --git a/packaging/macos/dmg/dmg-layout.DS_Store b/packaging/macos/dmg/dmg-layout.DS_Store
new file mode 100644
index 0000000000..b939350433
Binary files /dev/null and b/packaging/macos/dmg/dmg-layout.DS_Store differ
diff --git a/packaging/macos/dmg/generate.py b/packaging/macos/dmg/generate.py
new file mode 100644
index 0000000000..be6c7ed59c
--- /dev/null
+++ b/packaging/macos/dmg/generate.py
@@ -0,0 +1,158 @@
+#!/usr/bin/env python3
+"""Regenerate background.tiff and dmg-layout.DS_Store for the macOS DMG window (see README.md here).
+
+Needs resvg on PATH and:
+ pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3
+
+Runs on any OS, no Mac needed. Both outputs come from this script alone, so they carry nothing
+from the machine that made them: no local paths, user or disk names, dates or volume UUIDs.
+
+ python3 packaging/macos/dmg/generate.py
+"""
+
+import datetime
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+
+from ds_store import DSStore
+from mac_alias import Alias, TargetInfo, VolumeInfo
+from PIL import Image, ImageCms
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+
+# Keep in sync with package.sh (volume name) and README.md (layout).
+VOLUME = "PhotoCraft"
+WIDTH, HEIGHT = 660, 400 # window content, pt; the background's 1x size
+TITLE_BAR = 32
+ICON_SIZE = 128
+PALETTE = 16 # colours in background.tiff
+ICONS = {"PhotoCraft.app": (326, 205), "Applications": (574, 205)}
+# A fixed date for the alias and the colour profile. Finder never matches it against the image
+# (each build is a new volume): it finds the background by volume name and path.
+FIXED_DATE = datetime.datetime(2026, 10, 8, tzinfo=datetime.timezone.utc)
+
+
+def render(svg, width, out):
+ # The SVG's text is already outlined and no fonts are loaded, so every machine draws the
+ # same pixels.
+ subprocess.run(
+ ["resvg", "--skip-system-fonts", "-w", str(width), svg, out],
+ check=True,
+ )
+ with Image.open(out) as im:
+ return im.convert("RGB")
+
+
+def quantize(im):
+ # No dithering: dither noise compresses badly and the artwork has flat fills.
+ return im.quantize(PALETTE, method=Image.Quantize.FASTOCTREE, dither=Image.Dither.NONE)
+
+
+def srgb_profile():
+ # LittleCMS's built-in sRGB ("No copyright, use freely"), with its creation date fixed.
+ icc = bytearray(ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes())
+ d = FIXED_DATE
+ icc[24:36] = b"".join(v.to_bytes(2, "big") for v in (d.year, d.month, d.day, 0, 0, 0))
+ if icc[84:100] != bytes(16):
+ sys.exit("error: the sRGB profile has an ID; recompute it after fixing the date")
+ return bytes(icc)
+
+
+def write_tiff(svg, out):
+ """1x (72 dpi) and 2x (144 dpi) in one TIFF, as `tiffutil -cathidpicheck` writes them."""
+ with tempfile.TemporaryDirectory() as tmp:
+ one, two = (
+ quantize(render(svg, WIDTH * s, os.path.join(tmp, f"{s}x.png"))) for s in (1, 2)
+ )
+ two.encoderinfo = {"dpi": (144, 144)} # the rest as the first page
+ # Opaque 16-colour palette, Deflate: the artwork is three inks plus antialiasing, and a palette
+ # keeps the file under ~90 KB (RGB was ~200 KB; repository assets must stay small).
+ one.save(
+ out,
+ "TIFF",
+ save_all=True,
+ append_images=[two],
+ compression="tiff_adobe_deflate", # brand-ok: Pillow's name for TIFF compression 8 (Deflate)
+ dpi=(72, 72),
+ icc_profile=srgb_profile(),
+ )
+ with Image.open(out) as im:
+ im.seek(1)
+ if im.info.get("dpi") != (144, 144):
+ sys.exit("error: the 2x page isn't 144 dpi (needs Pillow 12 or later)")
+ strips = []
+ for page in (0, 1):
+ im.seek(page)
+ strips += zip(im.tag_v2[273], im.tag_v2[279]) # StripOffsets, StripByteCounts
+ # TIFF keeps directories and tag data on even offsets. When a strip ends on an odd one, libtiff
+ # leaves the padding byte after it uninitialised, so the file changed from run to run with
+ # identical pixels. Zero it: nothing refers to that byte.
+ starts = {o for o, _ in strips}
+ with open(out, "r+b") as f:
+ for o, n in strips:
+ if (o + n) % 2 and o + n not in starts:
+ f.seek(o + n)
+ f.write(bytes(1))
+
+
+def background_alias():
+ # What Finder stores for /.background/background.tiff on the volume, minus anything about the
+ # machine (dmgbuild writes the same kind of alias from a mounted image). Disk type 5 (ejectable),
+ # the volume flags and the folder/file ids (25, 26) are what Finder wrote on a test image; the ids
+ # are only hints, as the volume's creation date.
+ volume = VolumeInfo(VOLUME, FIXED_DATE, b"H+", 5, 0x0D02, b"\0\0")
+ volume.posix_path = f"/Volumes/{VOLUME}"
+ target = TargetInfo(0, "background.tiff", 25, 26, FIXED_DATE, b"\0\0\0\0", b"\0\0\0\0")
+ target.folder_name = ".background"
+ target.cnid_path = [25]
+ target.carbon_path = f"{VOLUME}:.background:\0background.tiff"
+ target.posix_path = "/.background/background.tiff"
+ return Alias(volume=volume, target=target).to_bytes()
+
+
+def write_ds_store(out):
+ if os.path.exists(out):
+ os.remove(out)
+ with DSStore.open(out, "w+") as d:
+ d["."]["bwsp"] = {
+ "ShowStatusBar": False,
+ "ShowToolbar": False,
+ "ShowTabView": False,
+ "ContainerShowSidebar": False,
+ "ShowSidebar": False,
+ "WindowBounds": f"{{{{200, 528}}, {{{WIDTH}, {HEIGHT + TITLE_BAR}}}}}",
+ }
+ d["."]["icvp"] = {
+ "viewOptionsVersion": 1,
+ "backgroundType": 2,
+ "backgroundImageAlias": background_alias(),
+ "backgroundColorRed": 1.0,
+ "backgroundColorGreen": 1.0,
+ "backgroundColorBlue": 1.0,
+ "gridOffsetX": 0.0,
+ "gridOffsetY": 0.0,
+ "gridSpacing": 100.0,
+ "arrangeBy": "none",
+ "showIconPreview": True,
+ "showItemInfo": False,
+ "labelOnBottom": True,
+ "textSize": 16.0,
+ "iconSize": float(ICON_SIZE),
+ }
+ d["."]["vSrn"] = ("long", 1)
+ for name, pos in ICONS.items():
+ d[name]["Iloc"] = pos
+
+
+def main():
+ if not shutil.which("resvg"):
+ sys.exit("error: resvg not found (brew install resvg / cargo install resvg --locked)")
+ write_tiff(os.path.join(HERE, "background.svg"), os.path.join(HERE, "background.tiff"))
+ write_ds_store(os.path.join(HERE, "dmg-layout.DS_Store"))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh
index b94ca50687..194b379293 100755
--- a/packaging/macos/package.sh
+++ b/packaging/macos/package.sh
@@ -134,10 +134,15 @@ STAGE="$WORK/dmg"
mkdir -p "$STAGE"
ditto "$APP" "$STAGE/PhotoCraft.app"
ln -s /Applications "$STAGE/Applications"
+# Finder window layout: background, icon size and positions (packaging/macos/dmg/README.md).
+mkdir -p "$STAGE/.background"
+cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff"
+cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store"
rm -f "$DMG" "$WORK/raw.dmg"
# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`,
# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions.
-hdiutil makehybrid -hfs -hfs-volume-name "PhotoCraft $VERSION" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
+# The volume name has no version: .DS_Store finds the background through an alias that includes it.
+hdiutil makehybrid -hfs -hfs-volume-name "PhotoCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
rm -f "$WORK/raw.dmg"
sign "$DMG"
diff --git a/packaging/web/README.md b/packaging/web/README.md
index 96225cd82f..acbb8afd33 100644
--- a/packaging/web/README.md
+++ b/packaging/web/README.md
@@ -12,6 +12,65 @@ static site in `photocraft-web-/`:
There is no server-side code. Upload the folder's contents anywhere that serves static files.
+## Docker
+
+> The Docker recipe (`Dockerfile`, `.dockerignore`, `packaging/web/nginx.conf`) is
+> **community-maintained**: it is not built in CI or used for releases, so it can lag behind the
+> official web build above. Fixes are welcome.
+
+From the repository root (Docker is the only build prerequisite):
+
+```sh
+docker build --load -t photocraft-web:local .
+docker run -d --name photocraft-web --restart unless-stopped \
+ -p 8080:8080 photocraft-web:local
+```
+
+Open **http://localhost:8080/**. The multi-stage `Dockerfile` builds the existing Rust/Wasm
+app with Trunk, including HEIF support, then copies only the static site into NGINX. The
+runtime runs as the `nginx` user on port **8080**, with a `/healthz` endpoint and Docker
+health check. It serves precompressed gzip assets, the Wasm MIME type, immutable hashed
+assets, and an HTML page that revalidates after deployments. No Rust installation, Node.js,
+database, GPU passthrough, or document volume is needed on the host.
+
+The first build downloads Rust dependencies and Trunk's Wasm tools and can take several
+minutes. Subsequent builds reuse Cargo caches. The builder supports Linux amd64 and arm64;
+the default Rust version is the stable release CI used when the `Dockerfile` was last updated
+(CI and releases always use the latest stable), and Trunk is pinned to the release workflow's
+`TRUNK_VERSION`. `RUST_VERSION`, `TRUNK_VERSION`, and `NGINX_VERSION` can be overridden with
+`--build-arg`. To stamp the About dialog with the commit and date instead of "dev build", pass
+`--build-arg PHOTOCRAFT_BUILD_SHA=$(git rev-parse HEAD) --build-arg PHOTOCRAFT_BUILD_DATE=$(date -u +%F)`.
+The web build does not embed the optional `craft-fonts` checkout.
+
+The Docker build uses thin LTO and one Cargo build job to reduce peak memory, while keeping
+the web profile's size optimizations and `wasm-opt -Oz`. Its Wasm size may differ from the
+release zip figures below; NGINX has no 25 MiB file limit. To use the release zip's fat LTO on
+a builder with more memory, pass `--build-arg CARGO_PROFILE_WASM_RELEASE_LTO=fat`.
+`--build-arg CARGO_BUILD_JOBS=4` enables more concurrent compilation on larger builders. A compiler killed with
+`SIGKILL`/`cannot allocate memory` means the Docker/Podman VM needs more memory or swap.
+
+For a public deployment, put an HTTPS reverse proxy or your hosting platform's TLS endpoint
+in front of container port 8080. With a reverse proxy on the same host, bind only loopback
+(`-p 127.0.0.1:8080:8080`); with a proxy container, connect both through a Docker network.
+HTTPS enables WebGPU and browser clipboard APIs. If hosting under `/photocraft/`, redirect
+`/photocraft` to `/photocraft/` and strip that prefix when proxying to the container. Relative
+asset URLs then work without rebuilding. For example, in an existing HTTPS NGINX server:
+
+```nginx
+location = /photocraft { return 301 /photocraft/; }
+location /photocraft/ {
+ proxy_pass http://127.0.0.1:8080/;
+}
+```
+
+The editor and image processing run on the visitor's device. Open uses the browser file
+picker; Save and Export download files. Preferences use browser localStorage. There is no
+server-side document storage, desktop TCP control server, or web autosave/crash recovery;
+save/download work before closing or reloading the page. A browser with WebGPU or WebGL2 is
+required; `?webgl` forces the fallback when troubleshooting.
+
+To verify the browser deployment, open the page, open an image and save/export it.
+
## Sizes
Measured on the 0.2.x build (`packaging/web/package.sh`; gzip `-9`, Brotli quality 11):
diff --git a/packaging/web/nginx.conf b/packaging/web/nginx.conf
new file mode 100644
index 0000000000..3e49455ef0
--- /dev/null
+++ b/packaging/web/nginx.conf
@@ -0,0 +1,58 @@
+worker_processes auto;
+pid /tmp/nginx.pid;
+error_log /dev/stderr warn;
+
+events {
+ worker_connections 1024;
+}
+
+http {
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+ access_log /dev/stdout;
+ sendfile on;
+ server_tokens off;
+
+ # All runtime writes go to /tmp, allowing a read-only container filesystem.
+ client_body_temp_path /tmp/client_temp;
+ proxy_temp_path /tmp/proxy_temp;
+ fastcgi_temp_path /tmp/fastcgi_temp;
+ uwsgi_temp_path /tmp/uwsgi_temp;
+ scgi_temp_path /tmp/scgi_temp;
+
+ # Precompressed at build time; uncompressed clients receive the original.
+ gzip_static on;
+ gzip_vary on;
+
+ server {
+ listen 8080;
+ listen [::]:8080;
+ root /usr/share/nginx/html;
+ index index.html;
+ add_header X-Content-Type-Options nosniff always;
+
+ location = /healthz {
+ access_log off;
+ default_type text/plain;
+ return 200 "ok\n";
+ }
+
+ location = /index.html {
+ add_header Cache-Control no-cache always;
+ add_header X-Content-Type-Options nosniff always;
+ }
+
+ # Trunk gives these files content hashes. Missing assets must be 404s,
+ # never an HTML fallback or an error cached for a year.
+ location ~ \.(wasm|js)$ {
+ types { application/wasm wasm; text/javascript js; }
+ add_header Cache-Control "public, max-age=31536000, immutable";
+ add_header X-Content-Type-Options nosniff always;
+ try_files $uri =404;
+ }
+
+ location / {
+ try_files $uri $uri/ =404;
+ }
+ }
+}