Files
odysseus/static/js/editor/blend-modes.js
T
pewdiepie-archdaemonandClaude Opus 5.5 5fac693c1c feat(photo-editor): Ajax Image Editor client (Photoshop-parity tools)
Photoshop-parity editor client: layers, selection/masking, brushes,
smart filters, LUT import, Quick Edit, mobile gestures, workspaces,
batch operations, and the service-worker versioning (v978->v1006).

Source: vendor/photocraft (ad1cff44) photo-editor client files, merged
onto upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/A_conflicts.md; editor files taken from vendor/photocraft
(upstream's editor is an earlier snapshot of the same feature).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:57:35 +00:00

257 lines
7.9 KiB
JavaScript

/**
* Layer/graphic blend modes.
*
* Canvas2D can only composite a subset of the Photoshop blend modes natively.
* This module exposes that native set plus the "extra" modes PhotoCraft offers
* (Dissolve, Linear Burn, Darker Color, Linear Dodge/Add, Lighter Color,
* Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract, Divide) and a
* single `blendDraw` entry point that both paths go through.
*
* The non-native modes follow the W3C compositing spec (separable + the two
* non-separable luminance modes) and operate on ImageData in the overlap of
* the source and the destination so rendering stays reasonably cheap.
*/
export const NATIVE_BLEND_MODES = new Set([
'source-over',
'multiply',
'screen',
'overlay',
'darken',
'lighten',
'color-dodge',
'color-burn',
'hard-light',
'soft-light',
'difference',
'exclusion',
'hue',
'saturation',
'color',
'luminosity',
]);
const NATIVE_ALIASES = {
'linear-dodge': 'lighter',
add: 'lighter',
};
export const EXTRA_BLEND_MODES = [
{ value: 'linear-dodge', label: 'Linear Dodge (Add)' },
{ value: 'linear-burn', label: 'Linear Burn' },
{ value: 'darker-color', label: 'Darker Color' },
{ value: 'lighter-color', label: 'Lighter Color' },
{ value: 'vivid-light', label: 'Vivid Light' },
{ value: 'linear-light', label: 'Linear Light' },
{ value: 'pin-light', label: 'Pin Light' },
{ value: 'hard-mix', label: 'Hard Mix' },
{ value: 'subtract', label: 'Subtract' },
{ value: 'divide', label: 'Divide' },
{ value: 'dissolve', label: 'Dissolve' },
];
const NON_SEPARABLE = new Set(['darker-color', 'lighter-color']);
export function isNativeBlendMode(mode) {
if (!mode) return true;
return NATIVE_BLEND_MODES.has(mode) || Object.prototype.hasOwnProperty.call(NATIVE_ALIASES, mode);
}
export function isKnownBlendMode(mode) {
if (!mode) return true;
return isNativeBlendMode(mode) || EXTRA_BLEND_MODES.some(entry => entry.value === mode);
}
function clamp01(value) {
return value < 0 ? 0 : value > 1 ? 1 : value;
}
function lum(r, g, b) {
return 0.299 * r + 0.587 * g + 0.114 * b;
}
// Separable per-channel blend functions on normalized [0,1] values.
function blendChannel(mode, cb, cs) {
switch (mode) {
case 'normal':
return cs;
case 'multiply':
return cb * cs;
case 'screen':
return cb + cs - cb * cs;
case 'overlay':
return cb <= 0.5 ? 2 * cb * cs : 1 - 2 * (1 - cb) * (1 - cs);
case 'darken':
return Math.min(cb, cs);
case 'lighten':
return Math.max(cb, cs);
case 'color-dodge':
if (cb === 0) return 0;
if (cs === 1) return 1;
return Math.min(1, cb / (1 - cs));
case 'color-burn':
if (cb === 1) return 1;
if (cs === 0) return 0;
return 1 - Math.min(1, (1 - cb) / cs);
case 'hard-light':
return cs <= 0.5 ? 2 * cs * cb : 1 - 2 * (1 - cs) * (1 - cb);
case 'soft-light': {
if (cs <= 0.5) return cb - (1 - 2 * cs) * cb * (1 - cb);
const d = cb <= 0.25 ? ((16 * cb - 12) * cb + 4) * cb : Math.sqrt(cb);
return cb + (2 * cs - 1) * (d - cb);
}
case 'difference':
return Math.abs(cb - cs);
case 'exclusion':
return cb + cs - 2 * cb * cs;
case 'linear-burn':
return clamp01(cb + cs - 1);
case 'linear-dodge':
return clamp01(cb + cs);
case 'vivid-light':
return cs <= 0.5
? (cs === 0 ? 0 : 1 - Math.min(1, (1 - cb) / (2 * cs)))
: (cs === 1 ? 1 : Math.min(1, cb / (2 * (1 - cs))));
case 'linear-light':
return clamp01(cb + 2 * cs - 1);
case 'pin-light':
return cs <= 0.5 ? Math.min(cb, 2 * cs) : Math.max(cb, 2 * cs - 1);
case 'hard-mix': {
const vivid = cs <= 0.5
? (cs === 0 ? 0 : 1 - Math.min(1, (1 - cb) / (2 * cs)))
: (cs === 1 ? 1 : Math.min(1, cb / (2 * (1 - cs))));
return vivid < 0.5 ? 0 : 1;
}
case 'subtract':
return clamp01(cb - cs);
case 'divide':
return cs === 0 ? 1 : clamp01(cb / cs);
default:
return cs;
}
}
function dissolveRandom(i) {
let seed = (i * 2654435761) >>> 0;
seed ^= seed >>> 13;
seed = (seed * 1274126177) >>> 0;
return seed / 4294967296;
}
function ensureSourceData(source) {
const sw = source.width || source.naturalWidth || 0;
const sh = source.height || source.naturalHeight || 0;
if (!sw || !sh) return null;
const scratch = document.createElement('canvas');
scratch.width = sw;
scratch.height = sh;
const sctx = scratch.getContext('2d');
sctx.drawImage(source, 0, 0, sw, sh);
return { data: sctx.getImageData(0, 0, sw, sh).data, sw, sh };
}
function blendCustom(ctx, source, mode, alpha, dx, dy) {
if (alpha <= 0) return;
const src = ensureSourceData(source);
if (!src) return;
const tw = ctx.canvas.width;
const th = ctx.canvas.height;
const sw = src.sw;
const sh = src.sh;
const sx0 = Math.max(0, -dx);
const sy0 = Math.max(0, -dy);
const dx0 = Math.max(0, dx);
const dy0 = Math.max(0, dy);
const w = Math.min(sw - sx0, tw - dx0);
const h = Math.min(sh - sy0, th - dy0);
if (w <= 0 || h <= 0) return;
const dst = ctx.getImageData(dx0, dy0, w, h);
const dd = dst.data;
const sd = src.data;
const nonSeparable = NON_SEPARABLE.has(mode);
const dissolve = mode === 'dissolve';
for (let y = 0; y < h; y += 1) {
for (let x = 0; x < w; x += 1) {
const si = ((y + sy0) * sw + (x + sx0)) * 4;
const di = (y * w + x) * 4;
const sa = (sd[si + 3] / 255) * alpha;
const ba = dd[di + 3] / 255;
if (dissolve) {
const seed = (y + dy0) * 73856093 ^ (x + dx0) * 19349663;
if (dissolveRandom(seed) >= sa) continue;
dd[di] = sd[si];
dd[di + 1] = sd[si + 1];
dd[di + 2] = sd[si + 2];
dd[di + 3] = 255;
continue;
}
if (sa === 0) continue;
const sr = sd[si] / 255;
const sg = sd[si + 1] / 255;
const sb = sd[si + 2] / 255;
const br = dd[di] / 255;
const bg = dd[di + 1] / 255;
const bb = dd[di + 2] / 255;
let mergedR;
let mergedG;
let mergedB;
if (nonSeparable) {
const backdropLum = lum(br, bg, bb);
const sourceLum = lum(sr, sg, sb);
const useSource = mode === 'darker-color' ? sourceLum < backdropLum : sourceLum > backdropLum;
mergedR = useSource ? sr : br;
mergedG = useSource ? sg : bg;
mergedB = useSource ? sb : bb;
} else {
mergedR = blendChannel(mode, br, sr);
mergedG = blendChannel(mode, bg, sg);
mergedB = blendChannel(mode, bb, sb);
}
const srEff = (1 - ba) * sr + ba * mergedR;
const sgEff = (1 - ba) * sg + ba * mergedG;
const sbEff = (1 - ba) * sb + ba * mergedB;
const ao = sa + ba * (1 - sa);
if (ao <= 0) continue;
const cr = (sa * srEff + ba * br * (1 - sa)) / ao;
const cg = (sa * sgEff + ba * bg * (1 - sa)) / ao;
const cbOut = (sa * sbEff + ba * bb * (1 - sa)) / ao;
dd[di] = Math.round(clamp01(cr) * 255);
dd[di + 1] = Math.round(clamp01(cg) * 255);
dd[di + 2] = Math.round(clamp01(cbOut) * 255);
dd[di + 3] = Math.round(ao * 255);
}
}
ctx.putImageData(dst, dx0, dy0);
}
/**
* Composite `source` onto a 2D context (or canvas) using `mode` at `alpha`.
* Native modes use globalCompositeOperation; the rest fall back to pixel math.
*/
export function blendDraw(target, source, mode, alpha = 1, dx = 0, dy = 0) {
if (!target || !source) return;
const ctx = typeof target.getContext === 'function' ? target.getContext('2d') : target;
const a = Number.isFinite(alpha) ? clamp01(alpha) : 1;
const normalized = mode || 'source-over';
if (isNativeBlendMode(normalized)) {
const prevAlpha = ctx.globalAlpha;
const prevOp = ctx.globalCompositeOperation;
ctx.globalAlpha = a;
ctx.globalCompositeOperation = NATIVE_ALIASES[normalized] || normalized;
ctx.drawImage(source, dx, dy);
ctx.globalAlpha = prevAlpha;
ctx.globalCompositeOperation = prevOp;
return;
}
blendCustom(ctx, source, normalized, a, dx, dy);
}