M0.13: web render workers — pipeline renders off the main thread
Renders run in dedicated module Web Workers, each a second instance of the app's wasm module (index.html compiles it once and posts the WebAssembly.Module to worker.js). A job crosses as JSON (WireJob: source, settings, request); the worker reads originals and cached thumbnails from browser storage itself, keeps decoded sources and per-view stage caches, and transfers RGBA back. Jobs follow the worker that decoded the photo; the loupe waits for it. No wasm threads or SharedArrayBuffer needed; falls back to inline rendering if no worker starts (?workers=0). Thumbnail disk cache on the web: thumbs/<key>.jpg in OPFS/IndexedDB with an index on the main thread, pruned to the desktop budget (2 GB → 80 %, LRU). ui-egui: RenderOffload hook in the Renderer (queue/priorities/dedup stay in the UI). engine: RenderJob.origin + MediaCache::origin_ref; preview: encode_jpeg/decode_jpeg public. xtask web: copies worker.js; --serve sends COOP/COEP/CORP (documented in docs/web.md). Headless Chrome, cold load + grid scroll, 24 demo photos: main-thread busy 12.4 s → 0.7–1.1 s, long tasks 23 (max 1.1 s) → 0; grid filled in 6.3–8.4 s vs 13–16 s; slider job 4 ms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
961cf4afd2
commit
8f2058a124
|
|
@ -232,7 +232,6 @@ impl Backend {
|
|||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // thumbnail cache pruning (render workers)
|
||||
pub async fn remove(&self, path: &str) -> Result<(), String> {
|
||||
match self {
|
||||
Backend::Opfs(root) => {
|
||||
|
|
@ -252,7 +251,6 @@ impl Backend {
|
|||
}
|
||||
|
||||
/// Names of the files directly inside `dir`.
|
||||
#[allow(dead_code)] // thumbnail cache pruning (render workers)
|
||||
pub async fn list(&self, dir: &str) -> Result<Vec<String>, String> {
|
||||
match self {
|
||||
Backend::Opfs(root) => {
|
||||
|
|
|
|||
|
|
@ -6,19 +6,23 @@
|
|||
//! main thread): catalog journal, presets, view state and prefs are mirrored in memory and
|
||||
//! flushed in the background ([`files`]); imported originals are stored by content hash
|
||||
//! ([`store`]); a new library starts with the procedural demo photos;
|
||||
//! - renders run inline on the main thread (one job per frame) — no worker threads yet;
|
||||
//! - renders run in Web Workers, each a second instance of this module ([`wire`], `workers`), and
|
||||
//! fall back to the main thread (one job per frame) if workers can't start;
|
||||
//! - export downloads the file through the browser.
|
||||
//!
|
||||
//! `?bench` in the URL runs a scripted first-paint / slider-latency measurement and logs it to the
|
||||
//! console (see [`bench`]); `?store=idb` forces the IndexedDB backend, `?store=memory` disables
|
||||
//! persistence and `?reset` clears the stored library.
|
||||
//! persistence and `?workers=0` renders on the main thread.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod bench;
|
||||
pub mod files;
|
||||
pub mod store;
|
||||
pub mod wire;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod backend;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod workers;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! Browser host: opens the library in browser storage, starts eframe on
|
||||
//! Browser host: opens the library in browser storage, starts the render workers and eframe on
|
||||
//! `<canvas id="lightcraft_canvas">`, wires the file picker and drag-and-drop, and turns exports
|
||||
//! into downloads.
|
||||
|
||||
|
|
@ -18,13 +18,15 @@ use crate::backend::Backend;
|
|||
use crate::bench::Bench;
|
||||
use crate::files::{Files, FlushOp, LIBRARY_FILES};
|
||||
use crate::store::{Originals, content_hash, download_name, storage_key};
|
||||
use crate::wire::ThumbIndex;
|
||||
use crate::workers::{THUMB_INDEX, Workers};
|
||||
|
||||
const ACCEPT: &str = ".jpg,.jpeg,.png,.tif,.tiff,.webp,.dng,.cr2,.nef,.arw,.psd,.jxl,.gif,.bmp";
|
||||
|
||||
/// Where the library files live in browser storage.
|
||||
const LIBRARY_DIR: &str = "library";
|
||||
|
||||
/// How often view state and UI prefs are saved (ms).
|
||||
/// How often view state, UI prefs and the thumbnail index are saved (ms).
|
||||
const SAVE_EVERY_MS: f64 = 1000.0;
|
||||
|
||||
fn window() -> web_sys::Window {
|
||||
|
|
@ -46,11 +48,12 @@ fn library_key(name: &str) -> String {
|
|||
format!("{LIBRARY_DIR}/{name}")
|
||||
}
|
||||
|
||||
/// URL options (`?bench&store=idb&reset`).
|
||||
/// URL options (`?bench&store=idb&workers=2&reset`).
|
||||
struct Options {
|
||||
bench: bool,
|
||||
/// "opfs" (default: OPFS, falling back to IndexedDB), "idb" or "memory".
|
||||
store: String,
|
||||
workers: Option<usize>,
|
||||
reset: bool,
|
||||
}
|
||||
|
||||
|
|
@ -70,6 +73,7 @@ impl Options {
|
|||
Options {
|
||||
bench: get("bench").is_some(),
|
||||
store: get("store").filter(|s| s == "idb" || s == "memory").unwrap_or_else(|| "opfs".into()),
|
||||
workers: get("workers").and_then(|v| v.parse().ok()),
|
||||
reset: get("reset").is_some(),
|
||||
}
|
||||
}
|
||||
|
|
@ -184,6 +188,7 @@ struct Boot {
|
|||
opts: Options,
|
||||
backend: Option<Backend>,
|
||||
files: Files,
|
||||
index: ThumbIndex,
|
||||
}
|
||||
|
||||
async fn boot(opts: Options) -> Boot {
|
||||
|
|
@ -200,6 +205,7 @@ async fn boot(opts: Options) -> Boot {
|
|||
}
|
||||
};
|
||||
let files = Files::default();
|
||||
let mut index = ThumbIndex::default();
|
||||
if let Some(b) = &backend {
|
||||
if opts.reset {
|
||||
match b.clear().await {
|
||||
|
|
@ -214,10 +220,38 @@ async fn boot(opts: Options) -> Boot {
|
|||
Err(e) => log::error!("reading {name}: {e}"),
|
||||
}
|
||||
}
|
||||
if let Ok(Some(j)) = b.read(THUMB_INDEX).await {
|
||||
index = ThumbIndex::from_json(&j);
|
||||
}
|
||||
crate::backend::request_persistence();
|
||||
// thumbnails stored without an index entry (index saved late, or lost): delete them
|
||||
let b2 = b.clone();
|
||||
let known: std::collections::HashSet<String> = b
|
||||
.list("thumbs")
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|n| n.ends_with(".jpg"))
|
||||
.map(|n| n.trim_end_matches(".jpg").to_string())
|
||||
.collect();
|
||||
let orphans: Vec<String> = known.into_iter().filter(|k| !index.contains(k)).collect();
|
||||
if !orphans.is_empty() {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
for k in &orphans {
|
||||
let _ = b2.remove(&crate::wire::thumb_storage_key(k)).await;
|
||||
}
|
||||
log::info!("thumbnail cache: removed {} unindexed files", orphans.len());
|
||||
});
|
||||
}
|
||||
}
|
||||
log::info!("lightcraft: storage {} opened in {:.0} ms", backend.as_ref().map_or("memory", |b| b.kind()), perf_now() - t0);
|
||||
Boot { opts, backend, files }
|
||||
log::info!(
|
||||
"lightcraft: storage {} opened in {:.0} ms ({} thumbnails indexed, {:.1} MB)",
|
||||
backend.as_ref().map_or("memory", |b| b.kind()),
|
||||
perf_now() - t0,
|
||||
index.len(),
|
||||
index.total() as f64 / 1e6
|
||||
);
|
||||
Boot { opts, backend, files, index }
|
||||
}
|
||||
|
||||
struct WebApp {
|
||||
|
|
@ -225,6 +259,7 @@ struct WebApp {
|
|||
originals: Originals,
|
||||
files: Files,
|
||||
backend: Option<Backend>,
|
||||
workers: Option<Workers>,
|
||||
flushing: Rc<Cell<bool>>,
|
||||
bench: Option<Bench>,
|
||||
first_frame_logged: bool,
|
||||
|
|
@ -234,7 +269,7 @@ struct WebApp {
|
|||
|
||||
impl WebApp {
|
||||
fn new(cc: &eframe::CreationContext<'_>, boot: Boot) -> Self {
|
||||
let Boot { opts, backend, files } = boot;
|
||||
let Boot { opts, backend, files, index } = boot;
|
||||
let originals = Originals::default();
|
||||
let t = perf_now();
|
||||
let mut session = Session::new();
|
||||
|
|
@ -268,6 +303,16 @@ impl WebApp {
|
|||
app.ui = ui;
|
||||
}
|
||||
app.ui = app.ui.sanitized();
|
||||
let n = opts.workers.unwrap_or_else(|| {
|
||||
let cores = window().navigator().hardware_concurrency() as usize;
|
||||
cores.saturating_sub(1).clamp(1, 4)
|
||||
});
|
||||
let workers =
|
||||
(n > 0).then(|| Workers::start(n, backend.as_ref().map_or("memory", |b| b.kind()), backend.clone(), index, cc.egui_ctx.clone()));
|
||||
if let Some(w) = &workers {
|
||||
app.renderer.set_offload(Box::new(w.clone()));
|
||||
}
|
||||
log::info!("lightcraft: {n} render workers");
|
||||
CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone()));
|
||||
let origin = lightcraft_ui_egui::now_ms() - perf_now();
|
||||
WebApp {
|
||||
|
|
@ -275,6 +320,7 @@ impl WebApp {
|
|||
originals,
|
||||
files,
|
||||
backend,
|
||||
workers,
|
||||
flushing: Rc::new(Cell::new(false)),
|
||||
bench: opts.bench.then(|| Bench::new(origin)),
|
||||
first_frame_logged: false,
|
||||
|
|
@ -335,7 +381,7 @@ impl WebApp {
|
|||
}
|
||||
}
|
||||
|
||||
/// Save view state and UI prefs now and then; flush dirty files.
|
||||
/// Save view state, UI prefs and the thumbnail index now and then; flush dirty files.
|
||||
fn save(&mut self) {
|
||||
let now = perf_now();
|
||||
if now - self.last_save >= SAVE_EVERY_MS {
|
||||
|
|
@ -347,6 +393,16 @@ impl WebApp {
|
|||
self.files.write("ui.json", &ui);
|
||||
self.ui_written = ui;
|
||||
}
|
||||
if let (Some(w), Some(b)) = (&self.workers, &self.backend)
|
||||
&& let Some(index) = w.take_index_if_dirty()
|
||||
{
|
||||
let b = b.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Err(e) = b.write(THUMB_INDEX, &index).await {
|
||||
log::warn!("saving the thumbnail index: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(b) = &self.backend
|
||||
&& self.files.is_dirty()
|
||||
|
|
@ -368,7 +424,7 @@ thread_local! {
|
|||
|
||||
/// Run a command by id from JavaScript (automation, tests): `await command("library.info", "{}")`
|
||||
/// resolves to the result as JSON text, or rejects with the error. Besides every engine/UI
|
||||
/// command, `web.stats` reports the browser host's state (storage, originals in memory).
|
||||
/// command, `web.stats` reports the browser host's state (storage, workers, originals in memory).
|
||||
#[wasm_bindgen]
|
||||
pub fn command(id: String, params: String) -> js_sys::Promise {
|
||||
js_sys::Promise::new(&mut |resolve, reject| {
|
||||
|
|
@ -379,11 +435,13 @@ pub fn command(id: String, params: String) -> js_sys::Promise {
|
|||
|
||||
impl WebApp {
|
||||
fn web_stats(&self) -> serde_json::Value {
|
||||
let (alive, ready, remote, inline) = self.workers.as_ref().map_or((0, 0, 0, 0), Workers::stats);
|
||||
let (orig_n, orig_bytes) = self.originals.usage();
|
||||
json!({
|
||||
"storage": self.backend.as_ref().map_or("memory", |b| b.kind()),
|
||||
"dirty": self.files.is_dirty(),
|
||||
"flushing": self.flushing.get(),
|
||||
"workers": {"alive": alive, "ready": ready, "remoteJobs": remote, "inlineJobs": inline},
|
||||
"originalsInMemory": orig_n,
|
||||
"originalBytesInMemory": orig_bytes,
|
||||
"renderQueue": self.app.renderer.queued(),
|
||||
|
|
@ -416,6 +474,9 @@ impl eframe::App for WebApp {
|
|||
&& let Some(report) = b.step(&mut self.app)
|
||||
{
|
||||
log::info!("lightcraft-bench {report}");
|
||||
if let Some((alive, ready, remote, inline)) = self.workers.as_ref().map(Workers::stats) {
|
||||
log::info!("lightcraft-workers {{\"alive\":{alive},\"ready\":{ready},\"remote_jobs\":{remote},\"inline_jobs\":{inline}}}");
|
||||
}
|
||||
set_status(&format!("bench: {report}"));
|
||||
self.bench = None;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,324 @@
|
|||
//! Render jobs across the Web Worker boundary.
|
||||
//!
|
||||
//! A worker is a second wasm instance with its own memory, so an engine [`RenderJob`] (which holds
|
||||
//! `Arc`s into the main instance) can't be shared. The main thread sends a [`WireJob`] instead:
|
||||
//! where the pixels come from (the catalog [`Source`]), the develop settings and the request. The
|
||||
//! worker keeps its own decoded sources ([`WorkerCore`]), reads originals and cached thumbnails
|
||||
//! from browser storage, renders, and transfers the RGBA bytes back.
|
||||
//!
|
||||
//! Thumbnail disk cache on the web: workers read/write `thumbs/<key>.jpg` in browser storage; the
|
||||
//! main thread keeps the index (sizes, recency) and prunes it to the native cache's budget
|
||||
//! ([`ThumbIndex`]).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_engine::catalog::Source;
|
||||
use lightcraft_engine::develop::{DevelopSettings, EmbeddedLens};
|
||||
use lightcraft_engine::media::{MediaCache, RenderJob, SourceLevel, SourceRef};
|
||||
use lightcraft_engine::pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache};
|
||||
use lightcraft_preview::Lru;
|
||||
use lightcraft_raster::Rgb32f;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::store::hash_of_path;
|
||||
|
||||
/// One render request for a worker.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct WireJob {
|
||||
pub level: SourceLevel,
|
||||
pub origin: Source,
|
||||
/// Decode the source at most this long.
|
||||
pub max_edge: usize,
|
||||
pub raw: bool,
|
||||
pub as_shot_temp: f64,
|
||||
pub as_shot_tint: f64,
|
||||
pub lens: Option<EmbeddedLens>,
|
||||
pub settings: Arc<DevelopSettings>,
|
||||
pub max_w: usize,
|
||||
pub max_h: usize,
|
||||
pub draft: bool,
|
||||
pub apply_crop: bool,
|
||||
/// Thumbnail cache key (hex): read the cached file first if `thumb_cached`, store the result.
|
||||
pub thumb: Option<String>,
|
||||
pub thumb_cached: bool,
|
||||
/// Reuse the worker's intermediate results for this view (the loupe), like the desktop's
|
||||
/// per-slot stage cache: slider drags then only redo what changed.
|
||||
pub stages: Option<String>,
|
||||
}
|
||||
|
||||
impl WireJob {
|
||||
/// `view`: the slot name, for jobs that keep a stage cache.
|
||||
pub fn from_job(job: &RenderJob, thumb_cached: bool, view: &str) -> WireJob {
|
||||
let max_edge = match &job.source {
|
||||
SourceRef::Demo { max_edge, .. } | SourceRef::File { max_edge, .. } => *max_edge,
|
||||
SourceRef::Loaded(img) => img.width.max(img.height),
|
||||
};
|
||||
WireJob {
|
||||
level: job.level,
|
||||
origin: job.origin.clone(),
|
||||
max_edge,
|
||||
raw: job.info.raw,
|
||||
as_shot_temp: job.info.as_shot_temp,
|
||||
as_shot_tint: job.info.as_shot_tint,
|
||||
lens: job.info.lens,
|
||||
settings: job.settings.clone(),
|
||||
max_w: job.request.max_w,
|
||||
max_h: job.request.max_h,
|
||||
draft: job.request.quality == Quality::Draft,
|
||||
apply_crop: job.request.apply_crop,
|
||||
thumb: job.cache.as_ref().map(|(_, k)| k.to_string()),
|
||||
thumb_cached,
|
||||
stages: job.stages.is_some().then(|| view.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn info(&self) -> SourceInfo {
|
||||
SourceInfo { raw: self.raw, as_shot_temp: self.as_shot_temp, as_shot_tint: self.as_shot_tint, lens: self.lens }
|
||||
}
|
||||
|
||||
pub fn request(&self) -> RenderRequest {
|
||||
RenderRequest {
|
||||
max_w: self.max_w,
|
||||
max_h: self.max_h,
|
||||
quality: if self.draft { Quality::Draft } else { Quality::Full },
|
||||
apply_crop: self.apply_crop,
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifies the decoded source this job needs.
|
||||
fn source_key(&self) -> String {
|
||||
match &self.origin {
|
||||
Source::File { path } => format!("{}:{path}", self.max_edge),
|
||||
Source::Demo { scene } => format!("{}:demo:{scene}", self.max_edge),
|
||||
}
|
||||
}
|
||||
|
||||
/// Storage key of the cached thumbnail.
|
||||
pub fn thumb_key(&self) -> Option<String> {
|
||||
self.thumb.as_ref().map(|h| thumb_storage_key(h))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn thumb_storage_key(hex: &str) -> String {
|
||||
format!("thumbs/{hex}.jpg")
|
||||
}
|
||||
|
||||
/// Decoded sources per worker.
|
||||
const WORKER_SOURCE_BYTES: usize = 192 << 20;
|
||||
|
||||
/// The synchronous part of a worker: decoded-source cache + rendering.
|
||||
pub struct WorkerCore {
|
||||
sources: Lru<String, Arc<Rgb32f>>,
|
||||
media: MediaCache,
|
||||
stages: HashMap<String, Arc<StageCache>>,
|
||||
}
|
||||
|
||||
impl Default for WorkerCore {
|
||||
fn default() -> Self {
|
||||
WorkerCore { sources: Lru::new(WORKER_SOURCE_BYTES), media: MediaCache::default(), stages: HashMap::new() }
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkerCore {
|
||||
/// The content hash of the original to read from storage before [`WorkerCore::render`], if
|
||||
/// the job needs one that isn't decoded yet.
|
||||
pub fn needs_original(&mut self, job: &WireJob) -> Option<String> {
|
||||
if self.sources.get(&job.source_key()).is_some() {
|
||||
return None;
|
||||
}
|
||||
match &job.origin {
|
||||
Source::File { path } => hash_of_path(path).map(str::to_string),
|
||||
Source::Demo { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render `job`; `original` holds the file's bytes when [`WorkerCore::needs_original`] asked.
|
||||
pub fn render(&mut self, job: &WireJob, original: Option<&[u8]>) -> Result<Rendered, String> {
|
||||
let key = job.source_key();
|
||||
let src = match self.sources.get(&key).cloned() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let src = match (&job.origin, original) {
|
||||
(Source::File { .. }, Some(bytes)) => Arc::new(lightcraft_engine::files::load_bytes(bytes, job.max_edge)?.0),
|
||||
(Source::File { path }, None) => return Err(format!("{path}: original not found in browser storage")),
|
||||
(origin @ Source::Demo { .. }, _) => self.media.origin_ref(origin, job.max_edge).load()?,
|
||||
};
|
||||
let cost = src.width * src.height * 12 + 64;
|
||||
self.sources.insert(key, src.clone(), cost);
|
||||
src
|
||||
}
|
||||
};
|
||||
Ok(match &job.stages {
|
||||
Some(view) => {
|
||||
let st = self.stages.entry(view.clone()).or_default().clone();
|
||||
lightcraft_engine::pipeline::render_cached(&src, &job.info(), &job.settings, &job.request(), &st)
|
||||
}
|
||||
None => lightcraft_engine::pipeline::render(&src, &job.info(), &job.settings, &job.request()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The web thumbnail cache's index (main thread): size and last use of every stored thumbnail,
|
||||
/// pruned like the native disk cache (least recently used first, down to 80 % of the budget).
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub struct ThumbIndex {
|
||||
/// hex key → (bytes, last use).
|
||||
entries: HashMap<String, (u64, u64)>,
|
||||
clock: u64,
|
||||
#[serde(skip)]
|
||||
total: u64,
|
||||
#[serde(skip)]
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
impl ThumbIndex {
|
||||
pub fn from_json(bytes: &[u8]) -> ThumbIndex {
|
||||
let mut i: ThumbIndex = serde_json::from_slice(bytes).unwrap_or_default();
|
||||
i.total = i.entries.values().map(|e| e.0).sum();
|
||||
i
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> Vec<u8> {
|
||||
serde_json::to_vec(self).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Is `hex` stored? Counts as a use.
|
||||
pub fn touch(&mut self, hex: &str) -> bool {
|
||||
self.clock += 1;
|
||||
let clock = self.clock;
|
||||
match self.entries.get_mut(hex) {
|
||||
Some(e) => {
|
||||
e.1 = clock;
|
||||
self.dirty = true;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn contains(&self, hex: &str) -> bool {
|
||||
self.entries.contains_key(hex)
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, hex: &str, bytes: u64) {
|
||||
self.clock += 1;
|
||||
if let Some(old) = self.entries.insert(hex.to_string(), (bytes, self.clock)) {
|
||||
self.total -= old.0;
|
||||
}
|
||||
self.total += bytes;
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, hex: &str) {
|
||||
if let Some(old) = self.entries.remove(hex) {
|
||||
self.total -= old.0;
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn total(&self) -> u64 {
|
||||
self.total
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Over `budget`: drop the least recently used entries down to 80 % of it. Returns the keys
|
||||
/// whose files should be deleted.
|
||||
pub fn prune(&mut self, budget: u64) -> Vec<String> {
|
||||
if self.total <= budget {
|
||||
return Vec::new();
|
||||
}
|
||||
let target = budget / 5 * 4;
|
||||
let mut by_age: Vec<(String, u64, u64)> = self.entries.iter().map(|(k, v)| (k.clone(), v.0, v.1)).collect();
|
||||
by_age.sort_by_key(|e| e.2);
|
||||
let mut out = Vec::new();
|
||||
for (k, _, _) in by_age {
|
||||
if self.total <= target {
|
||||
break;
|
||||
}
|
||||
self.remove(&k);
|
||||
out.push(k);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_engine::Session;
|
||||
|
||||
#[test]
|
||||
fn wire_job_renders_like_the_engine() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.visible_cloned()[0];
|
||||
s.execute("library.select", &serde_json::json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("develop.set", &serde_json::json!({"control": "light.exposure", "value": 0.7})).unwrap();
|
||||
let job = s.thumb_job(id, 128).unwrap();
|
||||
let wire = WireJob::from_job(&job, false, "Thumb");
|
||||
// through JSON, as it crosses the worker boundary
|
||||
let wire: WireJob = serde_json::from_str(&serde_json::to_string(&wire).unwrap()).unwrap();
|
||||
assert!(wire.thumb.is_some());
|
||||
let mut core = WorkerCore::default();
|
||||
assert_eq!(core.needs_original(&wire), None, "demo scenes are generated");
|
||||
let a = core.render(&wire, None).unwrap();
|
||||
let b = job.run().rendered.unwrap();
|
||||
assert_eq!((a.image.width, a.image.height), (b.image.width, b.image.height));
|
||||
assert_eq!(a.image.data, b.image.data);
|
||||
assert_eq!(a.histogram, b.histogram);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_reads_originals_once() {
|
||||
let bytes = crate::store::png_bytes(64, 48);
|
||||
let hash = crate::store::content_hash(&bytes);
|
||||
let origin = Source::File { path: crate::store::original_path(&hash, "a.png") };
|
||||
let job = WireJob {
|
||||
level: SourceLevel::Thumb,
|
||||
origin,
|
||||
max_edge: 512,
|
||||
raw: false,
|
||||
as_shot_temp: 6500.0,
|
||||
as_shot_tint: 0.0,
|
||||
lens: None,
|
||||
settings: Arc::new(DevelopSettings::default()),
|
||||
max_w: 32,
|
||||
max_h: 32,
|
||||
draft: false,
|
||||
apply_crop: true,
|
||||
thumb: None,
|
||||
thumb_cached: false,
|
||||
stages: Some("Main".into()),
|
||||
};
|
||||
let mut core = WorkerCore::default();
|
||||
assert_eq!(core.needs_original(&job).as_deref(), Some(hash.as_str()));
|
||||
assert!(core.render(&job, None).is_err());
|
||||
let r = core.render(&job, Some(&bytes)).unwrap();
|
||||
assert_eq!(r.image.width, 32);
|
||||
assert_eq!(core.needs_original(&job), None, "decoded source is kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumb_index_prunes_lru() {
|
||||
let mut i = ThumbIndex::default();
|
||||
for k in ["a", "b", "c", "d"] {
|
||||
i.insert(k, 100);
|
||||
}
|
||||
assert!(i.touch("a"));
|
||||
assert!(!i.touch("zz"));
|
||||
assert!(i.prune(1000).is_empty());
|
||||
let gone = i.prune(300); // 400 > 300 → down to 240
|
||||
assert_eq!(gone, vec!["b".to_string(), "c".to_string()]);
|
||||
assert_eq!(i.total(), 200);
|
||||
let j = ThumbIndex::from_json(&i.to_json());
|
||||
assert_eq!((j.len(), j.total()), (2, 200));
|
||||
assert!(j.contains("a") && j.contains("d"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,392 @@
|
|||
//! Render workers: dedicated Web Workers, each running a second instance of this wasm module
|
||||
//! (`worker.js` → [`worker_main`]). The main thread compiles the module once and posts the
|
||||
//! compiled `WebAssembly.Module` to every worker, so a worker starts in a few milliseconds.
|
||||
//!
|
||||
//! This doesn't need wasm threads (shared memory, `+atomics`, a nightly `build-std`), only
|
||||
//! module workers: the main thread posts a [`WireJob`] as JSON, the worker renders it and
|
||||
//! transfers the RGBA bytes back (no copy across the boundary).
|
||||
//!
|
||||
//! Main side: [`Workers`] implements the UI's [`RenderOffload`]: one job per worker at a time,
|
||||
//! with affinity (a photo goes back to the worker that already decoded it), the rendered-
|
||||
//! thumbnail memory cache checked before dispatch, and the thumbnail storage index
|
||||
//! ([`ThumbIndex`]) kept and pruned here. If no worker comes up, jobs run inline.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use js_sys::{Array, Object, Reflect, Uint8Array, Uint32Array};
|
||||
use lightcraft_engine::catalog::PhotoId;
|
||||
use lightcraft_engine::media::{DISK_CACHE_BYTES, RenderJob, RenderResult, SourceLevel};
|
||||
use lightcraft_engine::pipeline::Rendered;
|
||||
use lightcraft_preview::{Hash128, PreviewCache};
|
||||
use lightcraft_raster::{Histogram, Rgba8};
|
||||
use lightcraft_ui_egui::render::{RenderOffload, Slot};
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::backend::Backend;
|
||||
use crate::wire::{ThumbIndex, WireJob, WorkerCore, thumb_storage_key};
|
||||
|
||||
/// Storage key of the thumbnail index.
|
||||
pub const THUMB_INDEX: &str = "thumbs/index.json";
|
||||
|
||||
fn set(o: &Object, k: &str, v: &JsValue) {
|
||||
let _ = Reflect::set(o, &k.into(), v);
|
||||
}
|
||||
|
||||
fn get(o: &JsValue, k: &str) -> JsValue {
|
||||
Reflect::get(o, &k.into()).unwrap_or(JsValue::UNDEFINED)
|
||||
}
|
||||
|
||||
fn now() -> f64 {
|
||||
let perf = get(&js_sys::global(), "performance");
|
||||
let f = get(&perf, "now");
|
||||
f.dyn_ref::<js_sys::Function>().and_then(|f| f.call0(&perf).ok()).and_then(|v| v.as_f64()).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// worker side
|
||||
|
||||
thread_local! {
|
||||
static CORE: RefCell<WorkerCore> = RefCell::new(WorkerCore::default());
|
||||
}
|
||||
|
||||
/// Entry point of a render worker (called by `worker.js` after instantiating the module).
|
||||
/// `store` is the backend kind the page uses ("opfs", "idb" or "memory").
|
||||
#[wasm_bindgen]
|
||||
pub fn worker_main(store: String) {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
let scope: web_sys::DedicatedWorkerGlobalScope = js_sys::global().unchecked_into();
|
||||
// The main thread sends jobs only after "ready", i.e. once storage is open, and one at a time.
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let backend = match store.as_str() {
|
||||
"memory" => None,
|
||||
_ => Backend::open(store == "idb").await.map_err(|e| log::warn!("render worker: no storage ({e})")).ok(),
|
||||
};
|
||||
let s = scope.clone();
|
||||
let onmessage = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |e: web_sys::MessageEvent| {
|
||||
let (s, b) = (s.clone(), backend.clone());
|
||||
wasm_bindgen_futures::spawn_local(async move { handle(&s, b, e.data()).await });
|
||||
});
|
||||
scope.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
|
||||
onmessage.forget();
|
||||
let ready = Object::new();
|
||||
set(&ready, "ready", &true.into());
|
||||
let _ = scope.post_message(&ready);
|
||||
});
|
||||
}
|
||||
|
||||
/// Run one job message and post the result.
|
||||
async fn handle(scope: &web_sys::DedicatedWorkerGlobalScope, backend: Option<Backend>, msg: JsValue) {
|
||||
let id = get(&msg, "id");
|
||||
let t0 = now();
|
||||
let out = Object::new();
|
||||
set(&out, "id", &id);
|
||||
let job: Result<WireJob, String> =
|
||||
get(&msg, "job").as_string().ok_or("no job".to_string()).and_then(|j| serde_json::from_str(&j).map_err(|e| e.to_string()));
|
||||
let mut transfer = Array::new();
|
||||
let result: Result<(Rendered, u32), String> = async {
|
||||
let job = job?;
|
||||
let mut cache_miss = false;
|
||||
// cached thumbnail?
|
||||
if job.thumb_cached
|
||||
&& let (Some(b), Some(key)) = (&backend, job.thumb_key())
|
||||
{
|
||||
match b.read(&key).await.ok().flatten().and_then(|bytes| lightcraft_preview::decode_jpeg(&bytes)) {
|
||||
Some(image) => {
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
return Ok((Rendered { image, histogram }, 0));
|
||||
}
|
||||
None => cache_miss = true,
|
||||
}
|
||||
}
|
||||
set(&out, "cacheMiss", &cache_miss.into());
|
||||
let original = match CORE.with(|c| c.borrow_mut().needs_original(&job)) {
|
||||
Some(hash) => match &backend {
|
||||
Some(b) => b.read(&crate::store::storage_key(&hash)).await?,
|
||||
None => None,
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let rendered = CORE.with(|c| c.borrow_mut().render(&job, original.as_deref()))?;
|
||||
let mut stored = 0;
|
||||
if let (Some(b), Some(key)) = (&backend, job.thumb_key())
|
||||
&& let Some(jpeg) = lightcraft_preview::encode_jpeg(&rendered.image)
|
||||
{
|
||||
match b.write(&key, &jpeg).await {
|
||||
Ok(()) => stored = jpeg.len() as u32,
|
||||
Err(e) => log::warn!("thumbnail cache write: {e}"),
|
||||
}
|
||||
}
|
||||
Ok((rendered, stored))
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok((r, stored)) => {
|
||||
let rgba = Uint8Array::from(r.image.as_bytes().as_ref());
|
||||
let h = &r.histogram;
|
||||
let hist = Uint32Array::new_with_length(4 * 256 + 1);
|
||||
for (i, ch) in [&h.r, &h.g, &h.b, &h.luma].into_iter().enumerate() {
|
||||
hist.subarray((i * 256) as u32, ((i + 1) * 256) as u32).copy_from(ch);
|
||||
}
|
||||
hist.set_index(1024, h.total);
|
||||
set(&out, "ok", &true.into());
|
||||
set(&out, "w", &(r.image.width as u32).into());
|
||||
set(&out, "h", &(r.image.height as u32).into());
|
||||
set(&out, "rgba", &rgba);
|
||||
set(&out, "hist", &hist);
|
||||
set(&out, "stored", &stored.into());
|
||||
transfer = Array::of2(&rgba.buffer(), &hist.buffer());
|
||||
}
|
||||
Err(e) => {
|
||||
set(&out, "ok", &false.into());
|
||||
set(&out, "err", &e.into());
|
||||
}
|
||||
}
|
||||
set(&out, "ms", &(now() - t0).into());
|
||||
if let Err(e) = scope.post_message_with_transfer(&out, &transfer) {
|
||||
log::error!("render worker: post: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// main side
|
||||
|
||||
struct Busy {
|
||||
id: u32,
|
||||
slot: Slot,
|
||||
photo: PhotoId,
|
||||
level: SourceLevel,
|
||||
key: u64,
|
||||
cache: Option<(Arc<PreviewCache>, Hash128)>,
|
||||
}
|
||||
|
||||
struct W {
|
||||
worker: web_sys::Worker,
|
||||
ready: bool,
|
||||
dead: bool,
|
||||
busy: Option<Busy>,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
workers: Vec<W>,
|
||||
done: Vec<(Slot, RenderResult, f64)>,
|
||||
next_id: u32,
|
||||
/// (photo, level) → worker that last decoded it.
|
||||
affinity: HashMap<(PhotoId, SourceLevel), usize>,
|
||||
index: ThumbIndex,
|
||||
backend: Option<Backend>,
|
||||
ctx: egui::Context,
|
||||
/// Jobs finished by workers / inline (for diagnostics).
|
||||
remote_done: u64,
|
||||
inline_done: u64,
|
||||
inline_this_frame: bool,
|
||||
}
|
||||
|
||||
/// The main thread's handle on the render workers.
|
||||
#[derive(Clone)]
|
||||
pub struct Workers(Rc<RefCell<Inner>>);
|
||||
|
||||
impl Workers {
|
||||
/// Start `n` workers (none: every job runs inline). `store` is the backend kind for workers.
|
||||
pub fn start(n: usize, store: &str, backend: Option<Backend>, index: ThumbIndex, ctx: egui::Context) -> Workers {
|
||||
let w = Workers(Rc::new(RefCell::new(Inner {
|
||||
workers: Vec::new(),
|
||||
done: Vec::new(),
|
||||
next_id: 0,
|
||||
affinity: HashMap::new(),
|
||||
index,
|
||||
backend,
|
||||
ctx,
|
||||
remote_done: 0,
|
||||
inline_done: 0,
|
||||
inline_this_frame: false,
|
||||
})));
|
||||
let module = get(&js_sys::global(), "lightcraftModule");
|
||||
if module.is_undefined() {
|
||||
log::warn!("render workers: no compiled module on the page (window.lightcraftModule); rendering on the main thread");
|
||||
return w;
|
||||
}
|
||||
for i in 0..n {
|
||||
let opts = web_sys::WorkerOptions::new();
|
||||
opts.set_type(web_sys::WorkerType::Module);
|
||||
opts.set_name(&format!("lightcraft-render-{i}"));
|
||||
let worker = match web_sys::Worker::new_with_options("./worker.js", &opts) {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
log::warn!("render worker {i}: {e:?}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let me = w.clone();
|
||||
let onmessage = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |e: web_sys::MessageEvent| me.on_message(i, e.data()));
|
||||
worker.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
|
||||
onmessage.forget();
|
||||
let me = w.clone();
|
||||
let onerror = Closure::<dyn FnMut(JsValue)>::new(move |e: JsValue| me.on_error(i, e));
|
||||
worker.set_onerror(Some(onerror.as_ref().unchecked_ref()));
|
||||
onerror.forget();
|
||||
let init = Object::new();
|
||||
set(&init, "module", &module);
|
||||
set(&init, "store", &store.into());
|
||||
if let Err(e) = worker.post_message(&init) {
|
||||
log::warn!("render worker {i}: {e:?}");
|
||||
continue;
|
||||
}
|
||||
w.0.borrow_mut().workers.push(W { worker, ready: false, dead: false, busy: None });
|
||||
}
|
||||
w
|
||||
}
|
||||
|
||||
fn on_error(&self, i: usize, e: JsValue) {
|
||||
let msg = get(&e, "message").as_string().unwrap_or_else(|| format!("{e:?}"));
|
||||
log::error!("render worker {i} failed: {msg}");
|
||||
let mut g = self.0.borrow_mut();
|
||||
if let Some(w) = g.workers.get_mut(i) {
|
||||
w.dead = true;
|
||||
w.worker.terminate();
|
||||
if let Some(b) = w.busy.take() {
|
||||
let r = RenderResult { photo: b.photo, level: b.level, key: b.key, rendered: Err(msg), loaded: None };
|
||||
g.done.push((b.slot, r, 0.0));
|
||||
}
|
||||
}
|
||||
g.ctx.request_repaint();
|
||||
}
|
||||
|
||||
fn on_message(&self, i: usize, data: JsValue) {
|
||||
let mut g = self.0.borrow_mut();
|
||||
let g = &mut *g;
|
||||
g.ctx.request_repaint();
|
||||
let Some(w) = g.workers.get_mut(i) else { return };
|
||||
if get(&data, "ready").is_truthy() {
|
||||
w.ready = true;
|
||||
return;
|
||||
}
|
||||
let id = get(&data, "id").as_f64().unwrap_or(-1.0) as u32;
|
||||
let Some(b) = w.busy.take_if(|b| b.id == id) else { return };
|
||||
let ms = get(&data, "ms").as_f64().unwrap_or(0.0);
|
||||
let rendered = if get(&data, "ok").is_truthy() {
|
||||
let (wd, ht) = (get(&data, "w").as_f64().unwrap_or(0.0) as usize, get(&data, "h").as_f64().unwrap_or(0.0) as usize);
|
||||
let rgba = Uint8Array::new(&get(&data, "rgba")).to_vec();
|
||||
let hist = Uint32Array::new(&get(&data, "hist")).to_vec();
|
||||
match Rgba8::from_bytes(wd, ht, &rgba) {
|
||||
Some(image) if hist.len() == 1025 => {
|
||||
let ch = |k: usize| hist[k * 256..(k + 1) * 256].to_vec();
|
||||
let histogram = Histogram { r: ch(0), g: ch(1), b: ch(2), luma: ch(3), total: hist[1024] };
|
||||
Ok(Rendered { image, histogram })
|
||||
}
|
||||
_ => Err("render worker: bad image".to_string()),
|
||||
}
|
||||
} else {
|
||||
Err(get(&data, "err").as_string().unwrap_or_else(|| "render worker failed".into()))
|
||||
};
|
||||
if let Ok(r) = &rendered
|
||||
&& let Some((cache, key)) = &b.cache
|
||||
{
|
||||
cache.put(*key, Arc::new(r.image.clone()));
|
||||
let hex = key.to_string();
|
||||
let stored = get(&data, "stored").as_f64().unwrap_or(0.0) as u64;
|
||||
if get(&data, "cacheMiss").is_truthy() {
|
||||
g.index.remove(&hex);
|
||||
}
|
||||
if stored > 0 {
|
||||
g.index.insert(&hex, stored);
|
||||
let gone = g.index.prune(DISK_CACHE_BYTES);
|
||||
if !gone.is_empty()
|
||||
&& let Some(be) = g.backend.clone()
|
||||
{
|
||||
log::info!("thumbnail cache: pruning {} files", gone.len());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
for k in gone {
|
||||
let _ = be.remove(&thumb_storage_key(&k)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
g.affinity.insert((b.photo, b.level), i);
|
||||
g.remote_done += 1;
|
||||
g.done.push((b.slot, RenderResult { photo: b.photo, level: b.level, key: b.key, rendered, loaded: None }, ms));
|
||||
}
|
||||
|
||||
/// The thumbnail index as JSON if it changed since the last call (the host saves it).
|
||||
pub fn take_index_if_dirty(&self) -> Option<Vec<u8>> {
|
||||
let mut g = self.0.borrow_mut();
|
||||
if !g.index.dirty {
|
||||
return None;
|
||||
}
|
||||
g.index.dirty = false;
|
||||
Some(g.index.to_json())
|
||||
}
|
||||
|
||||
/// (workers alive, workers ready, jobs done remotely, jobs done inline).
|
||||
pub fn stats(&self) -> (usize, usize, u64, u64) {
|
||||
let g = self.0.borrow();
|
||||
let alive = g.workers.iter().filter(|w| !w.dead).count();
|
||||
let ready = g.workers.iter().filter(|w| w.ready && !w.dead).count();
|
||||
(alive, ready, g.remote_done, g.inline_done)
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOffload for Workers {
|
||||
fn try_start(&mut self, slot: Slot, job: RenderJob) -> Option<RenderJob> {
|
||||
let mut g = self.0.borrow_mut();
|
||||
let g = &mut *g;
|
||||
// rendered thumbnail in memory: no worker needed
|
||||
if let Some((cache, key)) = &job.cache
|
||||
&& let Some(img) = cache.get(*key)
|
||||
{
|
||||
let image = Arc::unwrap_or_clone(img);
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
let r = RenderResult { photo: job.photo, level: job.level, key: job.key, rendered: Ok(Rendered { image, histogram }), loaded: None };
|
||||
g.done.push((slot, r, 0.0));
|
||||
return None;
|
||||
}
|
||||
if g.workers.iter().all(|w| w.dead) {
|
||||
// no workers: render here, one job per frame (as without workers)
|
||||
if g.inline_this_frame {
|
||||
return Some(job);
|
||||
}
|
||||
g.inline_this_frame = true;
|
||||
let t0 = now();
|
||||
let r = job.run();
|
||||
g.inline_done += 1;
|
||||
g.done.push((slot, r, now() - t0));
|
||||
return None;
|
||||
}
|
||||
let free = |w: &W| w.ready && !w.dead && w.busy.is_none();
|
||||
let pick = match g.affinity.get(&(job.photo, job.level)) {
|
||||
// a decoded preview-level source, or the loupe's stage cache, is worth waiting for; a
|
||||
// thumbnail isn't
|
||||
Some(&i) if g.workers.get(i).is_some_and(free) => Some(i),
|
||||
Some(&i) if (job.level == SourceLevel::Preview || job.stages.is_some()) && g.workers.get(i).is_some_and(|w| !w.dead) => None,
|
||||
_ => g.workers.iter().position(free),
|
||||
};
|
||||
let Some(i) = pick else { return Some(job) };
|
||||
// decided here, so the next job for this photo follows even before this one finishes
|
||||
g.affinity.insert((job.photo, job.level), i);
|
||||
let thumb_cached = job.cache.as_ref().is_some_and(|(_, k)| g.index.touch(&k.to_string()));
|
||||
let wire = WireJob::from_job(&job, thumb_cached, &format!("{slot:?}"));
|
||||
let Ok(text) = serde_json::to_string(&wire) else { return Some(job) };
|
||||
g.next_id = g.next_id.wrapping_add(1);
|
||||
let id = g.next_id;
|
||||
let msg = Object::new();
|
||||
set(&msg, "id", &id.into());
|
||||
set(&msg, "job", &text.into());
|
||||
let w = &mut g.workers[i];
|
||||
if let Err(e) = w.worker.post_message(&msg) {
|
||||
log::warn!("render worker {i}: {e:?}");
|
||||
return Some(job);
|
||||
}
|
||||
w.busy = Some(Busy { id, slot, photo: job.photo, level: job.level, key: job.key, cache: job.cache });
|
||||
None
|
||||
}
|
||||
|
||||
fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> {
|
||||
let mut g = self.0.borrow_mut();
|
||||
g.inline_this_frame = false;
|
||||
std::mem::take(&mut g.done)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
// LightCraft render worker: a second instance of the app's wasm module that renders jobs posted
|
||||
// by the page (see apps/lightcraft-web/src/workers.rs). The first message carries the compiled
|
||||
// WebAssembly.Module and the storage backend kind; the module then takes over `onmessage`.
|
||||
import init, { worker_main } from "./lightcraft_web.js";
|
||||
|
||||
self.onmessage = async (e) => {
|
||||
self.onmessage = null;
|
||||
try {
|
||||
await init({ module_or_path: e.data.module });
|
||||
worker_main(e.data.store);
|
||||
} catch (err) {
|
||||
// surfaces as the Worker's `error` event on the page, which falls back to inline rendering
|
||||
setTimeout(() => { throw err; });
|
||||
}
|
||||
};
|
||||
|
|
@ -167,7 +167,13 @@ impl MediaCache {
|
|||
(Source::Demo { .. }, SourceLevel::Full) => p.width.max(p.height).max(1) as usize,
|
||||
_ => usize::MAX,
|
||||
});
|
||||
match &p.source {
|
||||
self.origin_ref(&p.source, max_edge)
|
||||
}
|
||||
|
||||
/// How to load `origin` at most `max_edge` pixels long, ignoring decoded sources in memory (a
|
||||
/// render worker in another wasm instance builds its sources from this).
|
||||
pub fn origin_ref(&mut self, origin: &Source, max_edge: usize) -> SourceRef {
|
||||
match origin {
|
||||
Source::Demo { scene } => {
|
||||
if self.scenes.is_empty() {
|
||||
self.scenes = lightcraft_scenes::demo_library();
|
||||
|
|
@ -188,6 +194,9 @@ pub struct RenderJob {
|
|||
pub photo: PhotoId,
|
||||
pub level: SourceLevel,
|
||||
pub source: SourceRef,
|
||||
/// Where the photo's pixels come from (for executors that can't share `source`, e.g. a web
|
||||
/// worker).
|
||||
pub origin: Source,
|
||||
pub info: SourceInfo,
|
||||
pub settings: Arc<DevelopSettings>,
|
||||
pub request: RenderRequest,
|
||||
|
|
@ -303,7 +312,7 @@ impl crate::Session {
|
|||
let k = Hasher128::new().str(&content_key(&p)).u64(settings.hash64()).u64(b as u64).u64(RENDER_CACHE_VERSION).finish();
|
||||
(self.media.rendered.clone(), k)
|
||||
});
|
||||
Some(RenderJob { photo: id, level, source, info: source_info(&p), settings, request, key, cache, stages: None })
|
||||
Some(RenderJob { photo: id, level, source, origin: p.source.clone(), info: source_info(&p), settings, request, key, cache, stages: None })
|
||||
}
|
||||
|
||||
/// Accept a finished job's loaded source into the cache.
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ impl DiskCache {
|
|||
self.misses.fetch_add(1, Ordering::Relaxed);
|
||||
return None;
|
||||
};
|
||||
match decode(&bytes) {
|
||||
match decode_jpeg(&bytes) {
|
||||
Some(img) => {
|
||||
self.hits.fetch_add(1, Ordering::Relaxed);
|
||||
// refresh recency for pruning
|
||||
|
|
@ -71,7 +71,7 @@ impl DiskCache {
|
|||
}
|
||||
|
||||
pub fn put(&self, key: Hash128, img: &Rgba8) {
|
||||
let Some(bytes) = encode(img) else { return };
|
||||
let Some(bytes) = encode_jpeg(img) else { return };
|
||||
let p = self.path(key);
|
||||
let Some(parent) = p.parent() else { return };
|
||||
if std::fs::create_dir_all(parent).is_err() {
|
||||
|
|
@ -144,7 +144,8 @@ impl DiskCache {
|
|||
}
|
||||
}
|
||||
|
||||
fn encode(img: &Rgba8) -> Option<Vec<u8>> {
|
||||
/// Encode a thumbnail as the cache stores it (JPEG, RGB, 4:2:0).
|
||||
pub fn encode_jpeg(img: &Rgba8) -> Option<Vec<u8>> {
|
||||
if img.width == 0 || img.height == 0 || img.width > u16::MAX as usize || img.height > u16::MAX as usize {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -159,7 +160,8 @@ fn encode(img: &Rgba8) -> Option<Vec<u8>> {
|
|||
Some(out)
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8]) -> Option<Rgba8> {
|
||||
/// Decode a cached thumbnail (see [`encode_jpeg`]).
|
||||
pub fn decode_jpeg(bytes: &[u8]) -> Option<Rgba8> {
|
||||
use zune_core::bytestream::ZCursor;
|
||||
use zune_core::colorspace::ColorSpace;
|
||||
use zune_core::options::DecoderOptions;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ pub mod pool;
|
|||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub use disk::DiskCache;
|
||||
pub use disk::{DiskCache, decode_jpeg, encode_jpeg};
|
||||
pub use hash::{Hash128, Hasher128, hash_bytes};
|
||||
use lightcraft_raster::Rgba8;
|
||||
pub use lru::Lru;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
//! image…). Requests are deduplicated per slot (a newer request replaces a queued older one) and
|
||||
//! prioritised (loupe first, then on-screen thumbnails, then prefetch); thumbnails scrolled far
|
||||
//! out of view are dropped from the queue. Thumbnail jobs hit the engine's preview cache (memory +
|
||||
//! disk) before rendering. On wasm the jobs run inline, one per frame.
|
||||
//! disk) before rendering. On wasm the jobs run inline, one per frame, unless the host installs a
|
||||
//! [`RenderOffload`] (the browser build's Web Workers): then queued jobs are handed to it as it has
|
||||
//! room, and its results are collected every frame.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
|
@ -35,8 +37,29 @@ pub struct Tex {
|
|||
pub pixels: Option<std::sync::Arc<egui::ColorImage>>,
|
||||
}
|
||||
|
||||
/// Runs render jobs outside this thread's job pool (the browser build: Web Workers, each with its
|
||||
/// own wasm instance). The [`Renderer`] keeps the queue (priorities, per-slot de-duplication) and
|
||||
/// hands jobs over one at a time.
|
||||
pub trait RenderOffload {
|
||||
/// Start `job` if an executor is free; otherwise give it back (`Some`: it stays queued).
|
||||
fn try_start(&mut self, slot: Slot, job: RenderJob) -> Option<RenderJob>;
|
||||
/// Finished jobs since the last call: (slot, result, run time in ms).
|
||||
fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)>;
|
||||
}
|
||||
|
||||
struct Queued {
|
||||
slot: Slot,
|
||||
priority: u32,
|
||||
seq: u64,
|
||||
job: RenderJob,
|
||||
}
|
||||
|
||||
pub struct Renderer {
|
||||
pool: JobPool<Slot, RenderResult>,
|
||||
/// Jobs run elsewhere (see [`RenderOffload`]); `queue` holds the ones not started yet.
|
||||
offload: Option<Box<dyn RenderOffload>>,
|
||||
queue: Vec<Queued>,
|
||||
seq: u64,
|
||||
/// Slot → (key, priority) of the request in flight (queued or running).
|
||||
pending: HashMap<Slot, (u64, u32)>,
|
||||
pub textures: HashMap<Slot, Tex>,
|
||||
|
|
@ -55,6 +78,9 @@ impl Default for Renderer {
|
|||
let threads = if cfg!(target_arch = "wasm32") { 0 } else { JobPool::<Slot, RenderResult>::default_threads().min(6) };
|
||||
Renderer {
|
||||
pool: JobPool::new(threads),
|
||||
offload: None,
|
||||
queue: Vec::new(),
|
||||
seq: 0,
|
||||
pending: HashMap::new(),
|
||||
textures: HashMap::new(),
|
||||
last_main_ms: 0.0,
|
||||
|
|
@ -66,6 +92,15 @@ impl Default for Renderer {
|
|||
}
|
||||
|
||||
impl Renderer {
|
||||
/// Run jobs through `offload` from now on (instead of the job pool / inline).
|
||||
pub fn set_offload(&mut self, offload: Box<dyn RenderOffload>) {
|
||||
self.offload = Some(offload);
|
||||
}
|
||||
|
||||
fn is_queued(&self, slot: Slot) -> bool {
|
||||
self.queue.iter().any(|q| q.slot == slot) || self.pool.is_queued(slot)
|
||||
}
|
||||
|
||||
/// Is the slot's current texture (or pending request) already for `key`?
|
||||
pub fn is_current(&self, slot: Slot, key: u64) -> bool {
|
||||
self.textures.get(&slot).is_some_and(|t| t.key == key) || self.pending.get(&slot).is_some_and(|p| p.0 == key)
|
||||
|
|
@ -78,18 +113,38 @@ impl Renderer {
|
|||
}
|
||||
if let Some(&(key, prio)) = self.pending.get(&slot)
|
||||
&& key == job.key
|
||||
&& (prio == priority || !self.pool.is_queued(slot))
|
||||
&& (prio == priority || !self.is_queued(slot))
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.pending.insert(slot, (job.key, priority));
|
||||
let key = job.key;
|
||||
// (an offload keeps its own per-view stage caches; the flag tells it to)
|
||||
let job = if matches!(slot, Slot::Main | Slot::Before) { job.with_stages(self.stages.entry(slot).or_default().clone()) } else { job };
|
||||
if self.offload.is_some() {
|
||||
self.seq += 1;
|
||||
self.queue.retain(|q| q.slot != slot);
|
||||
self.queue.push(Queued { slot, priority, seq: self.seq, job });
|
||||
self.dispatch(); // an idle worker starts now rather than next frame
|
||||
return;
|
||||
}
|
||||
let key = job.key;
|
||||
self.pool.submit(slot, key, priority, Box::new(move || job.run()));
|
||||
}
|
||||
|
||||
pub fn queued(&self) -> usize {
|
||||
self.pool.queued()
|
||||
self.pool.queued() + self.queue.len()
|
||||
}
|
||||
|
||||
/// Hand queued jobs to the offload (best first) while it has room.
|
||||
fn dispatch(&mut self) {
|
||||
let Some(off) = self.offload.as_mut() else { return };
|
||||
while let Some(i) = self.queue.iter().enumerate().max_by_key(|(_, q)| (q.priority, q.seq)).map(|(i, _)| i) {
|
||||
let q = self.queue.swap_remove(i);
|
||||
if let Some(job) = off.try_start(q.slot, q.job) {
|
||||
self.queue.push(Queued { job, ..q });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Requests queued or running.
|
||||
|
|
@ -113,9 +168,16 @@ impl Renderer {
|
|||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let inline_ms = 0.0;
|
||||
let mut changed = false;
|
||||
let mut finished = Vec::new();
|
||||
while let Some(done) = self.pool.try_recv() {
|
||||
let (slot, r, ms) = (done.slot, done.result, if done.ms > 0.0 { done.ms } else { inline_ms });
|
||||
finished.push((done.slot, done.result, if done.ms > 0.0 { done.ms } else { inline_ms }));
|
||||
}
|
||||
if let Some(off) = self.offload.as_mut() {
|
||||
finished.extend(off.finished());
|
||||
}
|
||||
self.dispatch();
|
||||
let mut changed = false;
|
||||
for (slot, r, ms) in finished {
|
||||
session.accept(&r);
|
||||
self.completed += 1;
|
||||
if self.pending.get(&slot).is_some_and(|p| p.0 == r.key) {
|
||||
|
|
@ -172,6 +234,14 @@ impl Renderer {
|
|||
for s in dropped {
|
||||
self.pending.remove(&s);
|
||||
}
|
||||
let pending = &mut self.pending;
|
||||
self.queue.retain(|q| match q.slot {
|
||||
Slot::Thumb(id) if !keep.contains(&id) && q.priority <= 10 => {
|
||||
pending.remove(&q.slot);
|
||||
false
|
||||
}
|
||||
_ => true,
|
||||
});
|
||||
let thumbs = self.textures.keys().filter(|s| matches!(s, Slot::Thumb(_))).count();
|
||||
if thumbs <= max {
|
||||
return;
|
||||
|
|
|
|||
76
docs/web.md
76
docs/web.md
|
|
@ -18,42 +18,86 @@ 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 # → <target>/web/{index.html, worker.js, 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`
|
||||
it serves `.wasm` as `application/wasm`. The bundle can't be opened from `file://` (module
|
||||
workers and OPFS need an HTTP(S) origin; `localhost`/`127.0.0.1` count as secure). 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).
|
||||
|
||||
## Deploying: headers
|
||||
|
||||
`cargo xtask web --serve` sends these on every response, and a production server should too:
|
||||
|
||||
```
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Embedder-Policy: require-corp
|
||||
Cross-Origin-Resource-Policy: same-origin
|
||||
```
|
||||
|
||||
COOP + COEP make the page *cross-origin isolated* (`crossOriginIsolated === true`). That is
|
||||
**required** for `SharedArrayBuffer`, i.e. for a future wasm-threads build (`+atomics`, nightly
|
||||
`build-std`), and gives full-resolution `performance.now()`. The current render workers (below)
|
||||
don't share memory, so the app also runs without them; with COEP on, every subresource must be
|
||||
same-origin or send CORP/CORS headers (the bundle has no third-party resources).
|
||||
|
||||
## What works
|
||||
|
||||
- **Demo library on load.** The procedural scenes from `lightcraft-scenes` are loaded at
|
||||
start, so the page always has something in it.
|
||||
- **A persistent library.** The library lives in the browser's storage for the page's origin:
|
||||
[OPFS](https://developer.mozilla.org/docs/Web/API/File_System_API/Origin_private_file_system)
|
||||
when the main thread can write it (`FileSystemFileHandle.createWritable`: Chrome, Edge,
|
||||
Firefox, recent Safari), otherwise IndexedDB. Both hold the same layout:
|
||||
- `library/catalog.snap`, `library/catalog.log`: the same crash-safe journal as the desktop app
|
||||
(`lightcraft-catalog`), plus `presets.json`, `view.json`, `prefs.json` and `ui.json` (panel
|
||||
layout). The catalog `Store` is a memory mirror loaded at start-up; every change is flushed in
|
||||
the background within a frame or two, each file replaced atomically, in modification order
|
||||
(`apps/lightcraft-web/src/files.rs`). View state and UI prefs are saved every second when they
|
||||
change (a tab can close without notice).
|
||||
- `originals/<content hash>`: the bytes of every imported photo. The catalog refers to them as
|
||||
`web/<hash>/<file name>`; the main thread keeps recently used originals in memory (≤ 768 MB)
|
||||
and loads the rest on demand (the active photo is prefetched).
|
||||
- `thumbs/<key>.jpg` + `thumbs/index.json`: the rendered-thumbnail cache, with the desktop's
|
||||
budget (2 GB, least recently used pruned to 80 %).
|
||||
|
||||
A new library starts with the procedural demo photos. The app asks for persistent storage
|
||||
(`navigator.storage.persist()`); without it the browser may evict the data under storage
|
||||
pressure. URL options: `?store=idb` forces IndexedDB, `?store=memory` keeps nothing, `?reset`
|
||||
deletes the stored library first.
|
||||
- **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.
|
||||
browser's file picker. You can also drop files anywhere on the page. The bytes are written to
|
||||
storage, then imported and decoded by the same engine code as the desktop app
|
||||
(`lightcraft_engine::files::{probe_bytes, load_bytes}`): JPEG/PNG/TIFF/WebP and the supported
|
||||
raw formats. "Copy into library" is the same as "Add" here.
|
||||
- **Rendering in Web Workers.** Renders don't run on the main thread: up to four dedicated
|
||||
workers (`hardwareConcurrency − 1`, `?workers=N` to override, `?workers=0` for the old inline
|
||||
path) each run a second instance of the same wasm module. `index.html` compiles the module once
|
||||
and posts the compiled `WebAssembly.Module` to `worker.js`. A job crosses as JSON (the photo's
|
||||
source, develop settings, request); the worker reads the original or the cached thumbnail from
|
||||
storage itself, keeps decoded sources and the loupe's stage cache, renders, and transfers the
|
||||
RGBA bytes back. Jobs for a photo go back to the worker that decoded it. This needs no wasm
|
||||
threads or `SharedArrayBuffer`. If no worker starts, rendering falls back to the main thread.
|
||||
- **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.
|
||||
- **Automation.** `await lightcraft.command("library.info", "{}")` runs any engine or UI command
|
||||
by id on the next frame and resolves to the JSON result (`web.stats` reports storage, workers
|
||||
and the render queue). This is how the headless-Chrome checks drive the page.
|
||||
|
||||
## 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.
|
||||
- **Exports and auto-adjustments run on the main thread** (they need the original's pixels
|
||||
there). Right after a reload, the first such command on a photo whose original isn't in memory
|
||||
yet fails with "still loading" and works a moment later.
|
||||
- **Durability is "a frame later", not fsync-before-return:** a crash or tab kill in the few
|
||||
milliseconds between a command and its flush loses that command.
|
||||
- **Preset files** (import/export `.json`) aren't wired to browser pickers yet.
|
||||
- **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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! `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).
|
||||
//! into `<target>/web/` (index.html + worker.js + lightcraft_web.js + lightcraft_web_bg.wasm).
|
||||
//! `--serve [port]` then serves that folder with a tiny static HTTP server (std only) that sends
|
||||
//! the cross-origin isolation headers (COOP/COEP).
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
|
|
@ -11,6 +12,9 @@ use crate::{cargo, metadata, root, run as step};
|
|||
|
||||
const TARGET: &str = "wasm32-unknown-unknown";
|
||||
|
||||
/// Files copied from `apps/lightcraft-web/` into the bundle as they are.
|
||||
const STATIC_FILES: [&str; 2] = ["index.html", "worker.js"];
|
||||
|
||||
/// 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}"))?;
|
||||
|
|
@ -72,8 +76,9 @@ pub fn run(args: &[&str]) -> Result<(), String> {
|
|||
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}"))?;
|
||||
for f in STATIC_FILES {
|
||||
std::fs::copy(root().join("apps/lightcraft-web").join(f), out.join(f)).map_err(|e| format!("copy {f}: {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);
|
||||
|
||||
|
|
@ -100,6 +105,17 @@ fn mime(path: &Path) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
/// Response headers every file gets. `Cross-Origin-Opener-Policy: same-origin` +
|
||||
/// `Cross-Origin-Embedder-Policy: require-corp` make the page cross-origin isolated
|
||||
/// (`crossOriginIsolated == true`): required for `SharedArrayBuffer` (a wasm-threads build) and
|
||||
/// for full-resolution `performance.now()`. The current worker design (separate wasm instances)
|
||||
/// works without them; they are sent so the dev server matches the recommended deployment.
|
||||
pub const ISOLATION_HEADERS: [(&str, &str); 3] = [
|
||||
("Cross-Origin-Opener-Policy", "same-origin"),
|
||||
("Cross-Origin-Embedder-Policy", "require-corp"),
|
||||
("Cross-Origin-Resource-Policy", "same-origin"),
|
||||
];
|
||||
|
||||
/// 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}"))?;
|
||||
|
|
@ -134,11 +150,12 @@ fn handle(mut stream: TcpStream, dir: &Path) -> std::io::Result<()> {
|
|||
("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()
|
||||
)?;
|
||||
let mut head = format!("HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\n", body.len());
|
||||
for (k, v) in ISOLATION_HEADERS {
|
||||
head.push_str(&format!("{k}: {v}\r\n"));
|
||||
}
|
||||
head.push_str("Connection: close\r\n\r\n");
|
||||
stream.write_all(head.as_bytes())?;
|
||||
if method != "HEAD" {
|
||||
stream.write_all(&body)?;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue