Files
odysseus/static/js/editor/fx/develop.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

162 lines
5.5 KiB
JavaScript

/**
* Camera-Raw-style "Develop" tonal pipeline — a single deterministic pass that
* mirrors the Basic slider set (white balance, exposure, highlights, shadows,
* whites, blacks, contrast, clarity, vibrance, saturation).
*
* Pure pixel math: no DOM, no network, no module state, so it runs identically
* on the main thread and inside the adjustment worker. Work is done in
* [0,1] floats and hue is preserved by scaling the RGB triplet by the ratio
* between the shaped luma and the original luma, so a tonal move never drifts
* the colour.
*/
function clamp01(value) {
return value < 0 ? 0 : value > 1 ? 1 : value;
}
function finiteOr(value, fallback) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
export const DEVELOP_DEFAULTS = Object.freeze({
exposure: 0,
contrast: 0,
highlights: 0,
shadows: 0,
whites: 0,
blacks: 0,
temperature: 0,
tint: 0,
clarity: 0,
vibrance: 0,
saturation: 0,
});
/** Separable box blur of a scalar field — used for the clarity local contrast. */
function boxBlurField(field, width, height, radius) {
if (radius < 1) return field;
const tmp = new Float32Array(field.length);
const out = new Float32Array(field.length);
const window = radius * 2 + 1;
for (let y = 0; y < height; y += 1) {
const row = y * width;
let sum = 0;
for (let x = -radius; x <= radius; x += 1) sum += field[row + Math.max(0, Math.min(width - 1, x))];
for (let x = 0; x < width; x += 1) {
tmp[row + x] = sum / window;
const add = field[row + Math.min(width - 1, x + radius + 1)];
const sub = field[row + Math.max(0, x - radius)];
sum += add - sub;
}
}
for (let x = 0; x < width; x += 1) {
let sum = 0;
for (let y = -radius; y <= radius; y += 1) sum += tmp[Math.max(0, Math.min(height - 1, y)) * width + x];
for (let y = 0; y < height; y += 1) {
out[y * width + x] = sum / window;
const add = tmp[Math.min(height - 1, y + radius + 1) * width + x];
const sub = tmp[Math.max(0, y - radius) * width + x];
sum += add - sub;
}
}
return out;
}
/**
* Apply the Develop pipeline in place on an RGBA byte array.
* @returns {Uint8ClampedArray} the same array (mutated) for chaining.
*/
export function developImageData(data, width, height, params = {}) {
const p = {
exposure: finiteOr(params.exposure, 0),
contrast: finiteOr(params.contrast, 0),
highlights: finiteOr(params.highlights, 0),
shadows: finiteOr(params.shadows, 0),
whites: finiteOr(params.whites, 0),
blacks: finiteOr(params.blacks, 0),
temperature: finiteOr(params.temperature, 0),
tint: finiteOr(params.tint, 0),
clarity: finiteOr(params.clarity, 0),
vibrance: finiteOr(params.vibrance, 0),
saturation: finiteOr(params.saturation, 0),
};
const active = Object.keys(p).some(key => Math.abs(p[key]) > 1e-6);
if (!active || width <= 0 || height <= 0) return data;
const n = width * height;
const exp = Math.pow(2, p.exposure);
const t = p.temperature / 100;
const ti = p.tint / 100;
// Temperature: +warms (more red, less blue). Tint: +magenta (less green).
const rMul = exp * (1 + 0.30 * t) * (1 + 0.10 * ti);
const gMul = exp * (1 - 0.20 * ti);
const bMul = exp * (1 - 0.30 * t) * (1 + 0.10 * ti);
const hi = p.highlights / 100;
const sh = p.shadows / 100;
const wh = p.whites / 100;
const bl = p.blacks / 100;
const con = p.contrast / 100;
const vib = p.vibrance / 100;
const sat = p.saturation / 100;
const clarity = p.clarity / 100;
const lum = new Float32Array(n);
for (let i = 0; i < n; i += 1) {
const o = i * 4;
let r = clamp01((data[o] / 255) * rMul);
let g = clamp01((data[o + 1] / 255) * gMul);
let b = clamp01((data[o + 2] / 255) * bMul);
const base = 0.2126 * r + 0.7152 * g + 0.0722 * b;
let y = base;
if (sh) y += sh * 0.5 * (1 - y) * (1 - y); // + brightens shadows
if (hi) y += hi * 0.5 * y * y; // + brightens / - recovers highlights
if (wh) y += wh * 0.30 * y * y; // whites: endpoint at the top
if (bl) y -= bl * 0.30 * (1 - y) * (1 - y); // blacks: endpoint at the bottom
y = clamp01(y);
if (con) y = clamp01(0.5 + (y - 0.5) * (1 + con * 0.9)); // S-curve about mid-grey
if (base > 1e-5) {
const k = y / base;
r = clamp01(r * k);
g = clamp01(g * k);
b = clamp01(b * k);
} else {
r = g = b = y;
}
lum[i] = 0.2126 * r + 0.7152 * g + 0.0722 * b;
if (vib || sat) {
const avg = (r + g + b) / 3;
const mx = Math.max(r, g, b);
const mn = Math.min(r, g, b);
const chroma = mx > 0 ? (mx - mn) / mx : 0;
const amount = sat + vib * (1 - chroma);
if (amount) {
r = clamp01(r + (r - avg) * amount);
g = clamp01(g + (g - avg) * amount);
b = clamp01(b + (b - avg) * amount);
}
}
data[o] = Math.round(r * 255);
data[o + 1] = Math.round(g * 255);
data[o + 2] = Math.round(b * 255);
}
if (clarity) {
const radius = Math.max(2, Math.round(Math.min(width, height) * 0.01));
const blurred = boxBlurField(lum, width, height, radius);
for (let i = 0; i < n; i += 1) {
const detail = (lum[i] - blurred[i]) * clarity * 1.2;
const o = i * 4;
data[o] = Math.max(0, Math.min(255, data[o] + detail * 255));
data[o + 1] = Math.max(0, Math.min(255, data[o + 1] + detail * 255));
data[o + 2] = Math.max(0, Math.min(255, data[o + 2] + detail * 255));
}
}
return data;
}
export default { developImageData, DEVELOP_DEFAULTS };