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:
pewdiepie-archdaemon
2026-10-15 11:57:35 +00:00
co-authored by Claude Opus 5.5
parent c74f475679
commit 5fac693c1c
290 changed files with 34358 additions and 766 deletions
+183 -7
View File
@@ -48,6 +48,7 @@ import {
defaultAdjParams,
} from '../layer-helpers.js';
import { drawHistogram } from './histogram.js';
import { parseCubeLut, generateCubeLut } from './cube-lut.js';
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, getAdjustmentSource = null }) {
function suppressLayerGhostTap() {
@@ -152,6 +153,12 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
{ type: 'color-balance', label: 'Color Balance' },
{ type: 'selective-color', label: 'Selective Color' },
{ type: 'gradient-map', label: 'Gradient Map' },
{ type: 'invert', label: 'Invert' },
{ type: 'posterize', label: 'Posterize' },
{ type: 'threshold', label: 'Threshold' },
{ type: 'photo-filter', label: 'Photo Filter' },
{ type: 'channel-mixer', label: 'Channel Mixer' },
{ type: 'color-lookup', label: 'Color Lookup' },
];
menu.innerHTML = items.map(i =>
`<button class="ge-fx-menu-item" data-fx-type="${i.type}"><span class="ge-fx-menu-icon">${ADJ_ICONS[i.type] || ''}</span><span>${i.label}</span></button>`
@@ -332,7 +339,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
<span class="ge-adj-icon">${ADJ_ICONS[type] || ''}</span>
<span class="ge-adj-title">${adjLayerLabel(type)}</span>
<span class="ge-head-btns">
<button class="ge-adj-min" type="button" title="Minimise">&minus;</button>
<button class="ge-adj-close" type="button" title="Close" aria-label="Close">&times;</button>
</span>
</div>
<label class="ge-adj-preset-field">Preset
@@ -382,7 +389,6 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
}
pop.querySelector('.ge-adj-close')?.addEventListener('click', closeAdjPopup);
pop.querySelector('.ge-adj-min')?.addEventListener('click', () => minimiseAdjPopup(pop));
// Drag by head — anywhere except buttons. Mobile pins via !important
// rules; setProperty with 'important' lets inline styles win during drag.
const head = pop.querySelector('[data-adj-drag]');
@@ -530,8 +536,8 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
return p.channels[channel];
};
const revertIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
const sliderRow = (key, label, min, max, value, suffix) => `
<div class="ge-adj-row" data-adj-key="${key}">
const sliderRow = (key, label, min, max, value, suffix, defaultValue) => `
<div class="ge-adj-row" data-adj-key="${key}"${defaultValue != null ? ` data-default="${defaultValue}"` : ''}>
<label>${label}</label>
<input type="range" min="${min}" max="${max}" value="${value}" data-key="${key}" />
<span class="ge-adj-value">${value}${suffix || ''}</span>
@@ -551,6 +557,25 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
${sliderRow('offset', 'Offset', -50, 50, Math.round(p.offset * 100), '')}
${sliderRow('gamma', 'Gamma', 10, 300, Math.round(p.gamma * 100), 'γ')}
`;
} else if (type === 'develop') {
body.innerHTML = `
<div class="ge-adj-develop">
<div class="ge-adj-group-label">Basic</div>
${sliderRow('temperature', 'Temperature', -100, 100, Math.round(p.temperature || 0), '', 0)}
${sliderRow('tint', 'Tint', -100, 100, Math.round(p.tint || 0), '', 0)}
${sliderRow('exposure', 'Exposure', -500, 500, Math.round((p.exposure || 0) * 100), ' EV', 0)}
${sliderRow('contrast', 'Contrast', -100, 100, Math.round(p.contrast || 0), '', 0)}
${sliderRow('highlights', 'Highlights', -100, 100, Math.round(p.highlights || 0), '', 0)}
${sliderRow('shadows', 'Shadows', -100, 100, Math.round(p.shadows || 0), '', 0)}
${sliderRow('whites', 'Whites', -100, 100, Math.round(p.whites || 0), '', 0)}
${sliderRow('blacks', 'Blacks', -100, 100, Math.round(p.blacks || 0), '', 0)}
<div class="ge-adj-group-label">Presence</div>
${sliderRow('clarity', 'Clarity', -100, 100, Math.round(p.clarity || 0), '', 0)}
${sliderRow('vibrance', 'Vibrance', -100, 100, Math.round(p.vibrance || 0), '', 0)}
${sliderRow('saturation', 'Saturation', -100, 100, Math.round(p.saturation || 0), '', 0)}
<p class="ge-adj-help">Camera-Raw-style Basic panel: white balance, exposure and tone, then clarity, vibrance and saturation.</p>
</div>
`;
} else if (type === 'white-balance') {
body.innerHTML = `
${sliderRow('temperature', 'Temperature', -100, 100, Math.round(p.temperature), '')}
@@ -746,6 +771,134 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
p.reverse = event.target.checked;
scheduleAdjRefresh(layer);
});
} else if (type === 'invert') {
body.innerHTML = `
<p class="ge-adj-help">Inverts every color channel in the source layer.</p>
`;
} else if (type === 'posterize') {
body.innerHTML = `
${sliderRow('levels', 'Levels', 2, 255, Math.round(p.levels ?? 4), '')}
<p class="ge-adj-help">Reduces each channel to a limited number of tonal steps.</p>
`;
} else if (type === 'threshold') {
body.innerHTML = `
${sliderRow('level', 'Threshold', 0, 255, Math.round(p.level ?? 128), '')}
<p class="ge-adj-help">Pixels brighter than the threshold become white, the rest black.</p>
`;
} else if (type === 'photo-filter') {
body.innerHTML = `
<div class="ge-adj-row">
<label>Color</label>
<input type="color" data-photo-key="color" value="${p.color || '#ec8a00'}">
</div>
${sliderRow('density', 'Density', 0, 100, Math.round(p.density ?? 25), '%')}
<label class="ge-adj-toggle-row"><span>Preserve luminosity</span><input type="checkbox" data-photo-preserve${p.preserve_luminosity ? ' checked' : ''}><span class="ge-toggle-track"></span></label>
`;
body.querySelector('[data-photo-key="color"]')?.addEventListener('input', event => {
p.color = event.target.value;
scheduleAdjRefresh(layer);
});
body.querySelector('[data-photo-preserve]')?.addEventListener('change', event => {
p.preserve_luminosity = event.target.checked;
scheduleAdjRefresh(layer);
});
} else if (type === 'channel-mixer') {
const channels = ['red', 'green', 'blue'];
const ch = channels.includes(popEl._cmMixChannel) ? popEl._cmMixChannel : 'red';
popEl._cmMixChannel = ch;
if (!p[ch]) p[ch] = defaultAdjParams(type)[ch];
if (!p.constant) p.constant = defaultAdjParams(type).constant;
const values = p[ch];
body.innerHTML = `
<label class="ge-adj-channel-field">Output channel
<select class="ge-adj-cm-channel-select">
<option value="red"${ch === 'red' ? ' selected' : ''}>Red</option>
<option value="green"${ch === 'green' ? ' selected' : ''}>Green</option>
<option value="blue"${ch === 'blue' ? ' selected' : ''}>Blue</option>
</select>
</label>
${sliderRow(`${ch}-r`, 'Red', -200, 200, Math.round(values.r ?? 0), '%')}
${sliderRow(`${ch}-g`, 'Green', -200, 200, Math.round(values.g ?? 0), '%')}
${sliderRow(`${ch}-b`, 'Blue', -200, 200, Math.round(values.b ?? 0), '%')}
${sliderRow(`${ch}-c`, 'Constant', -100, 100, Math.round(p.constant[ch] ?? 0), '')}
<label class="ge-adj-toggle-row"><span>Monochrome</span><input type="checkbox" data-cm-monochrome${p.monochrome ? ' checked' : ''}><span class="ge-toggle-track"></span></label>
<p class="ge-adj-help">Mixes source channels into the selected output channel.</p>
`;
body.querySelector('.ge-adj-cm-channel-select')?.addEventListener('change', event => {
popEl._cmMixChannel = event.target.value;
body.innerHTML = '';
buildAdjBody(layer, type, body, popEl);
});
body.querySelector('[data-cm-monochrome]')?.addEventListener('change', event => {
p.monochrome = event.target.checked;
scheduleAdjRefresh(layer);
});
} else if (type === 'color-lookup') {
const presets = ['none', 'teal_orange', 'cold', 'warm', 'high_contrast', 'sepia'];
const presetLabels = { none: 'None', teal_orange: 'Teal & Orange', cold: 'Cold', warm: 'Warm', high_contrast: 'High Contrast', sepia: 'Sepia' };
const strength = p.strength == null ? 100 : p.strength;
const hasCube = !!(p.cube && p.cube.size && p.cube.data);
const lutName = hasCube ? `Loaded LUT · ${p.cube.size}³` : 'No .cube loaded';
body.innerHTML = `
<label class="ge-adj-channel-field">Lookup preset
<select class="ge-adj-lut-select">
${presets.map(name => `<option value="${name}"${(p.preset || 'none') === name ? ' selected' : ''}>${presetLabels[name]}</option>`).join('')}
</select>
</label>
<div class="ge-adj-lut-actions">
<label class="ge-btn ge-btn-sm ge-adj-lut-load">Load .cube…
<input type="file" accept=".cube,text/plain" hidden />
</label>
<button type="button" class="ge-btn ge-btn-sm ge-adj-lut-export"${hasCube ? '' : ' disabled'}>Export .cube</button>
</div>
<div class="ge-adj-lut-name">${lutName}</div>
${sliderRow('strength', 'Strength', 0, 100, strength, '%', 100)}
<p class="ge-adj-help">Import an Adobe .cube 3D LUT (or pick a built-in look). Strength blends the LUT with the original pixels.</p>
`;
body.querySelector('.ge-adj-lut-select')?.addEventListener('change', event => {
p.preset = event.target.value;
scheduleAdjRefresh(layer);
});
const fileInput = body.querySelector('.ge-adj-lut-load input[type="file"]');
fileInput?.addEventListener('change', async () => {
const file = fileInput.files && fileInput.files[0];
if (!file) return;
try {
const text = await file.text();
const lut = parseCubeLut(text);
p.cube = {
size: lut.size,
domainMin: Array.from(lut.domainMin),
domainMax: Array.from(lut.domainMax),
data: Array.from(lut.data),
};
if (p.strength == null) p.strength = 100;
const nameEl = body.querySelector('.ge-adj-lut-name');
if (nameEl) nameEl.textContent = `Loaded ${file.name} · ${lut.size}³`;
const exp = body.querySelector('.ge-adj-lut-export');
if (exp) exp.disabled = false;
scheduleAdjRefresh(layer);
} catch (err) {
const nameEl = body.querySelector('.ge-adj-lut-name');
if (nameEl) nameEl.textContent = 'Could not read .cube: ' + (err?.message || err);
}
});
body.querySelector('.ge-adj-lut-export')?.addEventListener('click', () => {
const cube = p.cube && p.cube.size ? p.cube : null;
if (!cube) return;
const text = generateCubeLut({
size: cube.size,
domainMin: cube.domainMin || [0, 0, 0],
domainMax: cube.domainMax || [1, 1, 1],
data: cube.data,
});
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'look.cube';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
});
}
// Wire all sliders.
body.querySelectorAll('input[type="range"]').forEach(sl => {
@@ -769,7 +922,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
function revertAdjKey(layer, type, key) {
const defaults = defaultAdjParams(type);
const p = layer._stagedAdj.params;
if (['brightness-contrast', 'exposure', 'white-balance', 'hue-saturation', 'vibrance', 'black-white', 'shadows-highlights', 'gradient-map'].includes(type)) {
if (['brightness-contrast', 'exposure', 'white-balance', 'hue-saturation', 'vibrance', 'black-white', 'shadows-highlights', 'gradient-map', 'posterize', 'threshold', 'photo-filter', 'color-lookup'].includes(type)) {
p[key] = defaults[key];
} else if (type === 'levels') {
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
@@ -779,6 +932,10 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
} else if (type === 'color-balance') {
const [tone, ch] = key.split('-');
p[tone][ch] = defaults[tone][ch];
} else if (type === 'channel-mixer') {
const [ch, comp] = key.split('-');
if (comp === 'c') p.constant[ch] = defaults.constant[ch];
else p[ch][comp] = defaults[ch][comp];
} else if (type === 'selective-color') {
p.ranges[p.range][key] = defaults.ranges[p.range][key];
}
@@ -791,6 +948,10 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
const raw = parseInt(sl.value, 10);
const valEl = sl.parentElement.querySelector('.ge-adj-value');
const p = layer._stagedAdj.params;
const row = sl.closest('.ge-adj-row');
if (row && row.hasAttribute('data-default')) {
row.classList.toggle('is-modified', Number(sl.value) !== Number(row.getAttribute('data-default')));
}
let display = String(raw);
if (type === 'brightness-contrast' || type === 'hue-saturation') {
if (key === 'brightness' || key === 'contrast' || key === 'saturation') {
@@ -804,10 +965,13 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
if (key === 'exposure') { p.exposure = raw / 100; display = (raw / 100).toFixed(2) + ' EV'; }
else if (key === 'offset') { p.offset = raw / 100; display = (raw / 100).toFixed(2); }
else if (key === 'gamma') { p.gamma = raw / 100; display = (raw / 100).toFixed(2) + 'γ'; }
} else if (type === 'develop') {
if (key === 'exposure') { p.exposure = raw / 100; display = (raw / 100).toFixed(2) + ' EV'; }
else { p[key] = raw; }
} else if (type === 'color-lookup') {
p.strength = raw; display = raw + '%';
} else if (type === 'white-balance') {
p[key] = raw;
} else if (type === 'vibrance') {
p[key] = raw;
} else if (type === 'black-white') {
p[key] = raw;
} else if (type === 'shadows-highlights') {
@@ -828,6 +992,18 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
} else if (type === 'gradient-map') {
p.midpoint = raw;
display = raw + '%';
} else if (type === 'posterize') {
p.levels = raw;
} else if (type === 'threshold') {
p.level = raw;
} else if (type === 'photo-filter') {
p.density = raw;
display = raw + '%';
} else if (type === 'channel-mixer') {
const [ch, comp] = key.split('-');
if (comp === 'c') p.constant[ch] = raw;
else p[ch][comp] = raw;
display = raw + (comp === 'c' ? '' : '%');
}
if (valEl) valEl.textContent = display;
scheduleAdjRefresh(layer);
+146
View File
@@ -0,0 +1,146 @@
/**
* Adobe .cube 3D LUT support — parse, apply (trilinear), and generate.
*
* Pure functions (no DOM, no network, no module state) so they run identically
* on the main thread, inside the adjustment worker, and under node in tests.
*
* Data layout: `data` is a flat RGB array with the RED component varying
* fastest, then green, then blue — the ordering the .cube spec uses for its
* row list. Sample index = (ri + gi*size + bi*size*size) * 3.
*/
function clamp01(v) {
return v < 0 ? 0 : v > 1 ? 1 : v;
}
function sampleChannel(data, size, ri, gi, bi, ch) {
return data[(ri + gi * size + bi * size * size) * 3 + ch];
}
/** Parse an Adobe .cube text file into { size, domainMin, domainMax, data }. */
export function parseCubeLut(text) {
if (typeof text !== 'string' || !text.trim()) throw new Error('empty .cube data');
const lines = text.split(/\r?\n/);
let size = 0;
let domainMin = [0, 0, 0];
let domainMax = [1, 1, 1];
const values = [];
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const parts = line.split(/\s+/);
const keyword = parts[0].toUpperCase();
if (keyword === 'TITLE') continue;
if (keyword === 'LUT_3D_SIZE') { size = Math.max(2, Math.round(Number(parts[1]) || 0)); continue; }
if (keyword === 'LUT_1D_SIZE') throw new Error('1D .cube LUTs are not supported');
if (keyword === 'DOMAIN_MIN') { domainMin = [Number(parts[1]) || 0, Number(parts[2]) || 0, Number(parts[3]) || 0]; continue; }
if (keyword === 'DOMAIN_MAX') { domainMax = [Number(parts[1]) || 1, Number(parts[2]) || 1, Number(parts[3]) || 1]; continue; }
const r = Number(parts[0]);
const g = Number(parts[1]);
const b = Number(parts[2]);
if (Number.isFinite(r) && Number.isFinite(g) && Number.isFinite(b)) values.push(r, g, b);
}
if (!size) throw new Error('missing LUT_3D_SIZE');
const expected = size * size * size * 3;
if (values.length < expected) throw new Error(`incomplete .cube data (${values.length / 3} of ${size ** 3} rows)`);
for (let i = 0; i < size; i += 1) {
if (domainMax[i] <= domainMin[i]) domainMax[i] = domainMin[i] + 1;
}
return { size, domainMin, domainMax, data: new Float64Array(values.slice(0, expected)) };
}
/** Trilinear lookup of an already domain-normalized [0,1] rgb triple. */
function trilinear(lut, r, g, b) {
const n = lut.size;
const max = n - 1;
const fr = clamp01(r) * max;
const fg = clamp01(g) * max;
const fb = clamp01(b) * max;
const r0 = Math.floor(fr); const r1 = Math.min(max, r0 + 1); const tr = fr - r0;
const g0 = Math.floor(fg); const g1 = Math.min(max, g0 + 1); const tg = fg - g0;
const b0 = Math.floor(fb); const b1 = Math.min(max, b0 + 1); const tb = fb - b0;
const out = [0, 0, 0];
for (let ch = 0; ch < 3; ch += 1) {
const c000 = sampleChannel(lut.data, n, r0, g0, b0, ch);
const c100 = sampleChannel(lut.data, n, r1, g0, b0, ch);
const c010 = sampleChannel(lut.data, n, r0, g1, b0, ch);
const c110 = sampleChannel(lut.data, n, r1, g1, b0, ch);
const c001 = sampleChannel(lut.data, n, r0, g0, b1, ch);
const c101 = sampleChannel(lut.data, n, r1, g0, b1, ch);
const c011 = sampleChannel(lut.data, n, r0, g1, b1, ch);
const c111 = sampleChannel(lut.data, n, r1, g1, b1, ch);
const c00 = c000 + (c100 - c000) * tr;
const c10 = c010 + (c110 - c010) * tr;
const c01 = c001 + (c101 - c001) * tr;
const c11 = c011 + (c111 - c011) * tr;
const c0 = c00 + (c10 - c00) * tg;
const c1 = c01 + (c11 - c01) * tg;
out[ch] = c0 + (c1 - c0) * tb;
}
return out;
}
/**
* Apply a parsed LUT in place to an RGBA byte array.
* @param {Uint8ClampedArray|Uint8Array} data RGBA bytes
* @param {number} strength 0..1 blend (1 = full LUT)
*/
export function applyCubeLut(data, width, height, lut, { strength = 1 } = {}) {
if (!lut || !lut.data || !lut.size) return data;
const s = clamp01(Number.isFinite(strength) ? strength : 1);
if (s <= 0) return data;
const { domainMin, domainMax } = lut;
const n = data.length;
const out = [0, 0, 0];
for (let i = 0; i < n; i += 4) {
const r = (data[i] / 255 - domainMin[0]) / (domainMax[0] - domainMin[0]);
const g = (data[i + 1] / 255 - domainMin[1]) / (domainMax[1] - domainMin[1]);
const b = (data[i + 2] / 255 - domainMin[2]) / (domainMax[2] - domainMin[2]);
const mapped = trilinear(lut, r, g, b);
data[i] = Math.round((data[i] / 255 + (clamp01(mapped[0]) - data[i] / 255) * s) * 255);
data[i + 1] = Math.round((data[i + 1] / 255 + (clamp01(mapped[1]) - data[i + 1] / 255) * s) * 255);
data[i + 2] = Math.round((data[i + 2] / 255 + (clamp01(mapped[2]) - data[i + 2] / 255) * s) * 255);
}
return data;
}
/** Serialize a { size, data } LUT to .cube text (r fastest). */
export function generateCubeLut(lut) {
const size = lut?.size;
if (!size || !lut.data) throw new Error('lut with size + data required');
const domainMin = lut.domainMin || [0, 0, 0];
const domainMax = lut.domainMax || [1, 1, 1];
const lines = [
'TITLE "odysseus"',
`LUT_3D_SIZE ${size}`,
`DOMAIN_MIN ${domainMin[0].toFixed(6)} ${domainMin[1].toFixed(6)} ${domainMin[2].toFixed(6)}`,
`DOMAIN_MAX ${domainMax[0].toFixed(6)} ${domainMax[1].toFixed(6)} ${domainMax[2].toFixed(6)}`,
];
for (let b = 0; b < size; b += 1) {
for (let g = 0; g < size; g += 1) {
for (let r = 0; r < size; r += 1) {
const o = (r + g * size + b * size * size) * 3;
lines.push(`${lut.data[o].toFixed(6)} ${lut.data[o + 1].toFixed(6)} ${lut.data[o + 2].toFixed(6)}`);
}
}
}
return lines.join('\n') + '\n';
}
/** Identity LUT of the given grid size. */
export function identityLut(size = 2) {
const n = Math.max(2, Math.round(size));
const data = new Float64Array(n * n * n * 3);
const max = n - 1;
for (let b = 0; b < n; b += 1) {
for (let g = 0; g < n; g += 1) {
for (let r = 0; r < n; r += 1) {
const o = (r + g * n + b * n * n) * 3;
data[o] = r / max; data[o + 1] = g / max; data[o + 2] = b / max;
}
}
}
return { size: n, domainMin: [0, 0, 0], domainMax: [1, 1, 1], data };
}
export default { parseCubeLut, applyCubeLut, generateCubeLut, identityLut };
+161
View File
@@ -0,0 +1,161 @@
/**
* 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 };
+159 -16
View File
@@ -14,6 +14,9 @@
* { type: 'curves', params: { points: {rgb, red, green, blue} } }
* { type: 'color-balance', params: { shadows, midtones, highlights } }
*/
import { developImageData } from './develop.js';
import { applyCubeLut } from './cube-lut.js';
function clampByte(value) {
return value < 0 ? 0 : value > 255 ? 255 : Math.round(value);
}
@@ -136,6 +139,12 @@ export function applyAdjustment(srcCanvas, adj) {
const img = octx.getImageData(0, 0, w, h);
const d = img.data;
if (adj.type === 'develop') {
const p = adj.params || {};
developImageData(d, w, h, p);
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'exposure') {
const p = adj.params || {};
const multiplier = Math.pow(2, finiteOr(p.exposure, 0));
@@ -182,13 +191,20 @@ export function applyAdjustment(srcCanvas, adj) {
if (adj.type === 'vibrance') {
const amount = Math.max(-1, Math.min(1, finiteOr(adj.params?.vibrance, 0) / 100));
// Scale chroma around luminance by a factor that leans on low-saturation
// pixels. Doing it directly in RGB (no HSL round-trip) keeps near-gray
// pixels near-gray instead of pinning them to an absolute saturation floor,
// which previously turned faint hue noise (e.g. lifted shadow haze) into
// false-colour banding.
for (let i = 0; i < d.length; i += 4) {
const [hue, saturation, lightness] = rgbToHsl(d[i], d[i + 1], d[i + 2]);
const adjustedSaturation = amount >= 0
? saturation + (1 - saturation) * amount
: saturation * (1 + amount);
const rgb = hslToRgb(hue, Math.max(0, Math.min(1, adjustedSaturation)), lightness);
d[i] = clampByte(rgb[0]); d[i + 1] = clampByte(rgb[1]); d[i + 2] = clampByte(rgb[2]);
const r = d[i], g = d[i + 1], b = d[i + 2];
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const avg = (r + g + b) / 3;
const sat = max <= 0 ? 0 : (max - min) / max;
const factor = 1 + (1 - sat) * amount;
d[i] = clampByte(avg + (r - avg) * factor);
d[i + 1] = clampByte(avg + (g - avg) * factor);
d[i + 2] = clampByte(avg + (b - avg) * factor);
}
octx.putImageData(img, 0, 0);
return out;
@@ -213,16 +229,23 @@ export function applyAdjustment(srcCanvas, adj) {
if (adj.type === 'shadows-highlights') {
const shadows = Math.max(-1, Math.min(1, finiteOr(adj.params?.shadows, 0) / 100));
const highlights = Math.max(-1, Math.min(1, finiteOr(adj.params?.highlights, 0) / 100));
const toneAdjust = (value, amount, weight) => amount >= 0
? value + (255 - value) * amount * weight
: value + value * amount * weight;
// Endpoint-preserving tonal lift. A linear "add toward white" lift turns
// every near-black pixel grey (flattening the whole image under a veil);
// instead scale luminance with a gamma curve so 0 stays 0 and 255 stays 255
// while shadows/midtones open up. Colour is preserved via the luminance ratio.
for (let i = 0; i < d.length; i += 4) {
const luminance = (0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]) / 255;
const shadowWeight = (1 - luminance) ** 2;
const highlightWeight = luminance ** 2;
d[i] = clampByte(toneAdjust(toneAdjust(d[i], shadows, shadowWeight), highlights, highlightWeight));
d[i + 1] = clampByte(toneAdjust(toneAdjust(d[i + 1], shadows, shadowWeight), highlights, highlightWeight));
d[i + 2] = clampByte(toneAdjust(toneAdjust(d[i + 2], shadows, shadowWeight), highlights, highlightWeight));
const r = d[i], g = d[i + 1], b = d[i + 2];
const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
if (luminance <= 1 / 255) continue;
const shadowWeight = (1 - luminance) * (1 - luminance);
const highlightWeight = luminance * luminance;
let exponent = 1;
if (shadows) exponent /= (1 + shadows * shadowWeight * 0.85);
if (highlights) exponent /= (1 + highlights * highlightWeight * 0.85);
const ratio = Math.pow(luminance, exponent - 1);
d[i] = clampByte(r * ratio);
d[i + 1] = clampByte(g * ratio);
d[i + 2] = clampByte(b * ratio);
}
octx.putImageData(img, 0, 0);
return out;
@@ -261,7 +284,17 @@ export function applyAdjustment(srcCanvas, adj) {
if (adj.type === 'color-balance') {
const cb = adj.params;
const scale = 0.6;
const s = cb.shadows, m = cb.midtones, hi = cb.highlights;
// Be robust to malformed planner params: a tone may arrive as a bare
// number instead of {r,g,b}, which would otherwise produce NaN pixels
// (a blank white canvas). Coerce every tone to finite {r,g,b}.
const toTone = (v) => {
if (v && typeof v === 'object') {
return { r: Number.isFinite(v.r) ? v.r : 0, g: Number.isFinite(v.g) ? v.g : 0, b: Number.isFinite(v.b) ? v.b : 0 };
}
const n = Number.isFinite(v) ? v : 0;
return { r: n, g: n, b: n };
};
const s = toTone(cb.shadows), m = toTone(cb.midtones), hi = toTone(cb.highlights);
const sR = s.r*scale, sG = s.g*scale, sB = s.b*scale;
const mR = m.r*scale, mG = m.g*scale, mB = m.b*scale;
const hR = hi.r*scale, hG = hi.g*scale, hB = hi.b*scale;
@@ -327,6 +360,116 @@ export function applyAdjustment(srcCanvas, adj) {
return out;
}
if (adj.type === 'invert') {
for (let i = 0; i < d.length; i += 4) {
d[i] = 255 - d[i];
d[i + 1] = 255 - d[i + 1];
d[i + 2] = 255 - d[i + 2];
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'posterize') {
const levels = Math.max(2, Math.min(255, Math.round(finiteOr(adj.params?.levels, 4))));
const step = 255 / (levels - 1);
for (let i = 0; i < d.length; i += 4) {
d[i] = clampByte(Math.round(d[i] / step) * step);
d[i + 1] = clampByte(Math.round(d[i + 1] / step) * step);
d[i + 2] = clampByte(Math.round(d[i + 2] / step) * step);
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'threshold') {
const level = Math.max(0, Math.min(255, Math.round(finiteOr(adj.params?.level, 128))));
for (let i = 0; i < d.length; i += 4) {
const luminance = 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2];
const value = luminance >= level ? 255 : 0;
d[i] = value; d[i + 1] = value; d[i + 2] = value;
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'photo-filter') {
const p = adj.params || {};
const color = parseHexColor(p.color, 'ec8a00');
const density = Math.max(0, Math.min(1, finiteOr(p.density, 25) / 100));
const preserve = p.preserve_luminosity !== false;
for (let i = 0; i < d.length; i += 4) {
const sourceLum = 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2];
let r = d[i] * (1 - density) + color[0] * density;
let g = d[i + 1] * (1 - density) + color[1] * density;
let b = d[i + 2] * (1 - density) + color[2] * density;
if (preserve) {
const filteredLum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
if (filteredLum > 0.001) { const scale = sourceLum / filteredLum; r *= scale; g *= scale; b *= scale; }
}
d[i] = clampByte(r); d[i + 1] = clampByte(g); d[i + 2] = clampByte(b);
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'channel-mixer') {
const p = adj.params || {};
const red = p.red || { r: 100, g: 0, b: 0 };
const green = p.green || { r: 0, g: 100, b: 0 };
const blue = p.blue || { r: 0, g: 0, b: 100 };
const constant = p.constant || { r: 0, g: 0, b: 0 };
const mono = !!p.monochrome;
for (let i = 0; i < d.length; i += 4) {
const r = d[i], g = d[i + 1], b = d[i + 2];
const nr = (r * finiteOr(red.r, 100) + g * finiteOr(red.g, 0) + b * finiteOr(red.b, 0)) / 100 + finiteOr(constant.r, 0) * 2.55;
const ng = (r * finiteOr(green.r, 0) + g * finiteOr(green.g, 100) + b * finiteOr(green.b, 0)) / 100 + finiteOr(constant.g, 0) * 2.55;
const nb = (r * finiteOr(blue.r, 0) + g * finiteOr(blue.g, 0) + b * finiteOr(blue.b, 100)) / 100 + finiteOr(constant.b, 0) * 2.55;
if (mono) { d[i] = d[i + 1] = d[i + 2] = clampByte(nr); continue; }
d[i] = clampByte(nr); d[i + 1] = clampByte(ng); d[i + 2] = clampByte(nb);
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'color-lookup') {
const preset = String(adj.params?.preset || 'none');
// An imported .cube LUT (Adobe Cube spec) takes precedence over the small
// built-in preset matrices. `params.cube` is stored serializable:
// { size, domainMin:[3], domainMax:[3], data:[size^3*3] } (red fastest)
const cube = adj.params?.cube;
if (cube && cube.size && cube.data) {
const lut = {
size: Number(cube.size),
domainMin: Array.isArray(cube.domainMin) ? cube.domainMin.slice(0, 3) : [0, 0, 0],
domainMax: Array.isArray(cube.domainMax) ? cube.domainMax.slice(0, 3) : [1, 1, 1],
data: cube.data instanceof Float64Array ? cube.data : new Float64Array(cube.data),
};
const strength = adj.params?.strength == null ? 1 : Math.max(0, Math.min(1, Number(adj.params.strength) / 100));
applyCubeLut(d, w, h, lut, { strength });
octx.putImageData(img, 0, 0);
return out;
}
const matrices = {
teal_orange: [[1.05, -0.05, 0.0], [0.0, 1.0, 0.0], [0.0, -0.02, 1.05]],
cold: [[0.95, 0.0, 0.05], [0.0, 1.0, 0.03], [0.02, 0.05, 1.12]],
warm: [[1.12, 0.02, 0.0], [0.03, 1.0, 0.0], [0.0, 0.02, 0.92]],
high_contrast: [[1.35, -0.18, -0.17], [-0.17, 1.35, -0.18], [-0.18, -0.17, 1.35]],
sepia: [[0.393, 0.769, 0.189], [0.349, 0.686, 0.168], [0.272, 0.534, 0.131]],
};
const m = matrices[preset];
if (m) {
for (let i = 0; i < d.length; i += 4) {
const r = d[i], g = d[i + 1], b = d[i + 2];
d[i] = clampByte(r * m[0][0] + g * m[0][1] + b * m[0][2]);
d[i + 1] = clampByte(r * m[1][0] + g * m[1][1] + b * m[1][2]);
d[i + 2] = clampByte(r * m[2][0] + g * m[2][1] + b * m[2][2]);
}
}
octx.putImageData(img, 0, 0);
return out;
}
return out;
}