mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-16 03:42:20 +02:00
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>
257 lines
7.9 KiB
JavaScript
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);
|
|
}
|