diff --git a/Cargo.toml b/Cargo.toml index 10bbfc3b4..6b13954fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -109,6 +109,24 @@ opt-level = "z" [profile.wasm-release.package.photocraft-web] opt-level = "z" +# Large crates off the per-frame and per-pixel paths: the document model, file parsers run once +# per open/save, JSON (settings, presets, control channel), SVG parsing, and shader translation +# (runs when a pipeline is created). "z" keeps the wasm well under the size gate. +[profile.wasm-release.package.photocraft-doc] +opt-level = "z" +[profile.wasm-release.package.photocraft-psd] +opt-level = "z" +[profile.wasm-release.package.photocraft-format] +opt-level = "z" +[profile.wasm-release.package.photocraft-affinity] +opt-level = "z" +[profile.wasm-release.package.serde_json] +opt-level = "z" +[profile.wasm-release.package.usvg] +opt-level = "z" +[profile.wasm-release.package.naga] +opt-level = "z" + [profile.wasm-release.package.photocraft-raster] opt-level = 3 [profile.wasm-release.package.photocraft-color] diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 560eaf71f..93090610a 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -292,11 +292,15 @@ fn command_list(a: &Args) -> Result, String> { fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { let opts = export_opts(a)?; let mut h = Headless::trusted_local(); + // Each missing-font warning is printed once, wherever it is first detected (open, a command or + // the save). + let mut reported_fonts = std::collections::HashSet::::new(); match (a.positional.as_slice(), a.get("--new")) { ([file], None) => { let o = h.open(Path::new(file)).map_err(|e| e.to_string())?; let ws: Vec = serde_json::from_value(o["warnings"].clone()).unwrap_or_default(); warn_all(err, &ws); + reported_fonts.extend(ws); } ([], Some(new)) => { let p: Value = serde_json::from_str(new).map_err(|e| format!("bad --new JSON: {e}"))?; @@ -308,7 +312,6 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { if cmds.is_empty() && a.get("--out").is_none() { return Err("run needs at least one --cmd (or --out)".into()); } - let mut reported_fonts = std::collections::HashSet::::new(); for (id, params) in cmds { let r = h.command_run(&id, params).map_err(|e| format!("`{id}`: {e}"))?; if let Some(e) = photocraft_engine::actions_cmds::nested_failure(&id, &r) { @@ -341,7 +344,9 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { warn_all(err, &remaining); if let Some(o) = a.get("--out") { let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?; - let ws: Vec = serde_json::from_value(r["warnings"].clone()).unwrap_or_default(); + // The save repeats the document's missing-font warnings; each was already reported above. + let ws: Vec = + serde_json::from_value::>(r["warnings"].clone()).unwrap_or_default().into_iter().filter(|w| !reported_fonts.contains(w)).collect(); warn_all(err, &ws); } Ok(()) diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 3081de03f..202cc7388 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -719,7 +719,9 @@ fn export_as_with_several_artboards_selected_writes_one_file_each() { assert!(!app.ui.status_error, "{}", app.ui.status); assert_eq!(written.borrow().len(), 2); assert_eq!(written.borrow()[0], "/out/boards.jpg"); - assert!(written.borrow()[1].starts_with("/out/boards-2-") && written.borrow()[1].ends_with(".jpg"), "{:?}", written.borrow()); + // The sibling path is joined with the platform separator (`/out\boards-2-…` on Windows). + let second = written.borrow()[1].replace('\\', "/"); + assert!(second.starts_with("/out/boards-2-") && second.ends_with(".jpg"), "{:?}", written.borrow()); let mut got = sizes.borrow().iter().map(|(_, w, h)| (*w, *h)).collect::>(); got.sort_unstable(); assert_eq!(got, [(10, 25), (20, 15)], "each board at its own size, scaled to 50 %"); diff --git a/crates/ui-egui/src/filter_preview_ui.rs b/crates/ui-egui/src/filter_preview_ui.rs index 5f4ac1392..bf2cb47b5 100644 --- a/crates/ui-egui/src/filter_preview_ui.rs +++ b/crates/ui-egui/src/filter_preview_ui.rs @@ -30,7 +30,7 @@ const CENTER_Y_FIELD: &str = "__pvCy"; /// Draw the filter dialog's in-dialog preview window below its parameters (its `fields` `f`). A /// no-op for dialogs without a preview and for Select and Mask, which has its own view. pub(crate) fn window(app: &PhotocraftApp, dialog: u64, ui: &mut egui::Ui, f: &mut Map) { - if !f.contains_key("__preview") || crate::select_mask_view::owns(f) { + if !shows(f) { return; } let Some(cmd) = f.get("__command").and_then(Value::as_str).map(str::to_owned) else { return }; @@ -145,7 +145,14 @@ const ZOOM_STEPS: [f32; 7] = [0.125, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0]; /// The vertical space the preview window takes below the parameters: the image, the zoom row and /// their spacing. `0` when the dialog has no preview (so the caller reserves nothing). pub(crate) fn reserved_height(f: &Map) -> f32 { - if f.contains_key("__preview") && !crate::select_mask_view::owns(f) { PREVIEW_H + 32.0 } else { 0.0 } + if shows(f) { PREVIEW_H + 32.0 } else { 0.0 } +} + +/// Whether the dialog with fields `f` gets the preview window: Filter menu dialogs with a Preview +/// checkbox, as in Photoshop. Image › Rotation › Arbitrary and the adjustment dialogs keep only +/// the on-canvas preview, and Select and Mask has its own view. +fn shows(f: &Map) -> bool { + f.contains_key("__preview") && !crate::select_mask_view::owns(f) && f.get("__command").and_then(Value::as_str).is_some_and(|c| c.starts_with("filter.")) } /// The next zoom level up or down from `zoom`, clamped to [`ZOOM_STEPS`]' ends. @@ -194,12 +201,29 @@ fn document_size(proxy: [u32; 2], k: u32) -> Vec2 { /// A composite as monitor pixels, matching the canvas (`canvas::display_image`). fn upload(ctx: &egui::Context, app: &PhotocraftApp, name: &str, doc: &Document) -> egui::TextureHandle { + // Never exceed what the GPU accepts (egui panics on an oversized texture): with low-resolution + // previews off the proxy is the full document. The view maps through UV fractions, so a reduced + // texture still lines up. + let k = texture_factor(doc.size.width, doc.size.height, ctx.input(|i| i.max_texture_side)); + let reduced; + let doc = if k > 1 { + reduced = crate::proxy::proxy_document(doc, k); + &reduced + } else { + doc + }; let buf = photocraft_compose::flatten(doc); let (display, _) = crate::canvas::canvas_display(app, doc, None); let img = crate::canvas::display_image(display.as_deref(), &buf); ctx.load_texture(name, img, egui::TextureOptions::LINEAR) } +/// The reduction that fits a `w`×`h` image inside a `max_side` texture (1: it already fits). +fn texture_factor(w: u32, h: u32, max_side: usize) -> u32 { + let max_side = u32::try_from(max_side).unwrap_or(u32::MAX).max(1); + w.max(h).div_ceil(max_side).max(1) +} + /// What a cached texture was built from, so it is rebuilt only when its source changes. #[derive(Clone, PartialEq)] enum TexKey { @@ -238,6 +262,30 @@ mod tests { assert_eq!(step_zoom(f32::NAN, false), MIN_ZOOM); } + #[test] + fn only_filter_dialogs_get_the_preview_window() { + let fields = |cmd: &str| json!({"__preview": true, "__command": cmd}).as_object().cloned().unwrap(); + assert!(shows(&fields("filter.blur.gaussianBlur"))); + assert!(reserved_height(&fields("filter.blur.gaussianBlur")) > 0.0); + // Photoshop's Rotate Canvas and adjustment dialogs have no preview window. + assert!(!shows(&fields("image.rotation.arbitrary"))); + assert_eq!(reserved_height(&fields("image.rotation.arbitrary")), 0.0); + assert!(!shows(&fields("adjust.levels"))); + // No Preview checkbox or no command: no window. + assert!(!shows(json!({"__command": "filter.blur.gaussianBlur"}).as_object().unwrap())); + assert!(!shows(json!({"__preview": true}).as_object().unwrap())); + } + + #[test] + fn textures_are_reduced_to_fit_the_gpu() { + assert_eq!(texture_factor(3000, 2000, 2048), 2); + assert_eq!(texture_factor(2048, 100, 2048), 1); + assert_eq!(texture_factor(9000, 9000, 2048), 5); + // A zero or absurd limit never divides by zero. + assert_eq!(texture_factor(3000, 2000, 0), 3000); + assert_eq!(texture_factor(0, 0, 2048), 1); + } + #[test] fn the_centre_is_clamped_to_the_document() { assert_eq!(clamp_center(-5.0, 99.0, 100.0, 50.0), (0.0, 50.0)); diff --git a/docs/development.md b/docs/development.md index 1236dd65c..74b61de1d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -352,7 +352,7 @@ trunk build --release # writes ../../dist/web (index.html, .js glue trunk serve --release # dev server on http://127.0.0.1:8765 ``` -Any static file server works for `dist/web`, for example `python3 -m http.server 8765` run inside that directory. Trunk downloads the matching `wasm-bindgen` and `wasm-opt` itself. `trunk build --release` uses the `wasm-release` Cargo profile (`data-cargo-profile` in `index.html`: fat LTO, opt-level "z" for `photocraft-ui-egui`, "s" for other crates except pixel processing, which keeps level 3). The UI override reduces browser code size while retaining the speed-oriented settings for filters and compositing. Serve the `.wasm` with compression; its raw size varies with the source and compiler. Keep it under 24 MiB (`packaging/web/package.sh` enforces this; Cloudflare's per-file cap is 25 MiB). Pull-request CI also builds the actual browser app with the same pinned Trunk version as the release job and runs `packaging/web/package.sh`, including the relative-URL and 24 MiB checks. The `web build and wasm size gate` check runs on main and on PRs that change something the web build compiles or packages (`apps/photocraft-web`, `packaging/web`, crate sources and manifests, `Cargo.toml`/`Cargo.lock`, the CI workflow), and reports the resulting wasm size in the Actions job summary. The web build never embeds craft-fonts (see Fonts above). To see where the bytes go, run `twiggy top -n 40` on `target/wasm32-unknown-unknown/wasm-release/photocraft-web.wasm` (before wasm-opt strips the names). +Any static file server works for `dist/web`, for example `python3 -m http.server 8765` run inside that directory. Trunk downloads the matching `wasm-bindgen` and `wasm-opt` itself. `trunk build --release` uses the `wasm-release` Cargo profile (`data-cargo-profile` in `index.html`: fat LTO, opt-level "z" for the UI, engine, I/O and web shells and for large crates off the hot paths (document model, file parsers, JSON, SVG parsing, shader translation), "s" for other crates except pixel processing, which keeps level 3). The UI override reduces browser code size while retaining the speed-oriented settings for filters and compositing. Serve the `.wasm` with compression; its raw size varies with the source and compiler. Keep it under 24 MiB (`packaging/web/package.sh` enforces this; Cloudflare's per-file cap is 25 MiB). Pull-request CI also builds the actual browser app with the same pinned Trunk version as the release job and runs `packaging/web/package.sh`, including the relative-URL and 24 MiB checks. The `web build and wasm size gate` check runs on main and on PRs that change something the web build compiles or packages (`apps/photocraft-web`, `packaging/web`, crate sources and manifests, `Cargo.toml`/`Cargo.lock`, the CI workflow), and reports the resulting wasm size in the Actions job summary. The web build never embeds craft-fonts (see Fonts above). To see where the bytes go, run `twiggy top -n 40` on `target/wasm32-unknown-unknown/wasm-release/photocraft-web.wasm` (before wasm-opt strips the names). URL flags: `?webgl` forces the WebGL2 backend, and `?cpu` forces the CPU canvas path.