mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-21 06:12:21 +02:00
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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c74f475679
commit
5fac693c1c
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user