mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-14 12:12:20 +02:00
324 lines
13 KiB
JavaScript
324 lines
13 KiB
JavaScript
/** Retained, non-destructive layer effects. */
|
|
|
|
const EFFECT_TYPES = new Set(['gaussian-blur', 'sharpen', 'color-overlay', 'drop-shadow', 'stroke', 'linear-gradient', 'radial-gradient']);
|
|
|
|
export const EFFECT_PRESETS = {
|
|
'soft-blur': { type: 'gaussian-blur', params: { radius: 4 } },
|
|
'crisp-detail': { type: 'sharpen', params: { amount: 0.35 } },
|
|
'soft-shadow': { type: 'drop-shadow', params: { color: '#000000', opacity: 0.3, blur: 8, x: 2, y: 3 } },
|
|
'white-outline': { type: 'stroke', params: { color: '#ffffff', opacity: 0.85, width: 2 } },
|
|
};
|
|
|
|
function clone(value) {
|
|
return JSON.parse(JSON.stringify(value));
|
|
}
|
|
|
|
function normalizeEffectMask(mask) {
|
|
if (!mask || typeof mask !== 'object' || Array.isArray(mask)) return null;
|
|
return {
|
|
id: String(mask.id || `effect-mask-${Math.random().toString(36).slice(2, 9)}`),
|
|
name: String(mask.name || 'Effect Mask'),
|
|
visible: mask.visible !== false,
|
|
canvas: mask.canvas || null,
|
|
ctx: mask.ctx || null,
|
|
canvasW: Number(mask.canvasW) || mask.canvas?.width || 0,
|
|
canvasH: Number(mask.canvasH) || mask.canvas?.height || 0,
|
|
dataUrl: typeof mask.dataUrl === 'string' ? mask.dataUrl : null,
|
|
};
|
|
}
|
|
|
|
export function defaultEffectParams(type) {
|
|
if (type === 'gaussian-blur') return { radius: 6 };
|
|
if (type === 'sharpen') return { amount: 0.5 };
|
|
if (type === 'color-overlay') return { color: '#ffffff', opacity: 0.2, blendMode: 'source-atop' };
|
|
if (type === 'drop-shadow') return { color: '#000000', opacity: 0.45, blur: 12, x: 4, y: 6 };
|
|
if (type === 'stroke') return { color: '#ffffff', opacity: 1, width: 3 };
|
|
if (type === 'linear-gradient' || type === 'radial-gradient') return {
|
|
x1: 0, y1: 0, x2: 1, y2: 0,
|
|
stops: [{ position: 0, color: '#e06c75', alpha: 1 }, { position: 100, color: '#ffffff', alpha: 1 }],
|
|
opacity: 1,
|
|
};
|
|
return {};
|
|
}
|
|
|
|
export function effectPreset(name) {
|
|
const preset = EFFECT_PRESETS[name];
|
|
return preset ? { type: preset.type, params: clone(preset.params) } : null;
|
|
}
|
|
|
|
export function normalizeEffect(effect) {
|
|
const type = EFFECT_TYPES.has(effect?.type) ? effect.type : 'gaussian-blur';
|
|
const defaults = defaultEffectParams(type);
|
|
const params = effect?.params && typeof effect.params === 'object'
|
|
? { ...defaults, ...clone(effect.params) }
|
|
: defaults;
|
|
if (type === 'gaussian-blur') params.radius = Math.max(0, Math.min(200, Number(params.radius) || 0));
|
|
if (type === 'sharpen') params.amount = Math.max(0, Math.min(1, Number(params.amount) || 0));
|
|
if (type === 'color-overlay') {
|
|
params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color;
|
|
params.opacity = Math.max(0, Math.min(1, Number(params.opacity) || 0));
|
|
params.blendMode = typeof params.blendMode === 'string' ? params.blendMode : defaults.blendMode;
|
|
}
|
|
if (type === 'drop-shadow') {
|
|
params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color;
|
|
for (const key of ['opacity', 'blur', 'x', 'y']) params[key] = Number(params[key]) || 0;
|
|
params.opacity = Math.max(0, Math.min(1, params.opacity));
|
|
params.blur = Math.max(0, Math.min(200, params.blur));
|
|
params.x = Math.max(-200, Math.min(200, params.x));
|
|
params.y = Math.max(-200, Math.min(200, params.y));
|
|
}
|
|
if (type === 'stroke') {
|
|
params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color;
|
|
params.opacity = Math.max(0, Math.min(1, Number(params.opacity) || 0));
|
|
params.width = Math.max(0, Math.min(100, Number(params.width) || 0));
|
|
}
|
|
if (type === 'linear-gradient' || type === 'radial-gradient') {
|
|
for (const key of ['x1', 'y1', 'x2', 'y2']) {
|
|
const value = Number(params[key]);
|
|
params[key] = Number.isFinite(value) ? Math.max(-2_000_000, Math.min(2_000_000, value)) : defaults[key];
|
|
}
|
|
params.opacity = Math.max(0, Math.min(1, Number(params.opacity) || 0));
|
|
const stops = Array.isArray(params.stops) ? params.stops : defaults.stops;
|
|
params.stops = stops.map(stop => ({
|
|
position: Math.max(0, Math.min(100, Number(stop?.position) || 0)),
|
|
color: /^#[0-9a-f]{6}$/i.test(stop?.color) ? stop.color : '#000000',
|
|
alpha: Math.max(0, Math.min(1, Number(stop?.alpha ?? 1) || 0)),
|
|
})).sort((a, b) => a.position - b.position).slice(0, 12);
|
|
if (params.stops.length < 2) params.stops = clone(defaults.stops);
|
|
}
|
|
return {
|
|
id: String(effect?.id || `effect-${Math.random().toString(36).slice(2, 9)}`),
|
|
type,
|
|
name: String(effect?.name || effectLabel(type)),
|
|
visible: effect?.visible !== false,
|
|
opacity: Math.max(0, Math.min(1, Number(effect?.opacity ?? 1) || 0)),
|
|
mask: normalizeEffectMask(effect?.mask),
|
|
params,
|
|
};
|
|
}
|
|
|
|
export function effectLabel(type) {
|
|
return {
|
|
'gaussian-blur': 'Gaussian Blur',
|
|
sharpen: 'Sharpen',
|
|
'color-overlay': 'Color Overlay',
|
|
'drop-shadow': 'Drop Shadow',
|
|
'stroke': 'Stroke',
|
|
'linear-gradient': 'Gradient',
|
|
'radial-gradient': 'Radial Gradient',
|
|
}[type] || type;
|
|
}
|
|
|
|
function copyCanvas(source) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = source.width;
|
|
canvas.height = source.height;
|
|
canvas.getContext('2d').drawImage(source, 0, 0);
|
|
return canvas;
|
|
}
|
|
|
|
function gradientColor(value, alpha = 1) {
|
|
const raw = String(value || '').replace(/^#/, '');
|
|
if (!/^[0-9a-f]{6}$/i.test(raw)) return `rgba(0,0,0,${alpha})`;
|
|
const channels = [0, 2, 4].map(offset => parseInt(raw.slice(offset, offset + 2), 16));
|
|
return `rgba(${channels.join(',')},${alpha})`;
|
|
}
|
|
|
|
function renderGradient(ctx, effect, width, height) {
|
|
const p = effect.params;
|
|
const gradient = effect.type === 'radial-gradient'
|
|
? ctx.createRadialGradient(p.x1, p.y1, 0, p.x1, p.y1, Math.max(0.5, Math.hypot(p.x2 - p.x1, p.y2 - p.y1)))
|
|
: ctx.createLinearGradient(p.x1, p.y1, p.x2, p.y2);
|
|
for (const stop of p.stops) gradient.addColorStop(stop.position / 100, gradientColor(stop.color, stop.alpha));
|
|
ctx.globalAlpha = p.opacity;
|
|
ctx.fillStyle = gradient;
|
|
ctx.fillRect(0, 0, width, height);
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
function applyEffectMask(source, effected, mask) {
|
|
if (!mask?.canvas || !source || !effected) return effected;
|
|
const masked = document.createElement('canvas');
|
|
masked.width = effected.width;
|
|
masked.height = effected.height;
|
|
const mctx = masked.getContext('2d');
|
|
mctx.drawImage(effected, 0, 0);
|
|
mctx.globalCompositeOperation = 'destination-in';
|
|
mctx.drawImage(mask.canvas, 0, 0, masked.width, masked.height);
|
|
mctx.globalCompositeOperation = 'source-over';
|
|
return masked;
|
|
}
|
|
|
|
function sharpenCanvas(source, amount, shouldContinue = () => true) {
|
|
if (!amount) return copyCanvas(source);
|
|
const out = document.createElement('canvas');
|
|
out.width = source.width;
|
|
out.height = source.height;
|
|
const src = source.getContext('2d').getImageData(0, 0, source.width, source.height);
|
|
const dst = out.getContext('2d').createImageData(source.width, source.height);
|
|
const a = Math.max(0, Math.min(1, Number(amount) || 0));
|
|
const stride = source.width * 4;
|
|
for (let y = 0; y < source.height; y += 1) {
|
|
if ((y & 7) === 0 && !shouldContinue()) return null;
|
|
for (let x = 0; x < source.width; x += 1) {
|
|
const at = (y * source.width + x) * 4;
|
|
for (let channel = 0; channel < 3; channel += 1) {
|
|
const center = src.data[at + channel] * (1 + 4 * a);
|
|
const left = src.data[y * stride + Math.max(0, x - 1) * 4 + channel];
|
|
const right = src.data[y * stride + Math.min(source.width - 1, x + 1) * 4 + channel];
|
|
const above = src.data[Math.max(0, y - 1) * stride + x * 4 + channel];
|
|
const below = src.data[Math.min(source.height - 1, y + 1) * stride + x * 4 + channel];
|
|
dst.data[at + channel] = Math.max(0, Math.min(255, center - a * (left + right + above + below)));
|
|
}
|
|
dst.data[at + 3] = src.data[at + 3];
|
|
}
|
|
}
|
|
out.getContext('2d').putImageData(dst, 0, 0);
|
|
return out;
|
|
}
|
|
|
|
/** Render effects in list order without mutating the source layer. */
|
|
export function renderEffects(source, effects = [], shouldContinue = () => true) {
|
|
if (!source || !Array.isArray(effects) || effects.length === 0) return source;
|
|
let current = copyCanvas(source);
|
|
for (const raw of effects) {
|
|
if (!shouldContinue()) return current;
|
|
const effect = normalizeEffect(raw);
|
|
if (!effect.visible || effect.opacity <= 0) continue;
|
|
const next = document.createElement('canvas');
|
|
next.width = current.width;
|
|
next.height = current.height;
|
|
const ctx = next.getContext('2d');
|
|
if (effect.type === 'gaussian-blur') {
|
|
ctx.filter = `blur(${effect.params.radius}px)`;
|
|
ctx.drawImage(current, 0, 0);
|
|
ctx.filter = 'none';
|
|
} else if (effect.type === 'sharpen') {
|
|
const sharpened = sharpenCanvas(current, effect.params.amount, shouldContinue);
|
|
if (!sharpened) return current;
|
|
ctx.drawImage(sharpened, 0, 0);
|
|
} else if (effect.type === 'color-overlay') {
|
|
ctx.drawImage(current, 0, 0);
|
|
ctx.globalAlpha = effect.params.opacity;
|
|
ctx.globalCompositeOperation = effect.params.blendMode;
|
|
ctx.fillStyle = effect.params.color;
|
|
ctx.fillRect(0, 0, next.width, next.height);
|
|
ctx.globalAlpha = 1;
|
|
} else if (effect.type === 'drop-shadow') {
|
|
ctx.globalAlpha = effect.params.opacity;
|
|
ctx.shadowColor = effect.params.color;
|
|
ctx.shadowBlur = effect.params.blur;
|
|
ctx.shadowOffsetX = effect.params.x;
|
|
ctx.shadowOffsetY = effect.params.y;
|
|
ctx.drawImage(current, 0, 0);
|
|
ctx.globalAlpha = 1;
|
|
} else if (effect.type === 'stroke') {
|
|
ctx.globalAlpha = effect.params.opacity;
|
|
ctx.shadowColor = effect.params.color;
|
|
ctx.shadowBlur = effect.params.width;
|
|
ctx.shadowOffsetX = 0;
|
|
ctx.shadowOffsetY = 0;
|
|
ctx.drawImage(current, 0, 0);
|
|
ctx.shadowColor = 'transparent';
|
|
ctx.shadowBlur = 0;
|
|
ctx.drawImage(current, 0, 0);
|
|
ctx.globalAlpha = 1;
|
|
} else if (effect.type === 'linear-gradient' || effect.type === 'radial-gradient') {
|
|
renderGradient(ctx, effect, next.width, next.height);
|
|
}
|
|
ctx.globalCompositeOperation = 'source-over';
|
|
const contribution = effect.mask?.canvas && effect.mask.visible !== false
|
|
? applyEffectMask(current, next, effect.mask)
|
|
: next;
|
|
const blended = copyCanvas(current);
|
|
const blendCtx = blended.getContext('2d');
|
|
blendCtx.globalAlpha = effect.opacity;
|
|
blendCtx.drawImage(contribution, 0, 0);
|
|
blendCtx.globalAlpha = 1;
|
|
current = blended;
|
|
}
|
|
return current;
|
|
}
|
|
|
|
/**
|
|
* Rasterize retained effects off the main thread when browser canvas workers
|
|
* are available. The synchronous renderer remains the compatibility fallback.
|
|
*/
|
|
export async function renderEffectsAsync(source, effects = [], shouldContinue = () => true) {
|
|
if (!source || !Array.isArray(effects) || effects.length === 0) return source;
|
|
if (typeof Worker === 'undefined' || typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap !== 'function') {
|
|
return renderEffects(source, effects, shouldContinue);
|
|
}
|
|
if (!shouldContinue()) return source;
|
|
let sourceBitmap;
|
|
const maskBitmaps = [];
|
|
try {
|
|
sourceBitmap = await createImageBitmap(source);
|
|
for (const effect of effects) {
|
|
if (!shouldContinue()) {
|
|
sourceBitmap.close?.();
|
|
for (const bitmap of maskBitmaps) bitmap?.close?.();
|
|
return null;
|
|
}
|
|
maskBitmaps.push(effect?.mask?.canvas && effect.mask.visible !== false
|
|
? await createImageBitmap(effect.mask.canvas)
|
|
: null);
|
|
}
|
|
} catch {
|
|
sourceBitmap?.close?.();
|
|
for (const bitmap of maskBitmaps) bitmap?.close?.();
|
|
return shouldContinue() ? renderEffects(source, effects, shouldContinue) : null;
|
|
}
|
|
if (!shouldContinue()) {
|
|
sourceBitmap.close?.();
|
|
for (const bitmap of maskBitmaps) bitmap?.close?.();
|
|
return source;
|
|
}
|
|
let worker;
|
|
try {
|
|
worker = new Worker(new URL('./effects-worker.js', import.meta.url), { type: 'module' });
|
|
} catch {
|
|
sourceBitmap.close?.();
|
|
for (const bitmap of maskBitmaps) bitmap?.close?.();
|
|
return shouldContinue() ? renderEffects(source, effects, shouldContinue) : null;
|
|
}
|
|
const payloadEffects = effects.map(raw => {
|
|
const effect = normalizeEffect(raw);
|
|
return { ...effect, mask: null };
|
|
});
|
|
return new Promise(resolve => {
|
|
let settled = false;
|
|
const finish = result => {
|
|
if (settled) return;
|
|
settled = true;
|
|
worker.terminate();
|
|
sourceBitmap.close?.();
|
|
for (const bitmap of maskBitmaps) bitmap?.close?.();
|
|
resolve(result);
|
|
};
|
|
worker.onmessage = event => {
|
|
const { bitmap, error } = event.data || {};
|
|
if (error || !bitmap || !shouldContinue()) {
|
|
bitmap?.close?.();
|
|
finish(shouldContinue() ? renderEffects(source, effects, shouldContinue) : source);
|
|
return;
|
|
}
|
|
const output = document.createElement('canvas');
|
|
output.width = source.width;
|
|
output.height = source.height;
|
|
output.getContext('2d').drawImage(bitmap, 0, 0);
|
|
bitmap.close?.();
|
|
finish(output);
|
|
};
|
|
// A stale worker must not trigger a full-resolution fallback while a newer
|
|
// preview is already queued.
|
|
worker.onerror = () => finish(shouldContinue() ? renderEffects(source, effects, shouldContinue) : null);
|
|
worker.postMessage({ source: sourceBitmap, effects: payloadEffects, masks: maskBitmaps }, [
|
|
sourceBitmap,
|
|
...maskBitmaps.filter(Boolean),
|
|
]);
|
|
});
|
|
}
|
|
|
|
export const EFFECT_TYPES_LIST = [...EFFECT_TYPES];
|