mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts: # crates/ui-egui/src/vector_ui.rs
This commit is contained in:
@@ -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
|
||||
@@ -10,3 +10,4 @@ target/
|
||||
/book/output
|
||||
/craft-fonts
|
||||
*.log
|
||||
__pycache__/
|
||||
|
||||
@@ -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 | <http://wordlist.aspell.net/> | 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)
|
||||
|
||||
|
||||
+68
@@ -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
|
||||
@@ -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).
|
||||
|
||||
@@ -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<f32>)> = {
|
||||
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<f32>)> = 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<f32>)> = {
|
||||
use rayon::prelude::*;
|
||||
chunk.par_iter().map(run).collect()
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let parts: Vec<(Rect, Vec<f32>)> = 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 };
|
||||
|
||||
+11
-3
@@ -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
|
||||
|
||||
@@ -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<f32> {
|
||||
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<f32> = (-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<f32>) {
|
||||
/// 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> {
|
||||
|
||||
@@ -108,6 +108,9 @@ fn parse_resample(s: &str) -> Option<Resample> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<Value> {
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
@@ -123,6 +126,20 @@ fn image_size(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(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] {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <version>`. 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-<version>-macos-<arch>.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).
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
@@ -12,6 +12,65 @@ static site in `photocraft-web-<version>/`:
|
||||
|
||||
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):
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user