mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 11:16:53 -04:00
Fix main: wasm size cap, Windows Export As test, filter preview window (scope and a crash), CLI duplicate font warnings (#4147)
* test(ui-egui): Export As artboards test accepts the Windows path separator The test added in #3909 compared the second file's path with a literal '/', but the exporter joins the sibling name with the platform separator, so Windows CI saw '/out\boards-2-Capsule.jpg' and failed. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(ui): only Filter menu dialogs get the in-dialog preview window #3877 drew the preview window in every schema dialog with a Preview checkbox, including Image > Rotation > Arbitrary. Photoshop's Rotate Canvas and adjustment dialogs have no preview window, and the taller dialog covered the canvas, which broke arbitrary_rotation_preview::size_changing_rotation_preview_is_visible_and_preserves_the_panned_center on main. The window and its reserved height now apply to filter.* commands only. Co-Authored-By: Claude Opus 5.5 <[email protected]> * build(web): opt-level z for large cold crates in the wasm release profile The browser wasm sat at the 24 MiB gate. The document model, file parsers (psd, format, affinity), serde_json, usvg and naga are off the per-frame and per-pixel paths; opt-level "z" for them takes the release wasm from 25,186,233 to 24,829,840 bytes (-356 KB) with no behaviour change. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(cli): run prints each missing-font warning once Since #3630 the headless open and save also report missing fonts, so `run <file> --out` and `run --cmd ... --out` printed the same warning two or three times and missing_typeface_is_reported_by_run_info_and_convert failed on main. Warnings from the open and the save now go through the same already-reported set as the per-command ones. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(ui): the filter preview window never uploads a texture past the GPU limit With low-resolution previews off, the preview window from #3877 uploaded the whole document as one texture. egui panics on a texture bigger than max_texture_side, so opening a filter dialog on a large image crashed (full_res_preview::filter_dialog_previews_at_full_resolution_when_low_res_is_off). The texture is now reduced to fit; the view maps through UV fractions, so it still lines up. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
2db6b7d8f9
commit
184c226a76
5 files changed
+79
-6
No files matched your search
+18
@@ -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]
|
||||
|
||||
@@ -292,11 +292,15 @@ fn command_list(a: &Args) -> Result<Vec<(String, Value)>, 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::<String>::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<String> = 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::<String>::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<String> = 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<String> =
|
||||
serde_json::from_value::<Vec<String>>(r["warnings"].clone()).unwrap_or_default().into_iter().filter(|w| !reported_fonts.contains(w)).collect();
|
||||
warn_all(err, &ws);
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
got.sort_unstable();
|
||||
assert_eq!(got, [(10, 25), (20, 15)], "each board at its own size, scaled to 50 %");
|
||||
|
||||
@@ -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<String, Value>) {
|
||||
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<String, Value>) -> 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<String, Value>) -> 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));
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user