mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-03 13:32:21 +02:00
Squash Odysseus development history
This commit is contained in:
Vendored
+323
@@ -0,0 +1,323 @@
|
||||
/** 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];
|
||||
Reference in New Issue
Block a user