perf(importer): offload pipeline to worker, stage cache, quick-preview tier

Replaces the synchronous reactive pipeline with a debounced worker RPC.
Slider drags and file loads no longer block the main thread.

- image-pipeline.js: pure staged pipeline (transform → resize →
  color-correction → quantize). Each upstream stage keeps one cached
  slot keyed on the inputs it depends on, so a single-slider change
  reruns only the downstream stages.
- image-pipeline-worker.js: hosts the pipeline inside a module Worker.
  Accepts set-source (transferable) and run(params). Returns indices
  + preview RGBA as transferable ArrayBuffers.
- image-pipeline-client.js: main-thread RPC with request-id tracking
  and stale-response dropping.
- ImageImporter.svelte: two-tier scheduler — throttled quick request
  (<=384px) for the preview panel while inputs churn, debounced full
  request (180ms) for overlay/buildPixels/opaque-count on settle.
  Source buffer cloned and transferred to the worker on load/resume.

Main bundle shrinks ~4KB (pipeline code now in its own worker chunk).
This commit is contained in:
tiennm99 committed 2026-04-18 14:51:48 +07:00
1 parent d6503676c6
commit 8c6b9aa191
5 files changed
+544 -53

No files matched your search

+139 -53
View File
@@ -1,10 +1,8 @@
<script>
import { CANVAS_WIDTH, CANVAS_HEIGHT, MAX_BATCH_SIZE, REQUEST_COOLDOWN_SEC } from '../../lib/constants.js';
import { rgbaToPalette, paletteToRgba, DITHER_METHODS } from '../../lib/image-to-palette.js';
import { resizeRgba } from '../../lib/image-resize.js';
import { transformRgba } from '../../lib/image-transform.js';
import { applyColorCorrection } from '../../lib/image-color-correction.js';
import { DITHER_METHODS } from '../../lib/image-to-palette.js';
import { createImageUploader } from '../../lib/image-uploader.js';
import { createPipelineClient } from '../../lib/image-pipeline-client.js';
import { onMount } from 'svelte';
import {
rgbaToDataUrl, dataUrlToRgba,
@@ -16,16 +14,23 @@
// True while THIS panel requested a pick and is still waiting for the result.
let picking = $state(false);
// Source image (decoded, palette-mapped)
// Source image (decoded, held on main thread for save/restore only)
let fileName = $state(null);
let srcWidth = $state(0);
let srcHeight = $state(0);
/** @type {Uint8ClampedArray|null} */
let srcRgba = $state(null);
/** @type {Int16Array|null} */
let paletteIndices = $state(null);
// Latest full-resolution quantized result. Used by overlay, buildPixels,
// save-job, and validation. Quick-tier results do NOT overwrite this.
/** @type {null | {indices: Int16Array, width: number, height: number, preview: ArrayBuffer}} */
let paletteResult = $state(null);
let opaqueCount = $state(0);
// Latest preview buffer (from quick *or* full) for the 120×120 panel only.
/** @type {null | {buffer: ArrayBuffer, width: number, height: number}} */
let previewBuffer = $state(null);
// Preview canvas
let previewEl = $state();
@@ -77,7 +82,27 @@
// to skip pixels that were already placed before a refresh.
let jobStartPlaced = 0;
// Worker-backed pipeline. Lives for the component's lifetime.
/** @type {ReturnType<typeof createPipelineClient>|null} */
let client = null;
// Drag-tier timers:
// quick: throttled (max one in-flight per QUICK_INTERVAL) while inputs churn,
// runs the pipeline at <= QUICK_MAX_DIM to feed the 120×120 preview panel.
// full: debounced; fires once after inputs settle, updates the overlay + paletteResult.
const QUICK_INTERVAL = 60;
const FULL_DEBOUNCE = 180;
const QUICK_MAX_DIM = 384;
let quickScheduled = false;
let lastQuickAt = 0;
/** @type {ReturnType<typeof setTimeout>|null} */
let fullTimer = null;
onMount(() => {
client = createPipelineClient();
client.onResult(handleWorkerResult);
client.onError((m) => { errorText = m.error || 'Pipeline error.'; });
const j = loadJob();
if (j && j.total > 0 && j.placed < j.total) {
resumable = {
@@ -86,8 +111,72 @@
startedAt: j.startedAt || 0,
};
}
return () => {
client?.dispose();
client = null;
if (fullTimer) clearTimeout(fullTimer);
};
});
function handleWorkerResult(m) {
const indices = new Int16Array(m.indices);
if (m.quick) {
// Panel only — do not disturb overlay/buildPixels state.
previewBuffer = { buffer: m.preview, width: m.width, height: m.height };
return;
}
// Full tier: update everything.
let count = 0;
for (let i = 0; i < indices.length; i++) if (indices[i] >= 0) count++;
opaqueCount = count;
paletteResult = { indices, width: m.width, height: m.height, preview: m.preview };
previewBuffer = { buffer: m.preview, width: m.width, height: m.height };
}
function quickDims(w, h) {
const m = Math.max(w, h);
if (m <= QUICK_MAX_DIM) return { w, h };
const r = QUICK_MAX_DIM / m;
return { w: Math.max(1, Math.round(w * r)), h: Math.max(1, Math.round(h * r)) };
}
function buildParams(resizeW_, resizeH_) {
return {
flipH, flipV, rotation,
resizeW: resizeW_, resizeH: resizeH_, resampleMethod,
brightness, contrast, saturation, gamma,
ditherMethod, skipWhite, whiteThreshold, paintTransparent,
};
}
function scheduleQuick() {
if (!client || !srcRgba || resizeW <= 0 || resizeH <= 0) return;
if (quickScheduled) return;
const now = Date.now();
const delay = Math.max(0, QUICK_INTERVAL - (now - lastQuickAt));
quickScheduled = true;
setTimeout(() => {
quickScheduled = false;
lastQuickAt = Date.now();
if (!client || !srcRgba || resizeW <= 0 || resizeH <= 0) return;
const { w, h } = quickDims(resizeW, resizeH);
// If already ≤ quick cap, skip — the full tier will cover it without aliasing.
if (w === resizeW && h === resizeH) return;
client.run(buildParams(w, h), { quick: true });
}, delay);
}
function scheduleFull() {
if (!client || !srcRgba || resizeW <= 0 || resizeH <= 0) return;
if (fullTimer) clearTimeout(fullTimer);
fullTimer = setTimeout(() => {
fullTimer = null;
if (!client || !srcRgba || resizeW <= 0 || resizeH <= 0) return;
client.run(buildParams(resizeW, resizeH), { quick: false });
}, FULL_DEBOUNCE);
}
/** Snapshot the current pipeline inputs into a job record (minus placed/total which are added later). */
function buildJobRecord() {
return {
@@ -136,11 +225,12 @@
return;
}
// Restore source + config — the reactive pipeline will regenerate paletteIndices.
// Restore source + config — the reactive pipeline will regenerate paletteResult.
fileName = j.fileName ?? null;
srcRgba = decoded.rgba;
srcWidth = decoded.width;
srcHeight = decoded.height;
client?.setSource(decoded.rgba, decoded.width, decoded.height);
originX = j.originX ?? 0;
originY = j.originY ?? 0;
resizeW = j.resizeW ?? decoded.width;
@@ -199,50 +289,44 @@
const ratio = Math.min(maxW / w, maxH / h, 1);
resizeW = Math.max(1, Math.floor(w * ratio));
resizeH = Math.max(1, Math.floor(h * ratio));
// paletteIndices recomputed reactively via $effect below.
client?.setSource(data, w, h);
// paletteResult recomputed reactively via $effect below.
} catch (err) {
errorText = `Failed to load image: ${err.message || err}`;
srcRgba = null;
paletteIndices = null;
paletteResult = null;
previewBuffer = null;
srcWidth = srcHeight = 0;
}
}
// Re-run pipeline when any relevant input changes.
// Order: transform → resize → palette.
// Trigger the worker pipeline whenever any relevant input changes.
// The worker holds per-stage caches, so a single-slider drag only
// recomputes the stages downstream of the change.
$effect(() => {
if (!srcRgba || resizeW <= 0 || resizeH <= 0) {
paletteIndices = null; opaqueCount = 0; return;
paletteResult = null; previewBuffer = null; opaqueCount = 0;
if (fullTimer) { clearTimeout(fullTimer); fullTimer = null; }
return;
}
const transformed = (flipH || flipV || rotation !== 0)
? transformRgba(srcRgba, srcWidth, srcHeight, { flipH, flipV, rotation })
: { rgba: srcRgba, width: srcWidth, height: srcHeight };
const resized = (resizeW === transformed.width && resizeH === transformed.height)
? transformed.rgba
: resizeRgba(transformed.rgba, transformed.width, transformed.height, resizeW, resizeH, resampleMethod);
const corrected = (brightness !== 0 || contrast !== 0 || saturation !== 0 || gamma !== 1)
? applyColorCorrection(resized, resizeW, resizeH, { brightness, contrast, saturation, gamma })
: resized;
const idx = rgbaToPalette(corrected, resizeW, resizeH, {
method: ditherMethod,
skipWhite, whiteThreshold,
paintTransparent,
});
paletteIndices = idx;
let count = 0;
for (let i = 0; i < idx.length; i++) if (idx[i] >= 0) count++;
opaqueCount = count;
renderPreview();
// Register reactive deps explicitly.
void flipH; void flipV; void rotation;
void resizeW; void resizeH; void resampleMethod;
void brightness; void contrast; void saturation; void gamma;
void ditherMethod; void skipWhite; void whiteThreshold; void paintTransparent;
scheduleQuick();
scheduleFull();
});
function renderPreview() {
if (!previewEl || !paletteIndices) return;
const rgba = paletteToRgba(paletteIndices, resizeW, resizeH);
previewEl.width = resizeW;
previewEl.height = resizeH;
// Paint the latest preview buffer (quick or full) into the panel canvas.
$effect(() => {
if (!open || !previewEl || !previewBuffer) return;
const { buffer, width, height } = previewBuffer;
previewEl.width = width;
previewEl.height = height;
const ctx = previewEl.getContext('2d');
ctx.putImageData(new ImageData(rgba, resizeW, resizeH), 0, 0);
}
ctx.putImageData(new ImageData(new Uint8ClampedArray(buffer), width, height), 0, 0);
});
function onResizeWInput(e) {
const v = parseInt(e.target.value, 10);
@@ -297,17 +381,16 @@
gamma = 1;
}
$effect(() => { if (open && paletteIndices) renderPreview(); });
// Push overlay state to the canvas renderer whenever its inputs change.
// Clears on close, missing image, or toggle off.
// The overlay always uses the latest *full-tier* result (paletteResult),
// so quick-tier previews never flash the canvas with low-res pixels.
$effect(() => {
if (!setOverlay) return;
if (open && showOverlay && paletteIndices && resizeW > 0 && resizeH > 0) {
if (open && showOverlay && paletteResult) {
setOverlay({
x: originX, y: originY,
width: resizeW, height: resizeH,
indices: paletteIndices,
width: paletteResult.width, height: paletteResult.height,
indices: paletteResult.indices,
alpha: overlayAlpha,
});
} else {
@@ -331,10 +414,11 @@
}
function validatePlacement() {
if (!paletteIndices) return 'No image loaded.';
if (!paletteResult) return 'No image loaded, or still processing…';
const { width, height } = paletteResult;
if (!Number.isInteger(originX) || !Number.isInteger(originY)) return 'X and Y must be integers.';
if (originX < 0 || originY < 0) return 'X and Y must be ≥ 0.';
if (originX + resizeW > CANVAS_WIDTH || originY + resizeH > CANVAS_HEIGHT) {
if (originX + width > CANVAS_WIDTH || originY + height > CANVAS_HEIGHT) {
return `Image would overflow canvas (${CANVAS_WIDTH}x${CANVAS_HEIGHT}).`;
}
return null;
@@ -342,11 +426,13 @@
function buildPixels() {
const pixels = [];
for (let i = 0; i < paletteIndices.length; i++) {
const color = paletteIndices[i];
if (!paletteResult) return pixels;
const { indices, width } = paletteResult;
for (let i = 0; i < indices.length; i++) {
const color = indices[i];
if (color < 0) continue;
const lx = i % resizeW;
const ly = (i / resizeW) | 0;
const lx = i % width;
const ly = (i / width) | 0;
const x = originX + lx;
const y = originY + ly;
if (skipMatching && getCommittedColor?.(x, y) === color) continue;
@@ -449,7 +535,7 @@
{#if fileName}<span class="file-name" title={fileName}>{fileName}</span>{/if}
</div>
{#if paletteIndices}
{#if srcRgba}
<div class="preview-row">
<div class="preview-wrap">
<canvas bind:this={previewEl} class="preview"></canvas>
@@ -589,7 +675,7 @@
<div class="controls">
{#if status === 'idle' || status === 'done' || status === 'error'}
<button class="primary" onclick={start} disabled={!paletteIndices}>Start</button>
<button class="primary" onclick={start} disabled={!paletteResult}>Start</button>
{#if status !== 'idle'}<button onclick={reset}>Reset</button>{/if}
{:else if status === 'running'}
<button onclick={pause}>Pause</button>
+72
View File
@@ -0,0 +1,72 @@
/**
* Browser-side RPC wrapper around the pipeline web worker.
*
* Responsibilities:
* - Own the Worker's lifetime (`dispose()` terminates it).
* - Copy the source buffer before transferring, so the caller's
* reference to `srcRgba` stays alive for save/restore flows.
* - Stamp every request with a monotonic id and drop responses
* whose id is older than the latest one seen (stale-coalescing).
* - Surface results via `onResult` / errors via `onError`.
*
* The worker maintains stage-level caches internally, so a slider drag
* that only moves one input reuses upstream stages automatically.
*/
export function createPipelineClient() {
const worker = new Worker(
new URL('./image-pipeline-worker.js', import.meta.url),
{ type: 'module' },
);
let nextId = 0;
let lastResultId = -1;
let resultHandler = null;
let errorHandler = null;
worker.onmessage = (e) => {
const m = e.data;
if (m.type === 'result') {
if (m.id <= lastResultId) return; // stale
lastResultId = m.id;
resultHandler?.(m);
} else if (m.type === 'error') {
errorHandler?.(m);
}
};
return {
/** Register a callback invoked with the newest `result` message. */
onResult(fn) { resultHandler = fn; },
/** Register a callback invoked with `error` messages. */
onError(fn) { errorHandler = fn; },
/**
* Push a new source image to the worker. The buffer is cloned so the
* caller retains ownership of the original.
* @param {Uint8Array|Uint8ClampedArray} rgba
* @param {number} width
* @param {number} height
*/
setSource(rgba, width, height) {
const copy = new Uint8ClampedArray(rgba);
worker.postMessage({
type: 'set-source',
id: ++nextId,
width, height,
buffer: copy.buffer,
}, [copy.buffer]);
},
/**
* Queue a pipeline run. `quick` is a free-form tag the worker echoes
* back in the result so the caller can tell preview tiers apart.
*/
run(params, { quick = false } = {}) {
const id = ++nextId;
worker.postMessage({ type: 'run', id, params, quick });
return id;
},
dispose() { worker.terminate(); },
};
}
+38
View File
@@ -0,0 +1,38 @@
import { createPipeline } from './image-pipeline.js';
import { paletteToRgba } from './image-to-palette.js';
// Staged pipeline, lives for the worker's lifetime.
const pipeline = createPipeline();
self.onmessage = (e) => {
const msg = e.data;
try {
if (msg.type === 'set-source') {
const buf = new Uint8ClampedArray(msg.buffer);
pipeline.setSource(buf, msg.width, msg.height);
self.postMessage({ type: 'ack', id: msg.id });
return;
}
if (msg.type === 'run') {
if (!pipeline.hasSource()) {
self.postMessage({ type: 'error', id: msg.id, error: 'No source set.' });
return;
}
const { indices, width, height } = pipeline.run(msg.params);
// Preview RGBA is rebuilt here (cheap) so the main thread can render
// without another pass over the palette.
const preview = paletteToRgba(indices, width, height);
self.postMessage({
type: 'result',
id: msg.id,
quick: !!msg.quick,
width, height,
indices: indices.buffer,
preview: preview.buffer,
}, [indices.buffer, preview.buffer]);
return;
}
} catch (err) {
self.postMessage({ type: 'error', id: msg.id, error: String(err?.message || err) });
}
};
+93
View File
@@ -0,0 +1,93 @@
import { transformRgba } from './image-transform.js';
import { resizeRgba } from './image-resize.js';
import { applyColorCorrection } from './image-color-correction.js';
import { rgbaToPalette } from './image-to-palette.js';
/**
* Staged image pipeline: transform → resize → color-correction → quantize.
*
* Each stage holds a single cached slot keyed by the inputs it depends on,
* so changing only one slider (e.g. brightness) reuses T and R and only
* reruns C and Q.
*
* Q (quantize) is intentionally *not* cached — we transfer its buffer to
* the main thread (detaches it), and the quantize pass is already the
* cheapest stage to redo on a cache miss.
*
* Typical usage:
* const p = createPipeline();
* p.setSource(rgba, w, h);
* const { indices, width, height } = p.run(params);
*/
export function createPipeline() {
let src = null;
const slots = { T: null, R: null, C: null };
function useStage(name, key, build) {
const s = slots[name];
if (s && s.key === key) return s.data;
const data = build();
slots[name] = { key, data };
return data;
}
return {
setSource(buf, width, height) {
src = { buf, width, height };
slots.T = slots.R = slots.C = null;
},
hasSource() { return src !== null; },
source() { return src; },
/**
* @param {{
* flipH: boolean, flipV: boolean, rotation: 0|90|180|270,
* resizeW: number, resizeH: number, resampleMethod: 'nearest'|'bilinear'|'box',
* brightness: number, contrast: number, saturation: number, gamma: number,
* ditherMethod: string, skipWhite: boolean, whiteThreshold: number, paintTransparent: boolean,
* }} params
* @returns {{ indices: Int16Array, width: number, height: number }}
*/
run(params) {
if (!src) throw new Error('Pipeline: no source set.');
const {
flipH, flipV, rotation,
resizeW, resizeH, resampleMethod,
brightness, contrast, saturation, gamma,
ditherMethod, skipWhite, whiteThreshold, paintTransparent,
} = params;
const kT = `${flipH ? 1 : 0}|${flipV ? 1 : 0}|${rotation}`;
const kR = `${kT}|${resizeW}|${resizeH}|${resampleMethod}`;
const kC = `${kR}|${brightness}|${contrast}|${saturation}|${gamma}`;
const T = useStage('T', kT, () => {
if (!flipH && !flipV && rotation === 0) {
return { rgba: src.buf, width: src.width, height: src.height };
}
return transformRgba(src.buf, src.width, src.height, { flipH, flipV, rotation });
});
const R = useStage('R', kR, () => {
if (resizeW === T.width && resizeH === T.height) {
return { rgba: T.rgba, width: T.width, height: T.height };
}
const buf = resizeRgba(T.rgba, T.width, T.height, resizeW, resizeH, resampleMethod);
return { rgba: buf, width: resizeW, height: resizeH };
});
const C = useStage('C', kC, () => {
if (brightness === 0 && contrast === 0 && saturation === 0 && gamma === 1) {
return { rgba: R.rgba, width: R.width, height: R.height };
}
const buf = applyColorCorrection(R.rgba, R.width, R.height, { brightness, contrast, saturation, gamma });
return { rgba: buf, width: R.width, height: R.height };
});
const indices = rgbaToPalette(C.rgba, C.width, C.height, {
method: ditherMethod, skipWhite, whiteThreshold, paintTransparent,
});
return { indices, width: C.width, height: C.height };
},
};
}