mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-15 12:42:21 +02:00
249 lines
12 KiB
JavaScript
249 lines
12 KiB
JavaScript
/**
|
|
* Pure helpers + constants for layers and adjustment sub-layers.
|
|
*
|
|
* Everything in this module is stateless — feed in a layer object and
|
|
* get back a value. The legacy gallery editor's module-level helpers
|
|
* re-export from here so existing call sites keep working unchanged.
|
|
*/
|
|
|
|
/** True if the layer has at least one FX/adjustment sub-layer. */
|
|
export function layerHasAdjustments(layer) {
|
|
return !!(layer && layer.adjLayers && layer.adjLayers.length > 0);
|
|
}
|
|
|
|
|
|
/**
|
|
* True if the layer carries a non-identity Levels OR Color-Balance
|
|
* adjustment that needs the per-pixel pass (vs the cheap CSS-filter
|
|
* path for plain B/C/H/S).
|
|
*/
|
|
export function layerNeedsPixelPass(layer) {
|
|
if (!layer || !layer.adjustments) return false;
|
|
const a = layer.adjustments;
|
|
if (a.levels && (a.levels.inBlack !== 0 || a.levels.inWhite !== 255 ||
|
|
a.levels.gamma !== 1 ||
|
|
a.levels.outBlack !== 0 || a.levels.outWhite !== 255)) return true;
|
|
if (a.colorBalance) {
|
|
for (const tone of ['shadows', 'midtones', 'highlights']) {
|
|
const v = a.colorBalance[tone];
|
|
if (v && (v.r || v.g || v.b)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
|
|
/**
|
|
* Compact hash of a layer's Levels + Color-Balance values. Used to
|
|
* key the per-pixel adjustment cache so we can skip recomputing when
|
|
* nothing changed.
|
|
*/
|
|
export function adjustmentsKey(adj) {
|
|
const l = adj.levels || {};
|
|
const cb = adj.colorBalance || {};
|
|
const s = cb.shadows || {}, m = cb.midtones || {}, h = cb.highlights || {};
|
|
return [
|
|
l.inBlack|0, l.inWhite|0, l.gamma || 1, l.outBlack|0, l.outWhite|0,
|
|
s.r|0, s.g|0, s.b|0, m.r|0, m.g|0, m.b|0, h.r|0, h.g|0, h.b|0,
|
|
].join('|');
|
|
}
|
|
|
|
|
|
/** Identity params for each adjustment type. */
|
|
export function defaultAdjParams(type) {
|
|
switch (type) {
|
|
case 'brightness-contrast': return { brightness: 1, contrast: 1 };
|
|
case 'exposure': return { exposure: 0, offset: 0, gamma: 1 };
|
|
case 'white-balance': return { temperature: 0, tint: 0 };
|
|
case 'hue-saturation': return { hue: 0, saturation: 1, lightness: 0 };
|
|
case 'vibrance': return { vibrance: 0 };
|
|
case 'black-white': return { red: 30, green: 59, blue: 11, constant: 0 };
|
|
case 'shadows-highlights': return { shadows: 0, highlights: 0 };
|
|
case 'levels': return {
|
|
channel: 'rgb',
|
|
inBlack: 0, inWhite: 255, gamma: 1.0, outBlack: 0, outWhite: 255,
|
|
channels: {
|
|
red: { inBlack: 0, inWhite: 255, gamma: 1.0, outBlack: 0, outWhite: 255 },
|
|
green: { inBlack: 0, inWhite: 255, gamma: 1.0, outBlack: 0, outWhite: 255 },
|
|
blue: { inBlack: 0, inWhite: 255, gamma: 1.0, outBlack: 0, outWhite: 255 },
|
|
},
|
|
};
|
|
case 'curves': return {
|
|
channel: 'rgb',
|
|
points: {
|
|
rgb: [[0, 0], [255, 255]],
|
|
red: [[0, 0], [255, 255]],
|
|
green: [[0, 0], [255, 255]],
|
|
blue: [[0, 0], [255, 255]],
|
|
},
|
|
};
|
|
case 'color-balance': return {
|
|
shadows: { r: 0, g: 0, b: 0 },
|
|
midtones: { r: 0, g: 0, b: 0 },
|
|
highlights: { r: 0, g: 0, b: 0 },
|
|
};
|
|
case 'selective-color': return {
|
|
range: 'reds',
|
|
ranges: Object.fromEntries(
|
|
['reds', 'yellows', 'greens', 'cyans', 'blues', 'magentas', 'neutrals', 'blacks']
|
|
.map(name => [name, { cyan: 0, magenta: 0, yellow: 0, black: 0 }]),
|
|
),
|
|
};
|
|
case 'gradient-map': return {
|
|
shadows: '#000000',
|
|
highlights: '#ffffff',
|
|
midpoint: 50,
|
|
reverse: false,
|
|
};
|
|
}
|
|
return {};
|
|
}
|
|
|
|
/** Preset names for the retained adjustment popup. */
|
|
export function adjustmentPresetOptions(type) {
|
|
const options = {
|
|
'brightness-contrast': ['Default', 'High Contrast'],
|
|
'exposure': ['Default', 'Lift Exposure', 'Lower Exposure'],
|
|
'white-balance': ['Default', 'Warm', 'Cool'],
|
|
'hue-saturation': ['Default', 'Desaturate', 'Boost Color'],
|
|
'vibrance': ['Default', 'Muted', 'Color Pop'],
|
|
'black-white': ['Default', 'High Contrast'],
|
|
'shadows-highlights': ['Default', 'Lift Shadows', 'Recover Highlights'],
|
|
'levels': ['Default', 'Auto Contrast'],
|
|
'curves': ['Default', 'S-Curve'],
|
|
'color-balance': ['Default', 'Warm Highlights', 'Cool Shadows'],
|
|
'selective-color': ['Default', 'Deep Reds'],
|
|
'gradient-map': ['Default', 'Warm Tone', 'Cool Tone'],
|
|
};
|
|
return options[type] || ['Default'];
|
|
}
|
|
|
|
/** Return a fresh parameter object for a named adjustment preset. */
|
|
export function adjustmentPresetParams(type, preset) {
|
|
const params = defaultAdjParams(type);
|
|
switch (`${type}:${preset}`) {
|
|
case 'brightness-contrast:High Contrast':
|
|
return { ...params, contrast: 1.28 };
|
|
case 'exposure:Lift Exposure':
|
|
return { ...params, exposure: 0.45 };
|
|
case 'exposure:Lower Exposure':
|
|
return { ...params, exposure: -0.45 };
|
|
case 'white-balance:Warm':
|
|
return { ...params, temperature: 35 };
|
|
case 'white-balance:Cool':
|
|
return { ...params, temperature: -35 };
|
|
case 'hue-saturation:Desaturate':
|
|
return { ...params, saturation: 0.35 };
|
|
case 'hue-saturation:Boost Color':
|
|
return { ...params, saturation: 1.35 };
|
|
case 'vibrance:Muted':
|
|
return { ...params, vibrance: -30 };
|
|
case 'vibrance:Color Pop':
|
|
return { ...params, vibrance: 45 };
|
|
case 'black-white:High Contrast':
|
|
return { ...params, red: 38, green: 54, blue: 8, constant: 0 };
|
|
case 'shadows-highlights:Lift Shadows':
|
|
return { ...params, shadows: 38 };
|
|
case 'shadows-highlights:Recover Highlights':
|
|
return { ...params, highlights: -38 };
|
|
case 'levels:Auto Contrast':
|
|
return { ...params, inBlack: 12, inWhite: 243 };
|
|
case 'curves:S-Curve':
|
|
return { ...params, points: { ...params.points, rgb: [[0, 12], [76, 64], [178, 194], [255, 243]] } };
|
|
case 'color-balance:Warm Highlights':
|
|
return { ...params, highlights: { r: 16, g: 4, b: -14 } };
|
|
case 'color-balance:Cool Shadows':
|
|
return { ...params, shadows: { r: -12, g: 3, b: 16 } };
|
|
case 'selective-color:Deep Reds':
|
|
return { ...params, ranges: { ...params.ranges, reds: { ...params.ranges.reds, black: -18 } } };
|
|
case 'gradient-map:Warm Tone':
|
|
return { ...params, shadows: '#21151a', highlights: '#ffd59a' };
|
|
case 'gradient-map:Cool Tone':
|
|
return { ...params, shadows: '#101c36', highlights: '#d8f2ff' };
|
|
default:
|
|
return params;
|
|
}
|
|
}
|
|
|
|
|
|
/** Human-readable name for an adjustment type. */
|
|
export function adjLayerLabel(type) {
|
|
return {
|
|
'brightness-contrast': 'Brightness/Contrast',
|
|
'exposure': 'Exposure',
|
|
'white-balance': 'White Balance',
|
|
'hue-saturation': 'Hue/Saturation',
|
|
'vibrance': 'Vibrance',
|
|
'black-white': 'Black & White',
|
|
'shadows-highlights': 'Shadows/Highlights',
|
|
'levels': 'Levels',
|
|
'curves': 'Curves',
|
|
'color-balance': 'Color Balance',
|
|
'selective-color': 'Selective Color',
|
|
'gradient-map': 'Gradient Map',
|
|
}[type] || type;
|
|
}
|
|
|
|
|
|
/**
|
|
* Per-type SVG icon strings. Used in popup title bars, the minimised
|
|
* FX-dock chips, and the layer-panel sub-row name so the same glyph
|
|
* shows up everywhere a given adjustment type appears.
|
|
*/
|
|
export const ADJ_ICONS = {
|
|
'brightness-contrast': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18Z" fill="currentColor" stroke="none"/></svg>',
|
|
'exposure': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 4.5 13H11l-1 9 8.5-11H12z"/></svg>',
|
|
'white-balance': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v10"/><circle cx="12" cy="17" r="4"/><path d="M9 6h3"/></svg>',
|
|
'hue-saturation': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="12" r="4"/><circle cx="15" cy="9.5" r="4"/><circle cx="15" cy="14.5" r="4"/></svg>',
|
|
'vibrance': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 18 10 6l4 12 6-12"/><path d="M7 14h10"/></svg>',
|
|
'black-white': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18Z" fill="currentColor" stroke="none"/></svg>',
|
|
'shadows-highlights': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="4"/><path d="M14 14h7M17.5 10.5v7"/><path d="M5 17h6"/></svg>',
|
|
'levels': '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="14" width="3" height="6" rx="0.5"/><rect x="8" y="9" width="3" height="11" rx="0.5"/><rect x="13" y="11" width="3" height="9" rx="0.5"/><rect x="18" y="6" width="3" height="14" rx="0.5"/></svg>',
|
|
'curves': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19C7 19 8 5 13 5s4 8 7 8"/><path d="M4 4v16h16"/></svg>',
|
|
'color-balance': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 3v18M3 12a9 9 0 0 1 9-9v18a9 9 0 0 1-9-9z" fill="currentColor" stroke="none"/></svg>',
|
|
'selective-color': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3"/><path d="M12 3v6M21 12h-6M12 21v-6M3 12h6"/></svg>',
|
|
'gradient-map': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="6" width="18" height="12" rx="2"/><path d="M12 6v12"/></svg>',
|
|
};
|
|
|
|
|
|
/** SVG used in the topbar/history button glyphs. */
|
|
export const HISTORY_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/><polyline points="12 7 12 12 16 14"/></svg>';
|
|
|
|
|
|
/** Quick downsampled-alpha check: are there any opaque pixels on this canvas? */
|
|
export function isMaskCanvasEmpty(canvas) {
|
|
if (!canvas) return true;
|
|
try {
|
|
const w = canvas.width, h = canvas.height;
|
|
if (!w || !h) return true;
|
|
const sw = Math.min(200, w), sh = Math.min(200, h);
|
|
const tmp = document.createElement('canvas');
|
|
tmp.width = sw; tmp.height = sh;
|
|
tmp.getContext('2d').drawImage(canvas, 0, 0, sw, sh);
|
|
const d = tmp.getContext('2d').getImageData(0, 0, sw, sh).data;
|
|
for (let i = 3; i < d.length; i += 4) if (d[i] > 0) return false;
|
|
return true;
|
|
} catch { return false; }
|
|
}
|
|
|
|
|
|
/** Same as `isMaskCanvasEmpty` but accepts a layer wrapper. */
|
|
export function isLayerEmpty(layer) {
|
|
if (!layer || !layer.canvas) return true;
|
|
return isMaskCanvasEmpty(layer.canvas);
|
|
}
|
|
|
|
|
|
/**
|
|
* Compact "now / 30s / 12m / 4h" relative-time string. Used in the
|
|
* editor's history panel labels.
|
|
*/
|
|
export function relTime(ts) {
|
|
if (!ts) return '';
|
|
const dt = (Date.now() - ts) / 1000;
|
|
if (dt < 5) return 'now';
|
|
if (dt < 60) return Math.round(dt) + 's';
|
|
if (dt < 3600) return Math.round(dt / 60) + 'm';
|
|
return Math.round(dt / 3600) + 'h';
|
|
}
|