updraft / lib /canvas.js
Nicholas Celestin
Build update — 2026-05-22T18:34:00.912Z
3f22414
Raw
History Blame Contribute Delete
5.43 kB
function clamp(v, min, max) {
return v < min ? min : v > max ? max : v;
}
function smoothstep(edge0, edge1, x) {
if (edge1 <= edge0) return x >= edge1 ? 1 : 0;
const t = clamp((x - edge0) / (edge1 - edge0), 0, 1);
return t * t * (3 - 2 * t);
}
export function expandRect(rect, paddingPx, maxW, maxH) {
const x1 = clamp(rect.x - paddingPx, 0, maxW);
const y1 = clamp(rect.y - paddingPx, 0, maxH);
const x2 = clamp(rect.x + rect.w + paddingPx, 0, maxW);
const y2 = clamp(rect.y + rect.h + paddingPx, 0, maxH);
return {
x: Math.floor(x1),
y: Math.floor(y1),
w: Math.max(1, Math.ceil(x2 - x1)),
h: Math.max(1, Math.ceil(y2 - y1)),
};
}
export function ensureCanvas(imageLike) {
if (imageLike?.getContext?.('2d')) return imageLike;
const copy = document.createElement('canvas');
copy.width = imageLike.width;
copy.height = imageLike.height;
copy.getContext('2d').drawImage(imageLike, 0, 0);
return copy;
}
export function cropToCanvas(image, rect) {
const c = document.createElement('canvas');
c.width = rect.w;
c.height = rect.h;
const ctx = c.getContext('2d');
if (!ctx) return null;
ctx.drawImage(
image,
rect.x, rect.y, rect.w, rect.h,
0, 0, rect.w, rect.h,
);
return c;
}
export function canvasToBlobUrl(canvas) {
return new Promise(resolve => canvas.toBlob(blob => resolve(URL.createObjectURL(blob)), 'image/png'));
}
export function imageToBlobUrl(image) {
const c = document.createElement('canvas');
c.width = image.width;
c.height = image.height;
c.getContext('2d').drawImage(image, 0, 0);
return canvasToBlobUrl(c);
}
export function blendCanvas(destCanvas, srcCanvas, opacity) {
const alpha = clamp(opacity, 0, 1);
if (alpha <= 0) return destCanvas;
const ctx = destCanvas.getContext('2d');
if (!ctx) return destCanvas;
ctx.save();
ctx.globalAlpha = alpha;
ctx.drawImage(srcCanvas, 0, 0, destCanvas.width, destCanvas.height);
ctx.restore();
return destCanvas;
}
// Composite `patch` onto `dest` at (x, y) with a feathered alpha mask.
// `innerRect` (in patch-local coords) marks the unfeathered region; pixels
// outside it fade out over `featherPx`. Without it, feathering runs from
// the patch edges inward.
export function compositeFeathered(destCanvas, patchCanvas, x, y, {
featherPx = 8,
innerRect = null,
blendOpacity = 1,
} = {}) {
const w = patchCanvas.width;
const h = patchCanvas.height;
if (w < 1 || h < 1) return false;
const opacity = clamp(blendOpacity, 0, 1);
if (opacity <= 0) return true;
const minDim = Math.min(w, h);
const t = Math.max(1, Math.min(Math.floor(featherPx), Math.floor(minDim / 2)));
const maskCanvas = document.createElement('canvas');
maskCanvas.width = w;
maskCanvas.height = h;
const mctx = maskCanvas.getContext('2d');
if (!mctx) return false;
const mask = mctx.createImageData(w, h);
const mpx = mask.data;
for (let py = 0; py < h; py++) {
for (let px = 0; px < w; px++) {
const idx = (py * w + px) * 4;
let a = 0;
if (innerRect) {
const ix1 = innerRect.x;
const iy1 = innerRect.y;
const ix2 = innerRect.x + innerRect.w;
const iy2 = innerRect.y + innerRect.h;
const ox = px < ix1 ? (ix1 - px) : px >= ix2 ? (px - ix2 + 1) : 0;
const oy = py < iy1 ? (iy1 - py) : py >= iy2 ? (py - iy2 + 1) : 0;
const d = Math.hypot(ox, oy);
a = d <= 0 ? 1 : (1 - smoothstep(0, t, d));
} else {
const dx = Math.min(px, w - 1 - px);
const dy = Math.min(py, h - 1 - py);
const d = Math.min(dx, dy);
a = smoothstep(0, t, d);
}
const alpha = Math.max(0, Math.min(255, Math.round(a * opacity * 255)));
mpx[idx] = 255;
mpx[idx + 1] = 255;
mpx[idx + 2] = 255;
mpx[idx + 3] = alpha;
}
}
mctx.putImageData(mask, 0, 0);
const patchMasked = document.createElement('canvas');
patchMasked.width = w;
patchMasked.height = h;
const pctx = patchMasked.getContext('2d');
if (!pctx) return false;
pctx.drawImage(patchCanvas, 0, 0);
pctx.globalCompositeOperation = 'destination-in';
pctx.drawImage(maskCanvas, 0, 0);
const dctx = destCanvas.getContext('2d');
if (!dctx) return false;
dctx.drawImage(patchMasked, x, y);
maskCanvas.width = 0;
maskCanvas.height = 0;
patchMasked.width = 0;
patchMasked.height = 0;
return true;
}
// Feather width (output px) for compositing a detection patch back onto an
// upscaled canvas. Scales with the detected region's min dimension, clamped
// by the padding ring and patch size so the transition stays inside the pad.
export function computeFeatherPx({
configuredFeatherPx,
regionW,
regionH,
patchW,
patchH,
paddingPx,
scale,
}) {
const minOut = 4;
const maxOut = 12;
const configuredOut = Math.max(0, configuredFeatherPx * scale);
const regionMinOut = Math.max(1, Math.min(regionW, regionH) * scale);
const regionDriven = Math.round(regionMinOut * 0.015);
let feather = clamp(
Math.max(configuredOut, regionDriven),
minOut,
maxOut,
);
const paddingOut = Math.max(0, paddingPx * scale);
if (paddingOut > 0) {
feather = Math.min(feather, Math.max(4, Math.round(paddingOut * 0.9)));
} else {
feather = Math.min(feather, 12);
}
const patchLimit = Math.max(2, Math.floor(Math.min(patchW, patchH) / 4));
return Math.max(2, Math.min(Math.round(feather), patchLimit));
}