mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-10 08:52:21 +02:00
Squash Odysseus development history
This commit is contained in:
@@ -43,11 +43,13 @@ import modalManager from '../../modalManager.js';
|
||||
import {
|
||||
ADJ_ICONS,
|
||||
adjLayerLabel,
|
||||
adjustmentPresetOptions,
|
||||
adjustmentPresetParams,
|
||||
defaultAdjParams,
|
||||
} from '../layer-helpers.js';
|
||||
import { drawHistogram } from './histogram.js';
|
||||
|
||||
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel }) {
|
||||
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, getAdjustmentSource = null }) {
|
||||
function suppressLayerGhostTap() {
|
||||
window.__geSuppressLayerTapUntil = Date.now() + 650;
|
||||
}
|
||||
@@ -104,6 +106,10 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
}
|
||||
|
||||
function openFxPopup(layer, anchorEl) {
|
||||
if (layer?.kind === 'adjustment') {
|
||||
openAdjPopup(layer, layer.adjustment?.type || 'levels', anchorEl, layer.adjustment);
|
||||
return;
|
||||
}
|
||||
// Toggle off ONLY if a menu for this layer is genuinely on-screen.
|
||||
// `state` is a shared singleton that survives editor close/reopen,
|
||||
// so a stale `fxMenuEl` from a previous session (whose detached
|
||||
@@ -135,9 +141,17 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
menu.style.pointerEvents = 'auto';
|
||||
const items = [
|
||||
{ type: 'brightness-contrast', label: 'Brightness / Contrast' },
|
||||
{ type: 'exposure', label: 'Exposure' },
|
||||
{ type: 'white-balance', label: 'White Balance' },
|
||||
{ type: 'hue-saturation', label: 'Hue / Saturation' },
|
||||
{ type: 'vibrance', label: 'Vibrance' },
|
||||
{ type: 'black-white', label: 'Black & White' },
|
||||
{ type: 'shadows-highlights', label: 'Shadows / Highlights' },
|
||||
{ type: 'levels', label: 'Levels' },
|
||||
{ type: 'curves', label: 'Curves' },
|
||||
{ type: 'color-balance', label: 'Color Balance' },
|
||||
{ type: 'selective-color', label: 'Selective Color' },
|
||||
{ type: 'gradient-map', label: 'Gradient Map' },
|
||||
];
|
||||
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>`
|
||||
@@ -257,7 +271,21 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
suppressLayerGhostTap();
|
||||
const layer = state.adjPopupEl._layer;
|
||||
if (layer) {
|
||||
if (state.adjPreviewLayer === layer) state.adjPreviewLayer = null;
|
||||
layer._adjPreview = false;
|
||||
layer._adjCompare = false;
|
||||
if (layer._stagedAdj) layer._stagedAdj = null;
|
||||
if (layer._newAdjustmentLayer) {
|
||||
const index = state.layers.findIndex(item => item.id === layer.id);
|
||||
if (index >= 0) state.layers.splice(index, 1);
|
||||
state.layerOffsets.delete(layer.id);
|
||||
if (Number.isInteger(layer._newAdjustmentUndoLength) && state.undoStack.length > layer._newAdjustmentUndoLength) {
|
||||
state.undoStack.length = layer._newAdjustmentUndoLength;
|
||||
}
|
||||
state.activeLayerId = state.layers[state.layers.length - 1]?.id || null;
|
||||
layer._newAdjustmentLayer = false;
|
||||
renderLayerPanel();
|
||||
}
|
||||
if (layer._editingAdjId) layer._editingAdjId = null;
|
||||
layer._adjFinalKey = null;
|
||||
composite();
|
||||
@@ -277,11 +305,15 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
closeAdjPopup();
|
||||
// Editing an existing sub-layer? Pre-load its params as the staged
|
||||
// preview and mark the popup so Apply updates instead of appending.
|
||||
const firstClass = layer?.kind === 'adjustment';
|
||||
const editing = !!existingAdj;
|
||||
const presetOptions = adjustmentPresetOptions(type);
|
||||
const startParams = editing
|
||||
? JSON.parse(JSON.stringify(existingAdj.params))
|
||||
: defaultAdjParams(type);
|
||||
layer._stagedAdj = { type, params: startParams };
|
||||
layer._adjPreview = true;
|
||||
state.adjPreviewLayer = layer;
|
||||
if (editing) {
|
||||
// Hide the existing sub-layer from the render stack so the
|
||||
// staged preview shows correctly without doubling the effect.
|
||||
@@ -303,8 +335,17 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
<button class="ge-adj-min" type="button" title="Minimise">−</button>
|
||||
</span>
|
||||
</div>
|
||||
<label class="ge-adj-preset-field">Preset
|
||||
<select class="ge-adj-preset-select">
|
||||
<option value="custom"${editing ? ' selected' : ''}>Custom</option>
|
||||
${presetOptions.map(name => `<option value="${name}"${!editing && name === 'Default' ? ' selected' : ''}>${name}</option>`).join('')}
|
||||
</select>
|
||||
</label>
|
||||
<div class="ge-adj-body" data-adj-body></div>
|
||||
<div class="ge-adj-foot">
|
||||
<button class="ge-btn ge-btn-sm ge-adj-reset-btn" data-adj-action="reset">Reset</button>
|
||||
<span class="ge-adj-foot-spacer"></span>
|
||||
<button class="ge-btn ge-btn-sm ge-adj-compare-btn" data-adj-action="compare" aria-pressed="false">Compare</button>
|
||||
<button class="ge-btn ge-btn-sm ge-adj-cancel-btn" data-adj-action="cancel">Cancel</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-primary ge-adj-apply-btn" data-adj-action="ok">Apply</button>
|
||||
</div>
|
||||
@@ -330,6 +371,15 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
|
||||
const body = pop.querySelector('[data-adj-body]');
|
||||
buildAdjBody(layer, type, body, pop);
|
||||
body.addEventListener('input', () => {
|
||||
const presetSelect = pop.querySelector('.ge-adj-preset-select');
|
||||
if (presetSelect) presetSelect.value = 'custom';
|
||||
});
|
||||
const popupRect = pop.getBoundingClientRect();
|
||||
if (!window.matchMedia('(max-width: 820px)').matches) {
|
||||
const clampedTop = Math.max(8, Math.min(Number.parseFloat(pop.style.top) || 8, window.innerHeight - popupRect.height - 8));
|
||||
pop.style.top = `${clampedTop}px`;
|
||||
}
|
||||
|
||||
pop.querySelector('.ge-adj-close')?.addEventListener('click', closeAdjPopup);
|
||||
pop.querySelector('.ge-adj-min')?.addEventListener('click', () => minimiseAdjPopup(pop));
|
||||
@@ -391,14 +441,52 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
e.stopPropagation();
|
||||
closeAdjPopup();
|
||||
});
|
||||
pop.querySelector('[data-adj-action="reset"]')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
layer._stagedAdj.params = defaultAdjParams(type);
|
||||
const presetSelect = pop.querySelector('.ge-adj-preset-select');
|
||||
if (presetSelect) presetSelect.value = 'Default';
|
||||
body.innerHTML = '';
|
||||
buildAdjBody(layer, type, body, pop);
|
||||
scheduleAdjRefresh(layer);
|
||||
});
|
||||
pop.querySelector('.ge-adj-preset-select')?.addEventListener('change', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const preset = e.currentTarget.value;
|
||||
layer._stagedAdj.params = adjustmentPresetParams(type, preset);
|
||||
layer._adjFinalKey = null;
|
||||
body.innerHTML = '';
|
||||
buildAdjBody(layer, type, body, pop);
|
||||
scheduleAdjRefresh(layer);
|
||||
});
|
||||
pop.querySelector('[data-adj-action="compare"]')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
layer._adjCompare = !layer._adjCompare;
|
||||
e.currentTarget.setAttribute('aria-pressed', layer._adjCompare ? 'true' : 'false');
|
||||
e.currentTarget.classList.toggle('is-active', layer._adjCompare);
|
||||
layer._adjFinalKey = null;
|
||||
composite();
|
||||
});
|
||||
pop.querySelector('[data-adj-action="ok"]')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
suppressLayerGhostTap();
|
||||
saveState(editing ? `Edit ${adjLayerLabel(type)}` : `Add ${adjLayerLabel(type)}`);
|
||||
if (!layer._newAdjustmentLayer) {
|
||||
saveState(editing ? `Edit ${adjLayerLabel(type)}` : `Add ${adjLayerLabel(type)}`);
|
||||
}
|
||||
const params = layer._stagedAdj.params;
|
||||
layer._stagedAdj = null;
|
||||
if (editing) {
|
||||
layer._adjPreview = false;
|
||||
layer._adjCompare = false;
|
||||
if (firstClass) {
|
||||
layer.adjustment = { type, params };
|
||||
layer.name = layer.name || adjLayerLabel(type);
|
||||
layer._newAdjustmentLayer = false;
|
||||
layer._newAdjustmentUndoLength = null;
|
||||
} else if (editing) {
|
||||
const existing = (layer.adjLayers || []).find(a => a.id === existingAdj.id);
|
||||
if (existing) existing.params = params;
|
||||
layer._editingAdjId = null;
|
||||
@@ -426,6 +514,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
state.adjRafPending = true;
|
||||
requestAnimationFrame(() => {
|
||||
state.adjRafPending = false;
|
||||
if (state.adjPreviewLayer !== layer || !layer._stagedAdj) return;
|
||||
layer._adjFinalKey = null;
|
||||
composite();
|
||||
});
|
||||
@@ -433,6 +522,13 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
|
||||
function buildAdjBody(layer, type, body, popEl) {
|
||||
const p = layer._stagedAdj.params;
|
||||
const levelValues = () => {
|
||||
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
|
||||
if (channel === 'rgb') return p;
|
||||
if (!p.channels) p.channels = {};
|
||||
if (!p.channels[channel]) p.channels[channel] = defaultAdjParams('levels').channels[channel];
|
||||
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}">
|
||||
@@ -449,14 +545,46 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
${sliderRow('brightness', 'Brightness', -100, 100, bSlider, '')}
|
||||
${sliderRow('contrast', 'Contrast', -100, 100, cSlider, '')}
|
||||
`;
|
||||
} else if (type === 'exposure') {
|
||||
body.innerHTML = `
|
||||
${sliderRow('exposure', 'Exposure', -500, 500, Math.round(p.exposure * 100), ' EV')}
|
||||
${sliderRow('offset', 'Offset', -50, 50, Math.round(p.offset * 100), '')}
|
||||
${sliderRow('gamma', 'Gamma', 10, 300, Math.round(p.gamma * 100), 'γ')}
|
||||
`;
|
||||
} else if (type === 'white-balance') {
|
||||
body.innerHTML = `
|
||||
${sliderRow('temperature', 'Temperature', -100, 100, Math.round(p.temperature), '')}
|
||||
${sliderRow('tint', 'Tint', -100, 100, Math.round(p.tint), '')}
|
||||
`;
|
||||
} else if (type === 'hue-saturation') {
|
||||
const hSlider = Math.round(p.hue);
|
||||
const sSlider = Math.round((p.saturation - 1) * 100);
|
||||
body.innerHTML = `
|
||||
${sliderRow('hue', 'Hue', -180, 180, hSlider, ' °')}
|
||||
${sliderRow('saturation', 'Saturation', -100, 100, sSlider, '')}
|
||||
${sliderRow('lightness', 'Lightness', -100, 100, Math.round(p.lightness || 0), '')}
|
||||
`;
|
||||
} else if (type === 'vibrance') {
|
||||
body.innerHTML = `
|
||||
${sliderRow('vibrance', 'Vibrance', -100, 100, Math.round(p.vibrance || 0), '')}
|
||||
<p class="ge-adj-help">Boosts muted colors more than already-saturated colors.</p>
|
||||
`;
|
||||
} else if (type === 'black-white') {
|
||||
body.innerHTML = `
|
||||
${sliderRow('red', 'Red', 0, 100, Math.round(p.red ?? 30), '%')}
|
||||
${sliderRow('green', 'Green', 0, 100, Math.round(p.green ?? 59), '%')}
|
||||
${sliderRow('blue', 'Blue', 0, 100, Math.round(p.blue ?? 11), '%')}
|
||||
${sliderRow('constant', 'Constant', -100, 100, Math.round(p.constant || 0), '')}
|
||||
<p class="ge-adj-help">Mixes the source channels into a neutral grayscale result.</p>
|
||||
`;
|
||||
} else if (type === 'shadows-highlights') {
|
||||
body.innerHTML = `
|
||||
${sliderRow('shadows', 'Shadows', -100, 100, Math.round(p.shadows || 0), '')}
|
||||
${sliderRow('highlights', 'Highlights', -100, 100, Math.round(p.highlights || 0), '')}
|
||||
<p class="ge-adj-help">Recover detail in dark and bright tones without shifting midtones.</p>
|
||||
`;
|
||||
} else if (type === 'levels') {
|
||||
const values = levelValues();
|
||||
// Histogram canvas + sliders. Histogram is computed from the
|
||||
// layer's pixel data (after any adjLayers below this one) so
|
||||
// the user is matching levels against what they're really seeing.
|
||||
@@ -464,6 +592,14 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
// vertical space; open by default on desktop.
|
||||
const isMobile = window.matchMedia('(max-width: 820px)').matches;
|
||||
body.innerHTML = `
|
||||
<label class="ge-adj-channel-field">Channel
|
||||
<select class="ge-adj-channel-select">
|
||||
<option value="rgb"${(p.channel || 'rgb') === 'rgb' ? ' selected' : ''}>RGB</option>
|
||||
<option value="red"${p.channel === 'red' ? ' selected' : ''}>Red</option>
|
||||
<option value="green"${p.channel === 'green' ? ' selected' : ''}>Green</option>
|
||||
<option value="blue"${p.channel === 'blue' ? ' selected' : ''}>Blue</option>
|
||||
</select>
|
||||
</label>
|
||||
<details class="ge-adj-hist-details"${isMobile ? '' : ' open'}>
|
||||
<summary>Histogram</summary>
|
||||
<div class="ge-adj-hist-wrap">
|
||||
@@ -474,20 +610,60 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
<div class="ge-adj-hist-handle hist-h-white" data-handle="inWhite" title="Input white — drag"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-adj-clip-status" aria-live="polite"></div>
|
||||
</details>
|
||||
${sliderRow('inBlack', 'Input black', 0, 254, p.inBlack, '')}
|
||||
${sliderRow('inWhite', 'Input white', 1, 255, p.inWhite, '')}
|
||||
${sliderRow('gamma', 'Gamma', 10, 990, Math.round((p.gamma || 1) * 100), 'γ')}
|
||||
${sliderRow('outBlack', 'Output black', 0, 255, p.outBlack, '')}
|
||||
${sliderRow('outWhite', 'Output white', 0, 255, p.outWhite, '')}
|
||||
${sliderRow('inBlack', 'Input black', 0, 254, values.inBlack, '')}
|
||||
${sliderRow('inWhite', 'Input white', 1, 255, values.inWhite, '')}
|
||||
${sliderRow('gamma', 'Gamma', 10, 990, Math.round((values.gamma || 1) * 100), 'γ')}
|
||||
${sliderRow('outBlack', 'Output black', 0, 255, values.outBlack, '')}
|
||||
${sliderRow('outWhite', 'Output white', 0, 255, values.outWhite, '')}
|
||||
`;
|
||||
const hist = body.querySelector('.ge-adj-histogram');
|
||||
drawHistogram(hist, layer);
|
||||
const histogramLayer = layer.kind === 'adjustment' && getAdjustmentSource
|
||||
? { canvas: getAdjustmentSource(layer), _stagedAdj: { params: values } }
|
||||
: layer;
|
||||
const clipStatus = body.querySelector('.ge-adj-clip-status');
|
||||
const renderHistogram = () => {
|
||||
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
|
||||
const stats = drawHistogram(hist, histogramLayer, channel);
|
||||
if (clipStatus) {
|
||||
const channelLabel = channel === 'rgb' ? 'RGB' : channel[0].toUpperCase() + channel.slice(1);
|
||||
clipStatus.textContent = `${channelLabel} clipping: black ${stats.black.toFixed(1)}% · white ${stats.white.toFixed(1)}%`;
|
||||
}
|
||||
};
|
||||
renderHistogram();
|
||||
wireHistogramHandles(body, layer, type);
|
||||
// Redraw histogram when the user opens the disclosure (canvas
|
||||
// dimensions are layout-dependent).
|
||||
body.querySelector('.ge-adj-hist-details')?.addEventListener('toggle', (e) => {
|
||||
if (e.target.open) drawHistogram(hist, layer);
|
||||
if (e.target.open) renderHistogram();
|
||||
});
|
||||
body.querySelector('.ge-adj-channel-select')?.addEventListener('change', event => {
|
||||
p.channel = event.target.value;
|
||||
buildAdjBody(layer, type, body, popEl);
|
||||
});
|
||||
} else if (type === 'curves') {
|
||||
if (!p.points) p.points = defaultAdjParams('curves').points;
|
||||
if (!p.channel) p.channel = 'rgb';
|
||||
body.innerHTML = `
|
||||
<label class="ge-adj-channel-field">Channel
|
||||
<select class="ge-adj-channel-select">
|
||||
<option value="rgb"${p.channel === 'rgb' ? ' selected' : ''}>RGB</option>
|
||||
<option value="red"${p.channel === 'red' ? ' selected' : ''}>Red</option>
|
||||
<option value="green"${p.channel === 'green' ? ' selected' : ''}>Green</option>
|
||||
<option value="blue"${p.channel === 'blue' ? ' selected' : ''}>Blue</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="ge-curves-wrap">
|
||||
<canvas class="ge-curves-canvas" width="280" height="180" aria-label="Editable tone curve"></canvas>
|
||||
</div>
|
||||
<p class="ge-adj-help">Click the curve to add a point. Drag to adjust. Double-click a point to remove it.</p>
|
||||
`;
|
||||
const canvas = body.querySelector('.ge-curves-canvas');
|
||||
wireCurveEditor(canvas, layer);
|
||||
body.querySelector('.ge-adj-channel-select')?.addEventListener('change', event => {
|
||||
p.channel = event.target.value;
|
||||
buildAdjBody(layer, type, body, popEl);
|
||||
});
|
||||
} else if (type === 'color-balance') {
|
||||
// Color-tinted slider ends so the user sees what direction does what.
|
||||
@@ -527,6 +703,49 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
body.innerHTML = '';
|
||||
buildAdjBody(layer, type, body, popEl);
|
||||
});
|
||||
} else if (type === 'selective-color') {
|
||||
if (!p.range) p.range = 'reds';
|
||||
if (!p.ranges?.[p.range]) p.ranges = defaultAdjParams(type).ranges;
|
||||
const values = p.ranges[p.range];
|
||||
const ranges = ['reds', 'yellows', 'greens', 'cyans', 'blues', 'magentas', 'neutrals', 'blacks'];
|
||||
body.innerHTML = `
|
||||
<label class="ge-adj-channel-field">Colors
|
||||
<select class="ge-adj-selective-range">
|
||||
${ranges.map(name => `<option value="${name}"${p.range === name ? ' selected' : ''}>${name[0].toUpperCase() + name.slice(1)}</option>`).join('')}
|
||||
</select>
|
||||
</label>
|
||||
${sliderRow('cyan', 'Cyan', -100, 100, values.cyan, '')}
|
||||
${sliderRow('magenta', 'Magenta', -100, 100, values.magenta, '')}
|
||||
${sliderRow('yellow', 'Yellow', -100, 100, values.yellow, '')}
|
||||
${sliderRow('black', 'Black', -100, 100, values.black, '')}
|
||||
`;
|
||||
body.querySelector('.ge-adj-selective-range')?.addEventListener('change', event => {
|
||||
p.range = event.target.value;
|
||||
buildAdjBody(layer, type, body, popEl);
|
||||
});
|
||||
} else if (type === 'gradient-map') {
|
||||
body.innerHTML = `
|
||||
<div class="ge-gradient-map-preview" style="--ge-gradient-start:${p.shadows};--ge-gradient-end:${p.highlights}"></div>
|
||||
<div class="ge-gradient-color-row">
|
||||
<label>Shadows <input type="color" data-gradient-key="shadows" value="${p.shadows}"></label>
|
||||
<label>Highlights <input type="color" data-gradient-key="highlights" value="${p.highlights}"></label>
|
||||
</div>
|
||||
${sliderRow('midpoint', 'Midpoint', 1, 99, Math.round(p.midpoint), '%')}
|
||||
<label class="ge-adj-toggle-row"><span>Reverse</span><input type="checkbox" data-gradient-reverse${p.reverse ? ' checked' : ''}><span class="ge-toggle-track"></span></label>
|
||||
`;
|
||||
body.querySelectorAll('[data-gradient-key]').forEach(input => {
|
||||
input.addEventListener('input', () => {
|
||||
p[input.dataset.gradientKey] = input.value;
|
||||
const preview = body.querySelector('.ge-gradient-map-preview');
|
||||
preview?.style.setProperty('--ge-gradient-start', p.shadows);
|
||||
preview?.style.setProperty('--ge-gradient-end', p.highlights);
|
||||
scheduleAdjRefresh(layer);
|
||||
});
|
||||
});
|
||||
body.querySelector('[data-gradient-reverse]')?.addEventListener('change', event => {
|
||||
p.reverse = event.target.checked;
|
||||
scheduleAdjRefresh(layer);
|
||||
});
|
||||
}
|
||||
// Wire all sliders.
|
||||
body.querySelectorAll('input[type="range"]').forEach(sl => {
|
||||
@@ -550,13 +769,18 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
function revertAdjKey(layer, type, key) {
|
||||
const defaults = defaultAdjParams(type);
|
||||
const p = layer._stagedAdj.params;
|
||||
if (type === 'brightness-contrast' || type === 'hue-saturation') {
|
||||
if (['brightness-contrast', 'exposure', 'white-balance', 'hue-saturation', 'vibrance', 'black-white', 'shadows-highlights', 'gradient-map'].includes(type)) {
|
||||
p[key] = defaults[key];
|
||||
} else if (type === 'levels') {
|
||||
p[key] = defaults[key];
|
||||
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
|
||||
const values = channel === 'rgb' ? p : p.channels[channel];
|
||||
const defaultValues = channel === 'rgb' ? defaults : defaults.channels[channel];
|
||||
values[key] = defaultValues[key];
|
||||
} else if (type === 'color-balance') {
|
||||
const [tone, ch] = key.split('-');
|
||||
p[tone][ch] = defaults[tone][ch];
|
||||
} else if (type === 'selective-color') {
|
||||
p.ranges[p.range][key] = defaults.ranges[p.range][key];
|
||||
}
|
||||
layer._adjFinalKey = null;
|
||||
composite();
|
||||
@@ -573,21 +797,142 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
p[key] = 1 + raw / 100;
|
||||
} else if (key === 'hue') {
|
||||
p.hue = raw; display = raw + ' °';
|
||||
} else if (key === 'lightness') {
|
||||
p.lightness = raw;
|
||||
}
|
||||
} else if (type === 'exposure') {
|
||||
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 === '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') {
|
||||
p[key] = raw;
|
||||
} else if (type === 'levels') {
|
||||
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
|
||||
const values = channel === 'rgb' ? p : p.channels[channel];
|
||||
if (key === 'gamma') {
|
||||
p.gamma = raw / 100; display = (raw / 100).toFixed(2) + 'γ';
|
||||
values.gamma = raw / 100; display = (raw / 100).toFixed(2) + 'γ';
|
||||
} else {
|
||||
p[key] = raw;
|
||||
values[key] = raw;
|
||||
}
|
||||
} else if (type === 'color-balance') {
|
||||
const [tone, ch] = key.split('-');
|
||||
p[tone][ch] = raw;
|
||||
} else if (type === 'selective-color') {
|
||||
p.ranges[p.range][key] = raw;
|
||||
} else if (type === 'gradient-map') {
|
||||
p.midpoint = raw;
|
||||
display = raw + '%';
|
||||
}
|
||||
if (valEl) valEl.textContent = display;
|
||||
scheduleAdjRefresh(layer);
|
||||
}
|
||||
|
||||
function wireCurveEditor(canvas, layer) {
|
||||
if (!canvas) return;
|
||||
const params = layer._stagedAdj.params;
|
||||
const channelColor = { rgb: '#f1f5f9', red: '#ff6b78', green: '#57d68d', blue: '#69a7ff' };
|
||||
const points = () => params.points[params.channel] || params.points.rgb;
|
||||
const canvasPoint = event => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.max(0, Math.min(255, ((event.clientX - rect.left) / rect.width) * 255)),
|
||||
y: Math.max(0, Math.min(255, (1 - (event.clientY - rect.top) / rect.height) * 255)),
|
||||
};
|
||||
};
|
||||
const draw = () => {
|
||||
const source = layer.kind === 'adjustment' && getAdjustmentSource
|
||||
? getAdjustmentSource(layer)
|
||||
: layer.canvas;
|
||||
drawHistogram(canvas, { canvas: source });
|
||||
const ctx = canvas.getContext('2d');
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
ctx.strokeStyle = 'rgba(255,255,255,.12)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let index = 1; index < 4; index += 1) {
|
||||
const x = Math.round(width * index / 4) + .5;
|
||||
const y = Math.round(height * index / 4) + .5;
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, height); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke();
|
||||
}
|
||||
const list = points();
|
||||
ctx.strokeStyle = channelColor[params.channel] || channelColor.rgb;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
list.forEach((point, index) => {
|
||||
const x = point[0] / 255 * width;
|
||||
const y = height - point[1] / 255 * height;
|
||||
if (index) ctx.lineTo(x, y); else ctx.moveTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
for (const point of list) {
|
||||
const x = point[0] / 255 * width;
|
||||
const y = height - point[1] / 255 * height;
|
||||
ctx.fillStyle = '#0b0d10';
|
||||
ctx.strokeStyle = channelColor[params.channel] || channelColor.rgb;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||||
}
|
||||
};
|
||||
const nearestIndex = event => {
|
||||
const point = canvasPoint(event);
|
||||
let nearest = -1;
|
||||
let distance = 14;
|
||||
points().forEach((candidate, index) => {
|
||||
const next = Math.hypot(candidate[0] - point.x, candidate[1] - point.y);
|
||||
if (next < distance) { distance = next; nearest = index; }
|
||||
});
|
||||
return nearest;
|
||||
};
|
||||
canvas.addEventListener('pointerdown', event => {
|
||||
event.preventDefault();
|
||||
const list = points();
|
||||
let index = nearestIndex(event);
|
||||
if (index < 0) {
|
||||
const point = canvasPoint(event);
|
||||
list.push([Math.round(point.x), Math.round(point.y)]);
|
||||
list.sort((a, b) => a[0] - b[0]);
|
||||
index = list.findIndex(item => item[0] === Math.round(point.x) && item[1] === Math.round(point.y));
|
||||
}
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
const move = moveEvent => {
|
||||
const next = canvasPoint(moveEvent);
|
||||
const current = list[index];
|
||||
const endpoint = index === 0 || index === list.length - 1;
|
||||
current[0] = endpoint
|
||||
? (index === 0 ? 0 : 255)
|
||||
: Math.round(Math.max(list[index - 1][0] + 1, Math.min(list[index + 1][0] - 1, next.x)));
|
||||
current[1] = Math.round(next.y);
|
||||
draw();
|
||||
scheduleAdjRefresh(layer);
|
||||
};
|
||||
const up = () => {
|
||||
canvas.releasePointerCapture(event.pointerId);
|
||||
canvas.removeEventListener('pointermove', move);
|
||||
canvas.removeEventListener('pointerup', up);
|
||||
};
|
||||
canvas.addEventListener('pointermove', move);
|
||||
canvas.addEventListener('pointerup', up);
|
||||
draw();
|
||||
scheduleAdjRefresh(layer);
|
||||
});
|
||||
canvas.addEventListener('dblclick', event => {
|
||||
const index = nearestIndex(event);
|
||||
const list = points();
|
||||
if (index <= 0 || index >= list.length - 1) return;
|
||||
list.splice(index, 1);
|
||||
draw();
|
||||
scheduleAdjRefresh(layer);
|
||||
});
|
||||
draw();
|
||||
}
|
||||
|
||||
// Position the three histogram triangle handles by current staged
|
||||
// values + wire pointer drags.
|
||||
function wireHistogramHandles(bodyEl, layer, type) {
|
||||
@@ -595,9 +940,14 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
const canvas = bodyEl.querySelector('.ge-adj-histogram');
|
||||
if (!wrap || !canvas) return;
|
||||
const handles = bodyEl.querySelectorAll('.ge-adj-hist-handle');
|
||||
const levelParams = () => {
|
||||
const root = layer._stagedAdj.params;
|
||||
const channel = ['red', 'green', 'blue'].includes(root.channel) ? root.channel : 'rgb';
|
||||
return channel === 'rgb' ? root : root.channels[channel];
|
||||
};
|
||||
const placeHandles = () => {
|
||||
const w = canvas.getBoundingClientRect().width;
|
||||
const p = layer._stagedAdj.params;
|
||||
const p = levelParams();
|
||||
const xB = (p.inBlack / 255) * w;
|
||||
const xW = (p.inWhite / 255) * w;
|
||||
// Gamma handle sits at a fraction of the (xB..xW) span, mapped
|
||||
@@ -623,7 +973,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
const onMove = (ev) => {
|
||||
const x = Math.max(0, Math.min(rect.width, ev.clientX - rect.left));
|
||||
const v = Math.round((x / rect.width) * 255);
|
||||
const p = layer._stagedAdj.params;
|
||||
const p = levelParams();
|
||||
if (which === 'inBlack') {
|
||||
p.inBlack = Math.min(p.inWhite - 1, v);
|
||||
} else if (which === 'inWhite') {
|
||||
@@ -642,14 +992,26 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
// Update visible slider rows + value labels.
|
||||
const updateRow = (key, displayVal) => {
|
||||
const sl = bodyEl.querySelector(`input[type="range"][data-key="${key}"]`);
|
||||
if (sl) sl.value = String(key === 'gamma' ? Math.round(layer._stagedAdj.params.gamma * 100) : layer._stagedAdj.params[key]);
|
||||
const values = levelParams();
|
||||
if (sl) sl.value = String(key === 'gamma' ? Math.round(values.gamma * 100) : values[key]);
|
||||
const val = sl?.parentElement.querySelector('.ge-adj-value');
|
||||
if (val) val.textContent = displayVal;
|
||||
};
|
||||
if (which === 'inBlack') updateRow('inBlack', String(layer._stagedAdj.params.inBlack));
|
||||
if (which === 'inWhite') updateRow('inWhite', String(layer._stagedAdj.params.inWhite));
|
||||
if (which === 'gamma') updateRow('gamma', layer._stagedAdj.params.gamma.toFixed(2) + 'γ');
|
||||
drawHistogram(canvas, layer);
|
||||
const values = levelParams();
|
||||
if (which === 'inBlack') updateRow('inBlack', String(values.inBlack));
|
||||
if (which === 'inWhite') updateRow('inWhite', String(values.inWhite));
|
||||
if (which === 'gamma') updateRow('gamma', values.gamma.toFixed(2) + 'γ');
|
||||
const histogramLayer = layer.kind === 'adjustment' && getAdjustmentSource
|
||||
? { canvas: getAdjustmentSource(layer), _stagedAdj: { params: values } }
|
||||
: layer;
|
||||
const channel = ['red', 'green', 'blue'].includes(layer._stagedAdj.params.channel)
|
||||
? layer._stagedAdj.params.channel : 'rgb';
|
||||
const stats = drawHistogram(canvas, histogramLayer, channel);
|
||||
const clipStatus = bodyEl.querySelector('.ge-adj-clip-status');
|
||||
if (clipStatus) {
|
||||
const channelLabel = channel === 'rgb' ? 'RGB' : channel[0].toUpperCase() + channel.slice(1);
|
||||
clipStatus.textContent = `${channelLabel} clipping: black ${stats.black.toFixed(1)}% · white ${stats.white.toFixed(1)}%`;
|
||||
}
|
||||
scheduleAdjRefresh(layer);
|
||||
};
|
||||
const onUp = () => {
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
* canvas: HTMLCanvasElement,
|
||||
* _stagedAdj?: {params?: {inBlack?: number, inWhite?: number}}
|
||||
* }} layer Source layer.
|
||||
* @param {'rgb'|'red'|'green'|'blue'} channel Channel to inspect. `rgb` uses luminance.
|
||||
* @returns {{black: number, white: number, samples: number}}
|
||||
*/
|
||||
export function drawHistogram(canvas, layer) {
|
||||
if (!canvas) return;
|
||||
export function drawHistogram(canvas, layer, channel = 'rgb') {
|
||||
if (!canvas) return { black: 0, white: 0, samples: 0 };
|
||||
const w = canvas.width, h = canvas.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
@@ -33,11 +35,21 @@ export function drawHistogram(canvas, layer) {
|
||||
const img = tctx.getImageData(0, 0, sampleW, sampleH).data;
|
||||
|
||||
const hist = new Uint32Array(256);
|
||||
let samples = 0;
|
||||
let black = 0;
|
||||
let white = 0;
|
||||
for (let i = 0; i < img.length; i += 4) {
|
||||
if (img[i + 3] < 8) continue; // skip near-transparent
|
||||
// Rec. 709 luminance — common choice for histograms in photo editors.
|
||||
const Y = (0.2126 * img[i] + 0.7152 * img[i + 1] + 0.0722 * img[i + 2]) | 0;
|
||||
hist[Math.min(255, Y)]++;
|
||||
const value = channel === 'red' ? img[i]
|
||||
: channel === 'green' ? img[i + 1]
|
||||
: channel === 'blue' ? img[i + 2]
|
||||
// Rec. 709 luminance — common choice for composite histograms.
|
||||
: 0.2126 * img[i] + 0.7152 * img[i + 1] + 0.0722 * img[i + 2];
|
||||
const bucket = Math.max(0, Math.min(255, value | 0));
|
||||
hist[bucket]++;
|
||||
samples++;
|
||||
if (bucket === 0) black++;
|
||||
if (bucket === 255) white++;
|
||||
}
|
||||
let peak = 1;
|
||||
for (let i = 0; i < 256; i++) if (hist[i] > peak) peak = hist[i];
|
||||
@@ -64,4 +76,9 @@ export function drawHistogram(canvas, layer) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.9)';
|
||||
ctx.fillRect((p.inWhite / 256) * w, 0, 1, h);
|
||||
}
|
||||
return {
|
||||
black: samples ? black / samples * 100 : 0,
|
||||
white: samples ? white / samples * 100 : 0,
|
||||
samples,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Apply a Brightness/Contrast, Hue/Saturation, Levels, or Color Balance
|
||||
* Apply a Brightness/Contrast, Black & White, Hue/Saturation, Levels, Curves, or Color Balance
|
||||
* adjustment to a source canvas and return a fresh canvas with the
|
||||
* result. Pure pixel math — no DOM, no module state.
|
||||
*
|
||||
@@ -11,8 +11,111 @@
|
||||
* { type: 'brightness-contrast', params: { brightness, contrast } }
|
||||
* { type: 'hue-saturation', params: { hue, saturation } }
|
||||
* { type: 'levels', params: { inBlack, inWhite, gamma, outBlack, outWhite } }
|
||||
* { type: 'curves', params: { points: {rgb, red, green, blue} } }
|
||||
* { type: 'color-balance', params: { shadows, midtones, highlights } }
|
||||
*/
|
||||
function clampByte(value) {
|
||||
return value < 0 ? 0 : value > 255 ? 255 : Math.round(value);
|
||||
}
|
||||
|
||||
function finiteOr(value, fallback) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : fallback;
|
||||
}
|
||||
|
||||
function levelLut(values = {}) {
|
||||
const inLow = Math.max(0, Math.min(254, finiteOr(values.inBlack, 0)));
|
||||
const inHigh = Math.max(inLow + 1, Math.min(255, finiteOr(values.inWhite, 255)));
|
||||
const gamma = Math.max(0.1, finiteOr(values.gamma, 1));
|
||||
const outLow = Math.max(0, Math.min(255, finiteOr(values.outBlack, 0)));
|
||||
const outHigh = Math.max(outLow, Math.min(255, finiteOr(values.outWhite, 255)));
|
||||
const lut = new Uint8ClampedArray(256);
|
||||
for (let value = 0; value < 256; value += 1) {
|
||||
const normalized = Math.max(0, Math.min(1, (value - inLow) / (inHigh - inLow)));
|
||||
lut[value] = clampByte(Math.pow(normalized, 1 / gamma) * (outHigh - outLow) + outLow);
|
||||
}
|
||||
return lut;
|
||||
}
|
||||
|
||||
function normalizedCurvePoints(points) {
|
||||
const clean = (Array.isArray(points) ? points : [])
|
||||
.filter(point => Array.isArray(point) && point.length >= 2)
|
||||
.map(point => [clampByte(Number(point[0]) || 0), clampByte(Number(point[1]) || 0)])
|
||||
.sort((a, b) => a[0] - b[0]);
|
||||
if (!clean.length || clean[0][0] !== 0) clean.unshift([0, clean[0]?.[1] ?? 0]);
|
||||
if (clean[clean.length - 1][0] !== 255) clean.push([255, clean[clean.length - 1]?.[1] ?? 255]);
|
||||
const unique = [];
|
||||
for (const point of clean) {
|
||||
if (unique.length && unique[unique.length - 1][0] === point[0]) unique[unique.length - 1] = point;
|
||||
else unique.push(point);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
export function curveLut(points) {
|
||||
const clean = normalizedCurvePoints(points);
|
||||
const lut = new Uint8ClampedArray(256);
|
||||
let segment = 0;
|
||||
for (let value = 0; value < 256; value += 1) {
|
||||
while (segment < clean.length - 2 && value > clean[segment + 1][0]) segment += 1;
|
||||
const left = clean[segment];
|
||||
const right = clean[Math.min(segment + 1, clean.length - 1)];
|
||||
const span = Math.max(1, right[0] - left[0]);
|
||||
const amount = Math.max(0, Math.min(1, (value - left[0]) / span));
|
||||
lut[value] = clampByte(left[1] + (right[1] - left[1]) * amount);
|
||||
}
|
||||
return lut;
|
||||
}
|
||||
|
||||
function rgbToHsl(r, g, b) {
|
||||
r /= 255; g /= 255; b /= 255;
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
const lightness = (max + min) / 2;
|
||||
if (max === min) return [0, 0, lightness];
|
||||
const delta = max - min;
|
||||
const saturation = lightness > .5 ? delta / (2 - max - min) : delta / (max + min);
|
||||
let hue;
|
||||
if (max === r) hue = ((g - b) / delta + (g < b ? 6 : 0)) / 6;
|
||||
else if (max === g) hue = ((b - r) / delta + 2) / 6;
|
||||
else hue = ((r - g) / delta + 4) / 6;
|
||||
return [hue, saturation, lightness];
|
||||
}
|
||||
|
||||
function hueChannel(p, q, t) {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
}
|
||||
|
||||
function hslToRgb(h, s, l) {
|
||||
if (!s) return [l * 255, l * 255, l * 255];
|
||||
const q = l < .5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
return [hueChannel(p, q, h + 1 / 3) * 255, hueChannel(p, q, h) * 255, hueChannel(p, q, h - 1 / 3) * 255];
|
||||
}
|
||||
|
||||
function parseHexColor(value, fallback) {
|
||||
const match = /^#?([0-9a-f]{6})$/i.exec(String(value || ''));
|
||||
const hex = match ? match[1] : fallback;
|
||||
return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)];
|
||||
}
|
||||
|
||||
function hueDistance(a, b) {
|
||||
const distance = Math.abs(a - b);
|
||||
return Math.min(distance, 1 - distance);
|
||||
}
|
||||
|
||||
function selectiveRangeWeight(name, hue, saturation, lightness) {
|
||||
if (name === 'blacks') return Math.max(0, Math.min(1, (0.55 - lightness) / 0.45));
|
||||
if (name === 'neutrals') return Math.max(0, Math.min(1, (1 - saturation) * (1 - Math.abs(lightness - .5) * 1.5)));
|
||||
const centers = { reds: 0, yellows: 1 / 6, greens: 1 / 3, cyans: .5, blues: 2 / 3, magentas: 5 / 6 };
|
||||
const distance = hueDistance(hue, centers[name] ?? 0);
|
||||
return Math.max(0, 1 - distance * 6) * saturation;
|
||||
}
|
||||
|
||||
export function applyAdjustment(srcCanvas, adj) {
|
||||
const w = srcCanvas.width, h = srcCanvas.height;
|
||||
const out = document.createElement('canvas');
|
||||
@@ -27,37 +130,129 @@ export function applyAdjustment(srcCanvas, adj) {
|
||||
octx.filter = 'none';
|
||||
return out;
|
||||
}
|
||||
if (adj.type === 'hue-saturation') {
|
||||
const p = adj.params;
|
||||
octx.filter = `saturate(${p.saturation}) hue-rotate(${p.hue}deg)`;
|
||||
octx.drawImage(srcCanvas, 0, 0);
|
||||
octx.filter = 'none';
|
||||
return out;
|
||||
}
|
||||
|
||||
// Levels + Color Balance need per-pixel math.
|
||||
// The remaining adjustments need deterministic per-pixel math so preview,
|
||||
// flattening, and reopened documents produce the same result.
|
||||
octx.drawImage(srcCanvas, 0, 0);
|
||||
const img = octx.getImageData(0, 0, w, h);
|
||||
const d = img.data;
|
||||
|
||||
if (adj.type === 'exposure') {
|
||||
const p = adj.params || {};
|
||||
const multiplier = Math.pow(2, finiteOr(p.exposure, 0));
|
||||
const offset = finiteOr(p.offset, 0);
|
||||
const gamma = Math.max(.1, finiteOr(p.gamma, 1));
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
d[i] = clampByte(Math.pow(Math.max(0, Math.min(1, d[i] / 255 * multiplier + offset)), 1 / gamma) * 255);
|
||||
d[i + 1] = clampByte(Math.pow(Math.max(0, Math.min(1, d[i + 1] / 255 * multiplier + offset)), 1 / gamma) * 255);
|
||||
d[i + 2] = clampByte(Math.pow(Math.max(0, Math.min(1, d[i + 2] / 255 * multiplier + offset)), 1 / gamma) * 255);
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'white-balance') {
|
||||
const p = adj.params || {};
|
||||
const temperature = finiteOr(p.temperature, 0) / 100;
|
||||
const tint = finiteOr(p.tint, 0) / 100;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
d[i] = clampByte(d[i] * (1 + temperature * .28 + tint * .09));
|
||||
d[i + 1] = clampByte(d[i + 1] * (1 - tint * .18));
|
||||
d[i + 2] = clampByte(d[i + 2] * (1 - temperature * .28 + tint * .09));
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'hue-saturation') {
|
||||
const p = adj.params || {};
|
||||
const hueShift = finiteOr(p.hue, 0) / 360;
|
||||
const saturationScale = Math.max(0, finiteOr(p.saturation, 1));
|
||||
const lightnessShift = finiteOr(p.lightness, 0) / 100;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
let [hue, saturation, lightness] = rgbToHsl(d[i], d[i + 1], d[i + 2]);
|
||||
hue = (hue + hueShift + 1) % 1;
|
||||
saturation = Math.max(0, Math.min(1, saturation * saturationScale));
|
||||
lightness = Math.max(0, Math.min(1, lightness + lightnessShift));
|
||||
const rgb = hslToRgb(hue, saturation, lightness);
|
||||
d[i] = clampByte(rgb[0]); d[i + 1] = clampByte(rgb[1]); d[i + 2] = clampByte(rgb[2]);
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'vibrance') {
|
||||
const amount = Math.max(-1, Math.min(1, finiteOr(adj.params?.vibrance, 0) / 100));
|
||||
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]);
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'black-white') {
|
||||
const p = adj.params || {};
|
||||
const red = finiteOr(p.red, 30) / 100;
|
||||
const green = finiteOr(p.green, 59) / 100;
|
||||
const blue = finiteOr(p.blue, 11) / 100;
|
||||
const constant = finiteOr(p.constant, 0) * 2.55;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const gray = d[i] * red + d[i + 1] * green + d[i + 2] * blue + constant;
|
||||
d[i] = clampByte(gray);
|
||||
d[i + 1] = clampByte(gray);
|
||||
d[i + 2] = clampByte(gray);
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
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;
|
||||
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));
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'levels') {
|
||||
const l = adj.params;
|
||||
const inLow = Math.max(0, Math.min(254, l.inBlack));
|
||||
const inHigh = Math.max(inLow + 1, Math.min(255, l.inWhite));
|
||||
const gamma = Math.max(0.1, l.gamma || 1);
|
||||
const outLow = Math.max(0, Math.min(255, l.outBlack));
|
||||
const outHigh = Math.max(outLow, Math.min(255, l.outWhite));
|
||||
const inv = 1.0 / gamma;
|
||||
const span = (outHigh - outLow);
|
||||
const lut = new Uint8ClampedArray(256);
|
||||
for (let v = 0; v < 256; v++) {
|
||||
let t = (v - inLow) / (inHigh - inLow);
|
||||
if (t < 0) t = 0; else if (t > 1) t = 1;
|
||||
t = Math.pow(t, inv);
|
||||
lut[v] = Math.round(t * span + outLow);
|
||||
}
|
||||
const master = levelLut(l);
|
||||
const red = levelLut(l.channels?.red);
|
||||
const green = levelLut(l.channels?.green);
|
||||
const blue = levelLut(l.channels?.blue);
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
d[i] = lut[d[i]]; d[i+1] = lut[d[i+1]]; d[i+2] = lut[d[i+2]];
|
||||
d[i] = red[master[d[i]]];
|
||||
d[i + 1] = green[master[d[i + 1]]];
|
||||
d[i + 2] = blue[master[d[i + 2]]];
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'curves') {
|
||||
const points = adj.params?.points || {};
|
||||
const master = curveLut(points.rgb);
|
||||
const red = curveLut(points.red);
|
||||
const green = curveLut(points.green);
|
||||
const blue = curveLut(points.blue);
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
d[i] = red[master[d[i]]];
|
||||
d[i + 1] = green[master[d[i + 1]]];
|
||||
d[i + 2] = blue[master[d[i + 2]]];
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
@@ -95,6 +290,43 @@ export function applyAdjustment(srcCanvas, adj) {
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'selective-color') {
|
||||
const ranges = adj.params?.ranges || {};
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
let r = d[i], g = d[i + 1], b = d[i + 2];
|
||||
const [hue, saturation, lightness] = rgbToHsl(r, g, b);
|
||||
for (const [name, values] of Object.entries(ranges)) {
|
||||
const weight = selectiveRangeWeight(name, hue, saturation, lightness) * .65;
|
||||
if (weight <= 0) continue;
|
||||
const black = finiteOr(values.black, 0) / 100;
|
||||
r += (-finiteOr(values.cyan, 0) / 100 * 255 - black * r) * weight;
|
||||
g += (-finiteOr(values.magenta, 0) / 100 * 255 - black * g) * weight;
|
||||
b += (-finiteOr(values.yellow, 0) / 100 * 255 - black * b) * weight;
|
||||
}
|
||||
d[i] = clampByte(r); d[i + 1] = clampByte(g); d[i + 2] = clampByte(b);
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'gradient-map') {
|
||||
const p = adj.params || {};
|
||||
let shadows = parseHexColor(p.shadows, '000000');
|
||||
let highlights = parseHexColor(p.highlights, 'ffffff');
|
||||
if (p.reverse) [shadows, highlights] = [highlights, shadows];
|
||||
const midpoint = Math.max(.01, Math.min(.99, finiteOr(p.midpoint, 50) / 100));
|
||||
const exponent = Math.log(.5) / Math.log(midpoint);
|
||||
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 amount = Math.pow(luminance, exponent);
|
||||
d[i] = clampByte(shadows[0] + (highlights[0] - shadows[0]) * amount);
|
||||
d[i + 1] = clampByte(shadows[1] + (highlights[1] - shadows[1]) * amount);
|
||||
d[i + 2] = clampByte(shadows[2] + (highlights[2] - shadows[2]) * amount);
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -215,14 +447,16 @@ export function renderLayerWithAdjLayers(layer) {
|
||||
const editingId = layer._editingAdjId || null;
|
||||
const stack = (layer.adjLayers || []).filter(a => a.visible && a.id !== editingId);
|
||||
const staged = layer._stagedAdj;
|
||||
if (stack.length === 0 && !staged) {
|
||||
if (stack.length === 0 && (!staged || layer._adjCompare)) {
|
||||
layer._adjFinalKey = '';
|
||||
return layer.canvas;
|
||||
}
|
||||
const sig = stack.map(a => `${a.id}:${a.visible?1:0}:${a.opacity}:${a.type}:${JSON.stringify(a.params)}`).join('|') +
|
||||
(staged ? `|S:${staged.type}:${JSON.stringify(staged.params)}` : '') +
|
||||
(editingId ? `|E:${editingId}` : '');
|
||||
if (layer._adjFinal && layer._adjFinalKey === sig) return layer._adjFinal;
|
||||
const compare = !!layer._adjCompare;
|
||||
const fullSig = `${sig}|C:${compare ? 1 : 0}`;
|
||||
if (layer._adjFinal && layer._adjFinalKey === fullSig) return layer._adjFinal;
|
||||
let cur = layer.canvas;
|
||||
const w = layer.canvas.width, h = layer.canvas.height;
|
||||
for (const adj of stack) {
|
||||
@@ -240,10 +474,10 @@ export function renderLayerWithAdjLayers(layer) {
|
||||
cur = blend;
|
||||
}
|
||||
}
|
||||
if (staged) {
|
||||
if (staged && !compare) {
|
||||
cur = applyAdjustment(cur, staged);
|
||||
}
|
||||
layer._adjFinal = cur;
|
||||
layer._adjFinalKey = sig;
|
||||
layer._adjFinalKey = fullSig;
|
||||
return cur;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user