Files
chrmc40 8e3dceae99 ComfyUI-PromptChain
A prompt-authoring and image-iteration suite for ComfyUI.
2026-06-17 19:35:25 -07:00

98 lines
4.0 KiB
JavaScript

// Layer engine for the viewer Edit modal — pure, DOM-free, browser+node ESM.
//
// The brush engine's no-rings guarantee comes from writing OPAQUE final colors
// with a SINGLE float blend + ONE quantization per pixel (see EditModal's brush
// notes). A single paint canvas could bake that at write time; layers can't —
// each layer must keep its own un-flattened contribution and the flatten has to
// happen at DISPLAY time, still with exactly one quantization, or the rings
// (8-bit per-step terraces / premultiplied staircase) come back.
//
// Layer storage = STRAIGHT (non-premultiplied) RGB 0..255 + coverage 0..1 in a
// Float32 RGBA buffer (A slot = coverage). The base image is just a layer with
// coverage 1 everywhere. `composite()` blends bottom→top in float and quantizes
// once with the same integer-hash dither the brush uses, so a smooth coverage
// ramp resolves to a smooth gradient instead of banded terraces.
// Per-pixel integer-hash white noise in [-0.5, 0.5] — deterministic per
// position so re-compositing identical layers never shimmers. VERBATIM from
// EditModal's `dither` (keep in sync; the equivalence harness asserts it).
export function ditherAt(x, y) {
let h = (x * 374761393 + y * 668265263) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296 - 0.5;
}
let _nextLayerId = 1;
// A blank transparent layer. `data` is Float32 RGBA (RGB 0..255, A = coverage
// 0..1). `mask`, when present, is a Float32 coverage field multiplied into the
// layer's alpha at composite time (a real per-layer mask).
export function createLayer(width, height, opts = {}) {
return {
id: opts.id ?? _nextLayerId++,
name: opts.name ?? "Layer",
visible: opts.visible ?? true,
opacity: opts.opacity ?? 1,
blendMode: opts.blendMode ?? "normal",
locked: opts.locked ?? false,
data: new Float32Array(width * height * 4),
mask: opts.mask ?? null,
};
}
// Seed a layer from RGBA pixel bytes (e.g. the source image's ImageData.data) —
// coverage 1 everywhere it's opaque, scaled by the byte alpha otherwise.
export function layerFromRGBA(bytes, width, height, opts = {}) {
const layer = createLayer(width, height, opts);
const d = layer.data;
for (let i = 0, n = width * height; i < n; i++) {
d[i * 4] = bytes[i * 4];
d[i * 4 + 1] = bytes[i * 4 + 1];
d[i * 4 + 2] = bytes[i * 4 + 2];
d[i * 4 + 3] = bytes[i * 4 + 3] / 255;
}
return layer;
}
// Straight-alpha "over" of one layer onto a resolved float RGB accumulator.
// Only "normal" is implemented; unknown modes fall back to normal for now.
function blendInto(accRGB, layer, width, height) {
const d = layer.data;
const op = layer.opacity;
const mask = layer.mask;
for (let i = 0, n = width * height; i < n; i++) {
let a = d[i * 4 + 3] * op;
if (mask) a *= mask[i];
if (a <= 0) continue;
const inv = 1 - a;
const k = i * 3;
accRGB[k] = d[i * 4] * a + accRGB[k] * inv;
accRGB[k + 1] = d[i * 4 + 1] * a + accRGB[k + 1] * inv;
accRGB[k + 2] = d[i * 4 + 2] * a + accRGB[k + 2] * inv;
}
}
// Flatten visible layers (array order = bottom→top) into opaque RGBA bytes,
// quantizing ONCE with dither. Returns Uint8ClampedArray(width*height*4); the
// browser wraps it in `new ImageData(bytes, width, height)`. The bottom layer
// is expected to be opaque (the base image); uncovered pixels resolve to black.
export function composite(layers, width, height, dither = ditherAt) {
const n = width * height;
const accRGB = new Float32Array(n * 3);
for (const layer of layers) {
if (layer.visible) blendInto(accRGB, layer, width, height);
}
const out = new Uint8ClampedArray(n * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = y * width + x;
const dq = dither(x, y);
out[i * 4] = Math.round(accRGB[i * 3] + dq);
out[i * 4 + 1] = Math.round(accRGB[i * 3 + 1] + dq);
out[i * 4 + 2] = Math.round(accRGB[i * 3 + 2] + dq);
out[i * 4 + 3] = 255;
}
}
return out;
}