mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
M0.11: web build (WASM)
apps/lightcraft-web runs the egui UI in the browser (eframe web, glow/WebGL2): procedural demo library on load; in-memory imports via file picker (Cmd+Shift+I) and drag-and-drop (MemStore + bytes-based engine hooks); export goes through app.export and downloads in the browser; `?bench` logs first-frame and slider latency. `cargo xtask web [--serve [port]] [--dev]` builds with the matching wasm-bindgen CLI into <target>/web/; the web app is part of the CI wasm check. Wasm fixes: lazy profiling Instant in pipeline, web-time clocks in ui-egui (render timings were 0 on wasm). Docs: docs/web.md. No persistence on web yet. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
17057f7bbc
commit
2d2bc2ad8f
16 files changed
+885
-27
No files matched your search
Generated
+20
@@ -934,6 +934,7 @@ dependencies = [
|
||||
"egui-wgpu",
|
||||
"egui-winit",
|
||||
"egui_glow",
|
||||
"glow",
|
||||
"glutin",
|
||||
"glutin-winit",
|
||||
"image",
|
||||
@@ -2182,6 +2183,25 @@ dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-web"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-engine",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-ui-egui",
|
||||
"log",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+13
@@ -58,6 +58,13 @@ egui_extras = { version = "0.36", default-features = false }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
rfd = "0.17"
|
||||
web-time = "1"
|
||||
|
||||
# web (apps/lightcraft-web)
|
||||
wasm-bindgen = "0.2.129"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = "0.3"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "deny"
|
||||
@@ -84,3 +91,9 @@ inherits = "release"
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
debug = false
|
||||
|
||||
# `cargo xtask web`: the browser build (no debug info; size matters more than build time)
|
||||
[profile.web]
|
||||
inherits = "release"
|
||||
debug = false
|
||||
lto = "thin"
|
||||
@@ -226,7 +226,8 @@ LightCraft is young and moving fast — see the [roadmap](ROADMAP.md) for estima
|
||||
| Remove / Heal / Clone spots (auto source) | ✅ · content-aware fill (PatchMatch), Red Eye 🚧 |
|
||||
| Export: JPEG / PNG / TIFF / WebP / AVIF, sizing, file-size limit, output sharpening, naming, batch | ✅ · metadata policy, watermark, DNG ⬜ |
|
||||
| MCP server, CLI, control channel | ✅ |
|
||||
| Optics, Geometry/Upright, GPU pipeline, web build, persistent library | 🚧 |
|
||||
| Optics, Geometry/Upright, GPU pipeline, persistent library | 🚧 |
|
||||
| Web build (same UI in the browser via WASM; in-memory imports, export downloads) | ✅ · persistence, workers 🚧 |
|
||||
|
||||
<br>
|
||||
|
||||
@@ -237,10 +238,14 @@ git clone https://github.com/storytold/lightcraft && cd lightcraft
|
||||
cargo run --release -p lightcraft # opens with a procedurally generated demo library
|
||||
cargo run --release -p lightcraft -- ~/Pictures/trip # or point it at your photos (folders are scanned)
|
||||
cargo run --release -p lightcraft -- --control 7980 # with the automation channel
|
||||
cargo xtask web --serve # the same app in the browser: http://127.0.0.1:8080/
|
||||
cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5
|
||||
cargo xtask ci # fmt, clippy, tests, layering, wasm checks
|
||||
```
|
||||
|
||||
The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI
|
||||
(`cargo xtask web` prints the exact install command); see [docs/web.md](docs/web.md).
|
||||
|
||||
Keyboard: <kbd>G</kbd> grid · <kbd>D</kbd> detail · <kbd>E</kbd> edit · <kbd>C</kbd> crop · <kbd>M</kbd> masking ·
|
||||
<kbd>Shift</kbd>+<kbd>P</kbd> presets · <kbd>\\</kbd> original · <kbd>Y</kbd> before/after · <kbd>Z</kbd> zoom ·
|
||||
<kbd>J</kbd> clipping · <kbd>⌘Z</kbd> undo · <kbd>⌘/</kbd> all shortcuts.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
[package]
|
||||
name = "lightcraft-web"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "LightCraft in the browser: the egui UI compiled to WASM (build with `cargo xtask web`)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
lightcraft-engine = { workspace = true }
|
||||
lightcraft-ui-egui = { workspace = true }
|
||||
lightcraft-codecs = { workspace = true }
|
||||
lightcraft-raster = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
eframe = { workspace = true, features = ["default_fonts", "glow"] }
|
||||
egui = { workspace = true }
|
||||
wasm-bindgen = { workspace = true }
|
||||
wasm-bindgen-futures = { workspace = true }
|
||||
js-sys = { workspace = true }
|
||||
web-sys = { workspace = true, features = [
|
||||
"Blob",
|
||||
"BlobPropertyBag",
|
||||
"Document",
|
||||
"Element",
|
||||
"File",
|
||||
"FileList",
|
||||
"HtmlAnchorElement",
|
||||
"HtmlCanvasElement",
|
||||
"HtmlElement",
|
||||
"HtmlInputElement",
|
||||
"Location",
|
||||
"Performance",
|
||||
"Url",
|
||||
"Window",
|
||||
] }
|
||||
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>LightCraft</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<meta name="description" content="LightCraft: open-source photo library and raw developer, running in your browser (WASM).">
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #1c1c1c; }
|
||||
canvas#lightcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#lightcraft_loading {
|
||||
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
color: #b8b8b8; font: 15px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; pointer-events: none;
|
||||
}
|
||||
#lightcraft_status { position: absolute; left: 0; bottom: 0; width: 1px; height: 1px; overflow: hidden; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="lightcraft_canvas"></canvas>
|
||||
<div id="lightcraft_loading">Loading LightCraft…</div>
|
||||
<div id="lightcraft_status" aria-live="polite"></div>
|
||||
<script type="module">
|
||||
import init from "./lightcraft_web.js";
|
||||
init().catch((e) => {
|
||||
console.error(e);
|
||||
const el = document.getElementById("lightcraft_loading");
|
||||
if (el) el.textContent = "LightCraft failed to load: " + e;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Scripted in-app measurement (`?bench`): open the first photo in Detail, then drive the Exposure
|
||||
//! control exactly like a slider drag (begin interaction → N `develop.set` → end) and time each
|
||||
//! step from the command to the moment the new loupe texture is ready. Results go to the log
|
||||
//! (the browser console on the web) as one `lightcraft-bench {…json…}` line.
|
||||
|
||||
use lightcraft_ui_egui::LightcraftApp;
|
||||
use lightcraft_ui_egui::render::Slot;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const STEPS: usize = 8;
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Phase {
|
||||
/// Wait for the grid's thumbnails to finish.
|
||||
Idle,
|
||||
/// Detail view requested; wait for the loupe.
|
||||
Opening {
|
||||
t0: f64,
|
||||
},
|
||||
/// A step was issued at `t0`; wait for a Main texture with a new key.
|
||||
Step {
|
||||
i: usize,
|
||||
t0: f64,
|
||||
key: u64,
|
||||
},
|
||||
Done,
|
||||
}
|
||||
|
||||
pub struct Bench {
|
||||
phase: Phase,
|
||||
frames: u32,
|
||||
start_ms: f64,
|
||||
first_frame_ms: Option<f64>,
|
||||
thumbs_done_ms: Option<f64>,
|
||||
open_ms: f64,
|
||||
drafts: Vec<f64>,
|
||||
jobs: Vec<f64>,
|
||||
final_ms: f64,
|
||||
}
|
||||
|
||||
impl Bench {
|
||||
/// `start_ms`: the page's time origin in the same clock as [`lightcraft_ui_egui::now_ms`].
|
||||
pub fn new(start_ms: f64) -> Self {
|
||||
Bench {
|
||||
phase: Phase::Idle,
|
||||
frames: 0,
|
||||
start_ms,
|
||||
first_frame_ms: None,
|
||||
thumbs_done_ms: None,
|
||||
open_ms: 0.0,
|
||||
drafts: vec![],
|
||||
jobs: vec![],
|
||||
final_ms: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn main_key(app: &LightcraftApp) -> Option<u64> {
|
||||
app.renderer.textures.get(&Slot::Main).map(|t| t.key)
|
||||
}
|
||||
|
||||
/// Call once per frame after `LightcraftApp::logic`. Returns the report when finished.
|
||||
pub fn step(&mut self, app: &mut LightcraftApp) -> Option<Value> {
|
||||
let now = lightcraft_ui_egui::now_ms();
|
||||
self.frames += 1;
|
||||
if self.first_frame_ms.is_none() && self.frames > 1 {
|
||||
self.first_frame_ms = Some(now - self.start_ms);
|
||||
}
|
||||
let idle = app.renderer.queued() == 0;
|
||||
match self.phase {
|
||||
Phase::Idle => {
|
||||
let thumbs = app.renderer.textures.keys().filter(|s| matches!(s, Slot::Thumb(_))).count();
|
||||
if idle && thumbs > 0 && self.frames > 3 {
|
||||
self.thumbs_done_ms = Some(now - self.start_ms);
|
||||
let first = app.session.visible().first().copied();
|
||||
if let Some(id) = first {
|
||||
let _ = app.run("library.select", json!({"ids": [id.0]}));
|
||||
}
|
||||
let _ = app.run("view.detail", json!({}));
|
||||
self.phase = Phase::Opening { t0: now };
|
||||
}
|
||||
}
|
||||
Phase::Opening { t0 } => {
|
||||
if idle && Self::main_key(app).is_some() && self.frames > 3 {
|
||||
self.open_ms = now - t0;
|
||||
let _ = app.run("develop.beginInteraction", json!({"label": "Exposure"}));
|
||||
self.issue(app, 0, now);
|
||||
}
|
||||
}
|
||||
Phase::Step { i, t0, key } => {
|
||||
if idle && Self::main_key(app).is_some_and(|k| k != key) {
|
||||
let dt = now - t0;
|
||||
if i < STEPS {
|
||||
self.drafts.push(dt);
|
||||
self.jobs.push(app.renderer.last_main_ms);
|
||||
if i + 1 < STEPS {
|
||||
self.issue(app, i + 1, now);
|
||||
} else {
|
||||
// release: the full-resolution render after the drag
|
||||
let key = Self::main_key(app).unwrap_or(0);
|
||||
let _ = app.run("develop.endInteraction", json!({}));
|
||||
let _ = app.run("develop.set", json!({"control": "light.exposure", "value": 0.0}));
|
||||
self.phase = Phase::Step { i: STEPS, t0: now, key };
|
||||
}
|
||||
} else {
|
||||
self.final_ms = dt;
|
||||
self.phase = Phase::Done;
|
||||
return Some(self.report(app));
|
||||
}
|
||||
}
|
||||
}
|
||||
Phase::Done => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn issue(&mut self, app: &mut LightcraftApp, i: usize, now: f64) {
|
||||
let key = Self::main_key(app).unwrap_or(0);
|
||||
let v = 0.15 * (i as f64 + 1.0);
|
||||
let _ = app.run("develop.set", json!({"control": "light.exposure", "value": v}));
|
||||
self.phase = Phase::Step { i, t0: now, key };
|
||||
}
|
||||
|
||||
fn report(&self, app: &LightcraftApp) -> Value {
|
||||
let mut d = self.drafts.clone();
|
||||
d.sort_by(f64::total_cmp);
|
||||
let median = d.get(d.len() / 2).copied().unwrap_or(0.0);
|
||||
let mean = if d.is_empty() { 0.0 } else { d.iter().sum::<f64>() / d.len() as f64 };
|
||||
let tex = app.renderer.textures.get(&Slot::Main).map(|t| t.size).unwrap_or([0, 0]);
|
||||
let r = |x: f64| (x * 10.0).round() / 10.0;
|
||||
json!({
|
||||
"first_frame_ms": self.first_frame_ms.map(r),
|
||||
"thumbs_done_ms": self.thumbs_done_ms.map(r),
|
||||
"open_detail_ms": r(self.open_ms),
|
||||
"slider_draft_ms": d.iter().copied().map(r).collect::<Vec<_>>(),
|
||||
"slider_draft_median_ms": r(median),
|
||||
"slider_draft_mean_ms": r(mean),
|
||||
"slider_job_ms": self.jobs.iter().copied().map(r).collect::<Vec<_>>(),
|
||||
"release_full_ms": r(self.final_ms),
|
||||
"loupe_px": tex,
|
||||
"frames": self.frames,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! LightCraft in the browser: the same egui UI as the desktop app, compiled to WASM.
|
||||
//!
|
||||
//! Build with `cargo xtask web` (→ `target/web/`), then serve that folder over HTTP.
|
||||
//! Differences from the desktop host:
|
||||
//! - no filesystem: photos picked (File ▸ Add Photos…) or dropped onto the page are held in
|
||||
//! memory ([`store::MemStore`]) and decoded from bytes;
|
||||
//! - export downloads the file through the browser;
|
||||
//! - renders run inline on the main thread (one job per frame) — no worker threads yet;
|
||||
//! - no persistence yet (UI prefs and the catalog reset on reload);
|
||||
//! - the procedural demo library is loaded at start so the page is never empty.
|
||||
//!
|
||||
//! `?bench` in the URL runs a scripted first-paint / slider-latency measurement and logs it to the
|
||||
//! console (see [`bench`]).
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod bench;
|
||||
pub mod store;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
@@ -0,0 +1,144 @@
|
||||
//! In-memory "filesystem" for the web build: files picked or dropped in the browser are kept as
|
||||
//! bytes under synthetic paths (`mem/<n>/<name>`), and the engine reads them through the same
|
||||
//! [`FileLoader`]/[`FileProbe`] hooks the desktop app backs with `std::fs`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use lightcraft_engine::files::{load_bytes, probe_bytes};
|
||||
use lightcraft_engine::media::{FileLoader, FileProbe};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Inner {
|
||||
files: HashMap<String, Arc<[u8]>>,
|
||||
/// Paths added since the last [`MemStore::take_pending`] (to import on the next frame).
|
||||
pending: Vec<String>,
|
||||
next: u64,
|
||||
}
|
||||
|
||||
/// Shared, cloneable byte store. File reads in the browser are async, so additions arrive from
|
||||
/// promise callbacks and are picked up by the app on its next frame.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct MemStore(Arc<Mutex<Inner>>);
|
||||
|
||||
impl MemStore {
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
|
||||
self.0.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Store a file's bytes; returns its synthetic path and queues it for import.
|
||||
pub fn add(&self, name: &str, bytes: Vec<u8>) -> String {
|
||||
let mut g = self.lock();
|
||||
g.next += 1;
|
||||
let name = name.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or("photo");
|
||||
let path = format!("mem/{}/{name}", g.next);
|
||||
g.files.insert(path.clone(), bytes.into());
|
||||
g.pending.push(path.clone());
|
||||
path
|
||||
}
|
||||
|
||||
pub fn get(&self, path: &str) -> Option<Arc<[u8]>> {
|
||||
self.lock().files.get(path).cloned()
|
||||
}
|
||||
|
||||
/// Paths added since the last call.
|
||||
pub fn take_pending(&self) -> Vec<String> {
|
||||
std::mem::take(&mut self.lock().pending)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.lock().files.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
/// Engine hooks that decode/probe from this store.
|
||||
pub fn hooks(&self) -> (FileLoader, FileProbe) {
|
||||
let s = self.clone();
|
||||
let loader: FileLoader = Arc::new(move |path: &str, max_edge: usize| {
|
||||
let bytes = s.get(path).ok_or_else(|| format!("{path}: not in memory"))?;
|
||||
load_bytes(&bytes, max_edge)
|
||||
});
|
||||
let s = self.clone();
|
||||
let probe: FileProbe = Arc::new(move |path: &str| {
|
||||
let bytes = s.get(path).ok_or_else(|| format!("{path}: not in memory"))?;
|
||||
probe_bytes(path, &bytes)
|
||||
});
|
||||
(loader, probe)
|
||||
}
|
||||
|
||||
/// Install the hooks into a session.
|
||||
pub fn install(&self, session: &mut lightcraft_engine::Session) {
|
||||
let (l, p) = self.hooks();
|
||||
session.media.file_loader = Some(l);
|
||||
session.media.file_probe = Some(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// File name part of an export path (the browser download name).
|
||||
pub fn download_name(path: &str) -> &str {
|
||||
path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or("export.png")
|
||||
}
|
||||
|
||||
/// MIME type for a download, by extension.
|
||||
pub fn mime_for(name: &str) -> &'static str {
|
||||
match name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).as_deref() {
|
||||
Some("jpg" | "jpeg") => "image/jpeg",
|
||||
Some("png") => "image/png",
|
||||
Some("tif" | "tiff") => "image/tiff",
|
||||
Some("webp") => "image/webp",
|
||||
Some("avif") => "image/avif",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn png_bytes(w: usize, h: usize) -> Vec<u8> {
|
||||
let bytes: Vec<u8> = (0..w * h).flat_map(|i| [(i * 7) as u8, 128, 200, 255]).collect();
|
||||
let img = lightcraft_raster::Rgba8::from_bytes(w, h, &bytes).unwrap();
|
||||
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_take_get() {
|
||||
let s = MemStore::default();
|
||||
let a = s.add("C:\\x\\a.png", vec![1, 2]);
|
||||
let b = s.add("a.png", vec![3]);
|
||||
assert_ne!(a, b, "same name twice gets distinct paths");
|
||||
assert!(a.ends_with("/a.png"));
|
||||
assert_eq!(s.take_pending(), vec![a.clone(), b]);
|
||||
assert!(s.take_pending().is_empty());
|
||||
assert_eq!(&*s.get(&a).unwrap(), &[1, 2]);
|
||||
assert_eq!(s.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_and_render_from_memory() {
|
||||
let store = MemStore::default();
|
||||
let mut session = lightcraft_engine::Session::new();
|
||||
store.install(&mut session);
|
||||
let path = store.add("tiny.png", png_bytes(40, 30));
|
||||
let r = session.execute("library.import", &json!({"paths": [path]})).unwrap();
|
||||
let id = lightcraft_engine::catalog::PhotoId(r["imported"][0].as_u64().unwrap());
|
||||
let p = session.catalog.photo(id).unwrap();
|
||||
assert_eq!((p.width, p.height), (40, 30));
|
||||
assert_eq!(p.file_name, "tiny.png");
|
||||
let out = session.render_now(id, 20, 20).unwrap();
|
||||
assert_eq!(out.image.width, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_names() {
|
||||
assert_eq!(download_name("dir/x-lightcraft.png"), "x-lightcraft.png");
|
||||
assert_eq!(download_name("y.png"), "y.png");
|
||||
assert_eq!(mime_for("a.JPG"), "image/jpeg");
|
||||
assert_eq!(mime_for("a"), "application/octet-stream");
|
||||
assert_eq!(download_name(""), "export.png");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Browser host: starts eframe on `<canvas id="lightcraft_canvas">`, wires the in-memory store to
|
||||
//! the file picker and drag-and-drop, and turns exports into downloads.
|
||||
|
||||
use lightcraft_engine::Session;
|
||||
use lightcraft_ui_egui::{LightcraftApp, Services};
|
||||
use serde_json::json;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::bench::Bench;
|
||||
use crate::store::{MemStore, download_name};
|
||||
|
||||
const ACCEPT: &str = ".jpg,.jpeg,.png,.tif,.tiff,.webp,.dng,.cr2,.nef,.arw,.psd,.jxl,.gif,.bmp";
|
||||
|
||||
fn window() -> web_sys::Window {
|
||||
web_sys::window().expect("no window")
|
||||
}
|
||||
|
||||
/// Milliseconds since navigation start.
|
||||
fn perf_now() -> f64 {
|
||||
window().performance().map(|p| p.now()).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn set_status(text: &str) {
|
||||
if let Some(el) = window().document().and_then(|d| d.get_element_by_id("lightcraft_status")) {
|
||||
el.set_text_content(Some(text));
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a browser `File` into the store.
|
||||
async fn read_file(store: MemStore, file: web_sys::File, ctx: egui::Context) {
|
||||
match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
|
||||
Ok(buf) => {
|
||||
store.add(&file.name(), js_sys::Uint8Array::new(&buf).to_vec());
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::warn!("reading {}: {e:?}", file.name()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the browser's open dialog; picked files land in the store asynchronously.
|
||||
fn open_picker(store: MemStore, ctx: egui::Context) {
|
||||
let Some(doc) = window().document() else { return };
|
||||
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
|
||||
input.set_type("file");
|
||||
input.set_multiple(true);
|
||||
input.set_accept(ACCEPT);
|
||||
let inp = input.clone();
|
||||
let on_change = Closure::<dyn FnMut()>::new(move || {
|
||||
if let Some(files) = inp.files() {
|
||||
for i in 0..files.length() {
|
||||
if let Some(f) = files.get(i) {
|
||||
wasm_bindgen_futures::spawn_local(read_file(store.clone(), f, ctx.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
|
||||
on_change.forget();
|
||||
input.click();
|
||||
}
|
||||
|
||||
/// Offer `bytes` as a download named after `path`.
|
||||
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let e = |e: JsValue| format!("download failed: {e:?}");
|
||||
let arr = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
let name = download_name(path);
|
||||
opts.set_type(crate::store::mime_for(name));
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&arr, &opts).map_err(e)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(e)?;
|
||||
let doc = window().document().ok_or("no document")?;
|
||||
let a: web_sys::HtmlAnchorElement = doc.create_element("a").map_err(e)?.unchecked_into();
|
||||
a.set_href(&url);
|
||||
a.set_download(name);
|
||||
a.click();
|
||||
// revoke once the click has been handled
|
||||
let revoke = Closure::once_into_js(move || {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
});
|
||||
let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 5000);
|
||||
log::info!("exported {name} ({} bytes)", bytes.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn services(store: MemStore, ctx: egui::Context) -> Services {
|
||||
Services {
|
||||
pick_files: Some(Box::new(move || {
|
||||
open_picker(store.clone(), ctx.clone());
|
||||
Vec::new() // files arrive asynchronously and are imported on a later frame
|
||||
})),
|
||||
write: Some(Box::new(download)),
|
||||
png: Some(Box::new(|img: &lightcraft_raster::Rgba8| {
|
||||
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
struct WebApp {
|
||||
app: LightcraftApp,
|
||||
store: MemStore,
|
||||
bench: Option<Bench>,
|
||||
first_frame_logged: bool,
|
||||
}
|
||||
|
||||
impl WebApp {
|
||||
fn new(cc: &eframe::CreationContext<'_>, bench: bool) -> Self {
|
||||
let store = MemStore::default();
|
||||
let t = perf_now();
|
||||
let mut session = Session::with_demo();
|
||||
log::info!("lightcraft: wasm started at {:.0} ms; demo library built in {:.0} ms", t, perf_now() - t);
|
||||
store.install(&mut session);
|
||||
// previews are large (≤ 2560 px, f32): keep few in a 32-bit address space
|
||||
session.media.preview_capacity = 3;
|
||||
let mut app = LightcraftApp::new(session, services(store.clone(), cc.egui_ctx.clone()));
|
||||
app.ui = app.ui.sanitized();
|
||||
let origin = lightcraft_ui_egui::now_ms() - perf_now();
|
||||
WebApp { app, store, bench: bench.then(|| Bench::new(origin)), first_frame_logged: false }
|
||||
}
|
||||
|
||||
fn import_dropped(&mut self, ctx: &egui::Context) {
|
||||
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
|
||||
for f in dropped {
|
||||
let (store, ctx) = (self.store.clone(), ctx.clone());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let name = f.path().to_string_lossy().to_string();
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
store.add(&name, bytes);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::warn!("reading dropped {name}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
let paths = self.store.take_pending();
|
||||
if !paths.is_empty() {
|
||||
let n = paths.len();
|
||||
match self.app.run("library.import", json!({"paths": paths})) {
|
||||
Ok(_) => self.app.toast(ctx, format!("Added {n} photo{}", if n == 1 { "" } else { "s" })),
|
||||
Err(e) => log::warn!("import: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for WebApp {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.import_dropped(ctx);
|
||||
self.app.logic(ctx);
|
||||
if let Some(b) = self.bench.as_mut()
|
||||
&& let Some(report) = b.step(&mut self.app)
|
||||
{
|
||||
log::info!("lightcraft-bench {report}");
|
||||
set_status(&format!("bench: {report}"));
|
||||
self.bench = None;
|
||||
}
|
||||
if self.bench.is_some() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(raw);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.app.ui(ui);
|
||||
if !self.first_frame_logged && !self.app.widgets.is_empty() {
|
||||
self.first_frame_logged = true;
|
||||
log::info!("lightcraft first UI frame at {:.0} ms after navigation start", perf_now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now());
|
||||
let bench = window().location().search().is_ok_and(|q| q.contains("bench"));
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(canvas) =
|
||||
window().document().and_then(|d| d.get_element_by_id("lightcraft_canvas")).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
|
||||
else {
|
||||
log::error!("missing <canvas id=\"lightcraft_canvas\">");
|
||||
return;
|
||||
};
|
||||
let runner = eframe::WebRunner::new();
|
||||
let r = runner.start(canvas, eframe::WebOptions::default(), Box::new(move |cc| Ok(Box::new(WebApp::new(cc, bench))))).await;
|
||||
match r {
|
||||
Ok(()) => {
|
||||
if let Some(el) = window().document().and_then(|d| d.get_element_by_id("lightcraft_loading")) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("LightCraft failed to start: {e:?}");
|
||||
set_status(&format!("LightCraft failed to start: {e:?}"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -98,14 +98,14 @@ pub fn output_size(src_w: usize, src_h: usize, s: &DevelopSettings, req: &Render
|
||||
/// Render `src` with settings `s`.
|
||||
pub fn render(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest) -> Rendered {
|
||||
let prof = std::env::var_os("LIGHTCRAFT_PROFILE").is_some();
|
||||
let t0 = std::time::Instant::now();
|
||||
let lap = |what: &str, t: &mut std::time::Instant| {
|
||||
if prof {
|
||||
// `Instant::now()` panics on wasm32-unknown-unknown: only read the clock when profiling.
|
||||
let lap = |what: &str, t: &mut Option<std::time::Instant>| {
|
||||
if let Some(t) = t {
|
||||
eprintln!(" {what}: {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
*t = std::time::Instant::now();
|
||||
}
|
||||
};
|
||||
let mut t = t0;
|
||||
let mut t = prof.then(std::time::Instant::now);
|
||||
let s = &*profiles::effective(s);
|
||||
let frame = geometry::Frame::new(src.width, src.height, s, req.apply_crop);
|
||||
let (w, h) = frame.fit(req.max_w, req.max_h);
|
||||
|
||||
@@ -15,6 +15,7 @@ lightcraft-raster = { workspace = true }
|
||||
lightcraft-develop = { workspace = true }
|
||||
lightcraft-pipeline = { workspace = true }
|
||||
lightcraft-catalog = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
lightcraft-engine = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -266,16 +266,10 @@ impl LightcraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wall-clock milliseconds since the Unix epoch (`web-time` maps to `Date.now()` on the web).
|
||||
pub fn now_ms() -> f64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
0.0
|
||||
}
|
||||
use web_time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Whether the settings render in black & white.
|
||||
|
||||
@@ -61,15 +61,7 @@ impl Default for Renderer {
|
||||
}
|
||||
|
||||
fn now() -> f64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
0.0
|
||||
}
|
||||
crate::now_ms()
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
@@ -142,8 +134,9 @@ impl Renderer {
|
||||
best.map(|b| g.remove(b))
|
||||
};
|
||||
if let Some((slot, _, job)) = job {
|
||||
let t0 = now();
|
||||
let r = job.run();
|
||||
let _ = self.results_tx.send((slot, r, 0.0));
|
||||
let _ = self.results_tx.send((slot, r, now() - t0));
|
||||
}
|
||||
}
|
||||
let mut changed = false;
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
# LightCraft in the browser
|
||||
|
||||
`apps/lightcraft-web` runs the same egui UI as the desktop app (`crates/ui-egui`) in the browser,
|
||||
compiled to WebAssembly and drawn with WebGL2 (eframe's `glow` backend).
|
||||
|
||||
## Build and run locally
|
||||
|
||||
One-time setup:
|
||||
|
||||
```sh
|
||||
rustup target add wasm32-unknown-unknown
|
||||
cargo install wasm-bindgen-cli --version <the wasm-bindgen version in Cargo.lock> --locked
|
||||
```
|
||||
|
||||
`cargo xtask web` checks the CLI against `Cargo.lock` and prints the exact install command if
|
||||
it's missing or a different version.
|
||||
|
||||
Build, then serve:
|
||||
|
||||
```sh
|
||||
cargo xtask web # → <target>/web/{index.html, lightcraft_web.js, lightcraft_web_bg.wasm}
|
||||
cargo xtask web --serve # build, then serve on http://127.0.0.1:8080/ (or `--serve 9000`)
|
||||
cargo xtask web --dev # unoptimized build with debug info (faster to compile, slow to run)
|
||||
```
|
||||
|
||||
`<target>` is `target/` unless `CARGO_TARGET_DIR` is set. Any static HTTP server works, as long as
|
||||
it serves `.wasm` as `application/wasm`. The bundle can't be opened from `file://`. If `wasm-opt`
|
||||
(binaryen) is on `PATH`, the release build also runs it over the module.
|
||||
|
||||
The release build uses the `web` Cargo profile (release + thin LTO, no debug info).
|
||||
|
||||
## What works
|
||||
|
||||
- **Demo library on load.** The procedural scenes from `lightcraft-scenes` are loaded at
|
||||
start, so the page always has something in it.
|
||||
- **Importing photos with no filesystem.** *File ▸ Add Photos…* (<kbd>⌘⇧I</kbd>) opens the
|
||||
browser's file picker. You can also drop files anywhere on the page. The bytes are kept in memory
|
||||
(`store::MemStore`) under synthetic paths (`mem/<n>/<name>`) and decoded by the same engine code
|
||||
as the desktop app (`lightcraft_engine::files::{probe_bytes, load_bytes}`). That covers
|
||||
JPEG/PNG/TIFF/WebP and the supported raw formats.
|
||||
- **Every develop control** (sliders, curves, mixer, grading, masking, crop…) works as it does
|
||||
on the desktop, since it's the same crate.
|
||||
- **Export downloads the file.** *Export…* (<kbd>⌘⇧E</kbd>) runs the same `app.export` path as
|
||||
the desktop app (`lightcraft_engine::export`: JPEG/PNG/TIFF/WebP, sizing, naming). The host's
|
||||
`write` service hands each file to the browser as a download instead of writing it to disk.
|
||||
|
||||
## Not yet
|
||||
|
||||
- **Persistence.** Nothing is saved across reloads: the catalog, edits, imported files and UI
|
||||
prefs all start fresh. The planned route is IndexedDB/OPFS for the op log, and file handles
|
||||
where the browser supports them.
|
||||
- **Worker threads.** Renders run on the main thread, one job per frame. On a cold load the grid
|
||||
fills in over a few seconds, and the UI can stall briefly while a thumbnail is generated. The fix
|
||||
is Web Workers, which need wasm threads (`+atomics`, SharedArrayBuffer, COOP/COEP headers).
|
||||
- **Bundle size.** The module is about 12 MB uncompressed, because it includes every codec and
|
||||
raw decoder. Serving it gzip/brotli-compressed or running `wasm-opt` shrinks it a lot.
|
||||
- **Control channel / MCP.** These are desktop-only, because they need a TCP socket.
|
||||
- **AVIF export** is untested in the browser; it's the one encoder that may not be wasm-safe.
|
||||
|
||||
## Measuring
|
||||
|
||||
Open `http://127.0.0.1:8080/?bench` to run a scripted measurement:
|
||||
|
||||
1. Wait for the grid thumbnails.
|
||||
2. Open the first photo in Detail.
|
||||
3. Drag Exposure through 8 steps, exactly as a slider drag does (begin interaction → `develop.set`
|
||||
×8 → end).
|
||||
|
||||
The page then logs one console line:
|
||||
|
||||
```
|
||||
lightcraft-bench {"first_frame_ms":…,"thumbs_done_ms":…,"slider_draft_ms":[…],"slider_draft_median_ms":…,"slider_job_ms":[…],"release_full_ms":…}
|
||||
```
|
||||
|
||||
- `slider_draft_ms`: time from the `develop.set` command until the new loupe texture is ready.
|
||||
- `slider_job_ms`: the pipeline's share of that time.
|
||||
- `release_full_ms`: the full-quality render after the drag ends.
|
||||
+9
-3
@@ -6,6 +6,7 @@
|
||||
mod assets;
|
||||
mod layers;
|
||||
mod stats;
|
||||
mod web;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitCode};
|
||||
@@ -16,7 +17,10 @@ usage: cargo xtask <command>
|
||||
commands:
|
||||
assets every image/icon/font/media file is attributed in assets/ATTRIBUTION.md; no Adobe assets
|
||||
layers enforce the crate dependency layering (plan/architecture.md §3)
|
||||
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
|
||||
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (+ the web app)
|
||||
web [--serve [port]] [--dev]
|
||||
build the browser app (apps/lightcraft-web) into <target>/web/;
|
||||
--serve serves it on http://127.0.0.1:<port> (default 8080)
|
||||
ci fmt --check, clippy -D warnings, test, layers, assets, wasm (stops at first failure)
|
||||
corpus [--download]
|
||||
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
|
||||
@@ -30,6 +34,7 @@ fn main() -> ExitCode {
|
||||
Some("layers") => cmd_layers(),
|
||||
Some("assets") => assets::run(&root()),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("web") => web::run(&rest),
|
||||
Some("ci") => cmd_ci(),
|
||||
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
|
||||
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
|
||||
@@ -59,7 +64,7 @@ pub fn cargo() -> Command {
|
||||
c
|
||||
}
|
||||
|
||||
fn run(mut cmd: Command, what: &str) -> Result<(), String> {
|
||||
pub fn run(mut cmd: Command, what: &str) -> Result<(), String> {
|
||||
eprintln!("$ {what}");
|
||||
let status = cmd.status().map_err(|e| format!("{what}: failed to spawn: {e}"))?;
|
||||
if status.success() { Ok(()) } else { Err(format!("{what}: exited with {status}")) }
|
||||
@@ -108,7 +113,7 @@ fn cmd_layers() -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// Workspace packages that must build for wasm32: all L0–L5 crates plus
|
||||
/// the egui shell.
|
||||
/// the egui shell and the web app.
|
||||
fn wasm_set() -> Result<Vec<String>, String> {
|
||||
let crates = layers::from_metadata(&metadata()?)?;
|
||||
Ok(crates
|
||||
@@ -119,6 +124,7 @@ fn wasm_set() -> Result<Vec<String>, String> {
|
||||
_ => false,
|
||||
})
|
||||
.map(|c| c.name)
|
||||
.chain(std::iter::once("lightcraft-web".to_string()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! `cargo xtask web`: build `apps/lightcraft-web` for wasm32 and bundle it with `wasm-bindgen`
|
||||
//! into `<target>/web/` (index.html + lightcraft_web.js + lightcraft_web_bg.wasm).
|
||||
//! `--serve [port]` then serves that folder with a tiny static HTTP server (std only).
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::{cargo, metadata, root, run as step};
|
||||
|
||||
const TARGET: &str = "wasm32-unknown-unknown";
|
||||
|
||||
/// The `wasm-bindgen` version pinned in Cargo.lock (the CLI must match it exactly).
|
||||
fn locked_bindgen_version() -> Result<String, String> {
|
||||
let lock = std::fs::read_to_string(root().join("Cargo.lock")).map_err(|e| format!("Cargo.lock: {e}"))?;
|
||||
let mut lines = lock.lines();
|
||||
while let Some(l) = lines.next() {
|
||||
if l.trim() == "name = \"wasm-bindgen\""
|
||||
&& let Some(v) = lines.next().and_then(|v| v.trim().strip_prefix("version = \""))
|
||||
{
|
||||
return Ok(v.trim_end_matches('"').to_string());
|
||||
}
|
||||
}
|
||||
Err("wasm-bindgen not found in Cargo.lock".into())
|
||||
}
|
||||
|
||||
fn check_bindgen(want: &str) -> Result<(), String> {
|
||||
let hint = format!("install it with:\n cargo install wasm-bindgen-cli --version {want} --locked");
|
||||
let out = Command::new("wasm-bindgen").arg("--version").output().map_err(|_| format!("`wasm-bindgen` CLI not found; {hint}"))?;
|
||||
let have = String::from_utf8_lossy(&out.stdout);
|
||||
let have = have.split_whitespace().nth(1).unwrap_or("?");
|
||||
if have != want {
|
||||
return Err(format!("wasm-bindgen CLI is {have} but Cargo.lock has {want}; {hint}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run(args: &[&str]) -> Result<(), String> {
|
||||
let dev = args.contains(&"--dev");
|
||||
let serve = args.iter().position(|a| *a == "--serve").map(|i| args.get(i + 1).and_then(|p| p.parse::<u16>().ok()).unwrap_or(8080));
|
||||
|
||||
let want = locked_bindgen_version()?;
|
||||
check_bindgen(&want)?;
|
||||
let meta = metadata()?;
|
||||
let target_dir = PathBuf::from(meta["target_directory"].as_str().ok_or("cargo metadata: no target_directory")?);
|
||||
let profile = if dev { "dev" } else { "web" };
|
||||
|
||||
let mut c = cargo();
|
||||
c.args(["build", "-p", "lightcraft-web", "--lib", "--target", TARGET, "--profile", profile]);
|
||||
step(c, &format!("cargo build -p lightcraft-web --target {TARGET} --profile {profile}"))?;
|
||||
|
||||
let wasm = target_dir.join(TARGET).join(if dev { "debug" } else { profile }).join("lightcraft_web.wasm");
|
||||
let out = target_dir.join("web");
|
||||
std::fs::create_dir_all(&out).map_err(|e| format!("{}: {e}", out.display()))?;
|
||||
let mut b = Command::new("wasm-bindgen");
|
||||
b.arg(&wasm).args(["--target", "web", "--no-typescript", "--out-name", "lightcraft_web", "--out-dir"]).arg(&out);
|
||||
if dev {
|
||||
b.arg("--debug");
|
||||
}
|
||||
step(b, &format!("wasm-bindgen {} → {}", wasm.display(), out.display()))?;
|
||||
|
||||
// optional size pass when binaryen is installed
|
||||
let bg = out.join("lightcraft_web_bg.wasm");
|
||||
if !dev && Command::new("wasm-opt").arg("--version").output().is_ok() {
|
||||
let mut o = Command::new("wasm-opt");
|
||||
o.args(["-O2", "--enable-bulk-memory", "--enable-nontrapping-float-to-int", "--enable-sign-ext", "--enable-mutable-globals"])
|
||||
.arg(&bg)
|
||||
.arg("-o")
|
||||
.arg(&bg);
|
||||
if step(o, "wasm-opt -O2").is_err() {
|
||||
eprintln!("(wasm-opt failed; keeping the unoptimized module)");
|
||||
}
|
||||
}
|
||||
let index = root().join("apps/lightcraft-web/index.html");
|
||||
std::fs::copy(&index, out.join("index.html")).map_err(|e| format!("copy index.html: {e}"))?;
|
||||
let size = std::fs::metadata(&bg).map(|m| m.len()).unwrap_or(0);
|
||||
println!("\nweb build ready: {} ({:.1} MB wasm)", out.display(), size as f64 / 1e6);
|
||||
|
||||
match serve {
|
||||
Some(port) => serve_dir(&out, port),
|
||||
None => {
|
||||
println!("serve it with `cargo xtask web --serve` (or any static HTTP server) and open http://127.0.0.1:8080/");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mime(path: &Path) -> &'static str {
|
||||
match path.extension().and_then(|e| e.to_str()).unwrap_or("") {
|
||||
"html" => "text/html; charset=utf-8",
|
||||
"js" => "text/javascript; charset=utf-8",
|
||||
"wasm" => "application/wasm",
|
||||
"css" => "text/css; charset=utf-8",
|
||||
"json" => "application/json",
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"svg" => "image/svg+xml",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve `dir` on 127.0.0.1:`port` (GET/HEAD only, no directory listing). Blocks.
|
||||
fn serve_dir(dir: &Path, port: u16) -> Result<(), String> {
|
||||
let listener = TcpListener::bind(("127.0.0.1", port)).map_err(|e| format!("bind 127.0.0.1:{port}: {e}"))?;
|
||||
println!("serving {} at http://127.0.0.1:{port}/ (Ctrl+C to stop)", dir.display());
|
||||
for stream in listener.incoming().flatten() {
|
||||
if let Err(e) = handle(stream, dir) {
|
||||
eprintln!("http: {e}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle(mut stream: TcpStream, dir: &Path) -> std::io::Result<()> {
|
||||
let mut reader = BufReader::new(stream.try_clone()?);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line)?;
|
||||
// drain headers
|
||||
let mut h = String::new();
|
||||
while reader.read_line(&mut h)? > 2 {
|
||||
h.clear();
|
||||
}
|
||||
let mut parts = line.split_whitespace();
|
||||
let method = parts.next().unwrap_or("");
|
||||
let target = parts.next().unwrap_or("/");
|
||||
let path = target.split(['?', '#']).next().unwrap_or("/");
|
||||
let rel = path.trim_start_matches('/');
|
||||
let rel = if rel.is_empty() { "index.html" } else { rel };
|
||||
let file = dir.join(rel);
|
||||
let safe = !rel.split('/').any(|c| c == ".." || c.contains('\\'));
|
||||
let (status, body, ctype) = match (method, safe, std::fs::read(&file)) {
|
||||
("GET" | "HEAD", true, Ok(b)) => ("200 OK", b, mime(&file)),
|
||||
("GET" | "HEAD", _, _) => ("404 Not Found", b"not found".to_vec(), "text/plain"),
|
||||
_ => ("405 Method Not Allowed", Vec::new(), "text/plain"),
|
||||
};
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
)?;
|
||||
if method != "HEAD" {
|
||||
stream.write_all(&body)?;
|
||||
}
|
||||
stream.flush()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bindgen_version_is_locked() {
|
||||
let v = locked_bindgen_version().unwrap();
|
||||
assert!(v.starts_with("0.2."), "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wasm_mime() {
|
||||
assert_eq!(mime(Path::new("a/lightcraft_web_bg.wasm")), "application/wasm");
|
||||
assert_eq!(mime(Path::new("index.html")), "text/html; charset=utf-8");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user