mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-01 18:52:21 +01:00
* fix(editor): stabilize image workflows and editing controls
* docs(editor): add desktop and mobile visual validation evidence
* fix(editor): synchronize quick edit model events and register imagePreferences in test coordinator
* fix(settings): enforce authoritative global image-gen policy and non-admin preference persistence
- Enforce authoritative administrator global image_gen_enabled disable in get_user_setting, do_generate_image, and image_editing_provider_available, preventing non-admin user preference overrides while preserving user-disabled opt-outs and can_generate_images privilege checks across direct, gallery, and editor paths.
- Filter non-admin preference persistence in saveImagePreferences to user preference keys only (image_model, image_edit_model, image_quality), preventing global policy toggles from leaking into user preferences.
- Return accurate 200 OK Response from saveImagePreferences after fallback persistence succeeds so settings UI displays 'Saved' instead of false 403 error, while retaining visibility of real errors.
- Load per-user preferences via readImagePreferences in initImageSettings so non-admin selections persist and display correctly after page reload.
- Clear per-user model preferences on successful admin save (POST /api/auth/settings) via PUT /api/prefs/{key} with value: null to prevent stale per-user preferences from shadowing updated global settings.
- Support clearing keys in PUT /api/prefs/{key} when value is null.
- Add comprehensive Python regression tests for global-disabled/user-enabled, global-enabled/user-disabled, and authorized generation, plus Node and browser regression tests for non-admin persistence, toggle isolation, reload, and admin clearing.
* fix(settings): invalidate shared appConfig cache near auth settings write
* fix(test): isolate test_tool_policy unit suite from external model endpoint state
* fix(test): isolate web_fetch failure regression from contextual follow-up heuristics
* fix(test): isolate test_tool_policy from cross-test approval gate state
Ensure _patch_loop_basics disables TOOL_APPROVAL_GATE_ENABLED so routing tests in test_tool_policy run in the intended opt-out posture even when prior tests populate DATA_DIR with skills or untrusted context. Remove unnecessary forced_tools and contextual web mock workarounds.
* fix(settings): isolate non-admin image toggle and surface preference cleanup errors
- Restrict image generation availability toggle to admin users in imageModels.js, disabling the switch for non-admins and reverting unauthorized changes with an explicit message.
- Separate non-admin image model preference saves from global toggle updates.
- Track preference cleanup results on admin save in imagePreferences.js, propagating cleanup failures instead of ignoring them.
- Require valid preference keys on 403 fallback so toggle-only attempts reject cleanly.
- Add /api/prefs route mock to test_image_model_settings_sync_both_directions_and_survive_reopening.
- Add regression coverage for admin cleanup failures and non-admin toggle rejection.
* fix(settings): reconcile admin toggle on partial cleanup failure and report errors
- In imagePreferences.js, distinguish pre-persistence failures from post-save cleanup failures using err.globalSaved and err.phase.
- In imagePreferences.js, notify listeners of global policy updates on partial success before propagating cleanup failure.
- In imageModels.js, retain the persisted target state on admin toggle when global persistence succeeds but cleanup fails.
- In imageModels.js, reconcile admin toggle with authoritative server state via readImagePreferences() after ambiguous or partial failures.
- In imageModels.js, report partial success accurately without suppressing preference cleanup error messages.
- Add regression coverage in test_settings_save_response_ok_js.py for cleanup HTTP 500, cleanup network rejection, successful cleanup, and failed global POST.
---------
Co-authored-by: pewdiepie-archdaemon <pewdiepie-archdaemon@users.noreply.github.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
154 lines
6.6 KiB
JavaScript
154 lines
6.6 KiB
JavaScript
/* Worker implementation for retained-effect rasterization. */
|
|
import { LAYER_STYLES, renderLayerStyle, renderDropShadow } from './layer-styles.js';
|
|
|
|
function copyCanvas(source) {
|
|
const out = new OffscreenCanvas(source.width, source.height);
|
|
out.getContext('2d').drawImage(source, 0, 0);
|
|
return out;
|
|
}
|
|
|
|
function sharpenCanvas(source, amount) {
|
|
if (!amount) return copyCanvas(source);
|
|
const out = new OffscreenCanvas(source.width, 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) {
|
|
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;
|
|
}
|
|
|
|
function maskedContribution(effectCanvas, mask) {
|
|
if (!mask) return effectCanvas;
|
|
const out = new OffscreenCanvas(effectCanvas.width, effectCanvas.height);
|
|
const ctx = out.getContext('2d');
|
|
ctx.drawImage(effectCanvas, 0, 0);
|
|
ctx.globalCompositeOperation = 'destination-in';
|
|
ctx.drawImage(mask, 0, 0, out.width, out.height);
|
|
ctx.globalCompositeOperation = 'source-over';
|
|
return out;
|
|
}
|
|
|
|
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 renderEffects(source, effects, masks) {
|
|
let current = copyCanvas(source);
|
|
for (let index = 0; index < effects.length; index += 1) {
|
|
const effect = effects[index];
|
|
if (effect.visible === false || effect.opacity <= 0) continue;
|
|
const next = new OffscreenCanvas(current.width, current.height);
|
|
const ctx = next.getContext('2d');
|
|
if (LAYER_STYLES[effect.type]) {
|
|
ctx.drawImage(renderLayerStyle(current, effect.type, effect.params), 0, 0);
|
|
} else 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') {
|
|
ctx.drawImage(sharpenCanvas(current, effect.params.amount), 0, 0);
|
|
} else if (effect.type === 'motion-blur') {
|
|
const dist = effect.params.distance;
|
|
const rad = (effect.params.angle || 0) * Math.PI / 180;
|
|
const dx = Math.cos(rad);
|
|
const dy = Math.sin(rad);
|
|
const samples = Math.max(1, Math.min(64, Math.round(dist) || 1));
|
|
ctx.globalAlpha = 1 / samples;
|
|
for (let s = 0; s < samples; s += 1) {
|
|
const t = (samples > 1 ? (s / (samples - 1) - 0.5) : 0) * dist;
|
|
ctx.drawImage(current, dx * t, dy * t);
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
} else if (effect.type === 'zoom-blur') {
|
|
const amt = effect.params.amount / 100;
|
|
const samples = Math.max(1, Math.min(48, Math.round(amt * 48) || 1));
|
|
const cx = next.width / 2;
|
|
const cy = next.height / 2;
|
|
ctx.globalAlpha = 1 / samples;
|
|
for (let s = 0; s < samples; s += 1) {
|
|
const scale = 1 + (s / samples) * amt * 0.5;
|
|
const dw = next.width * scale;
|
|
const dh = next.height * scale;
|
|
ctx.drawImage(current, cx - dw / 2, cy - dh / 2, dw, dh);
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
} else if (effect.type === 'pixelate') {
|
|
const size = Math.max(1, effect.params.size);
|
|
const sw = Math.max(1, Math.round(next.width / size));
|
|
const sh = Math.max(1, Math.round(next.height / size));
|
|
const tiny = new OffscreenCanvas(sw, sh);
|
|
const tctx = tiny.getContext('2d');
|
|
tctx.imageSmoothingEnabled = false;
|
|
tctx.drawImage(current, 0, 0, sw, sh);
|
|
ctx.imageSmoothingEnabled = false;
|
|
ctx.drawImage(tiny, 0, 0, next.width, next.height);
|
|
ctx.imageSmoothingEnabled = true;
|
|
} 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);
|
|
} else if (effect.type === 'drop-shadow') {
|
|
ctx.drawImage(renderDropShadow(current, effect.params), 0, 0);
|
|
} else if (effect.type === 'stroke') {
|
|
ctx.globalAlpha = effect.params.opacity;
|
|
ctx.shadowColor = effect.params.color;
|
|
ctx.shadowBlur = effect.params.width;
|
|
ctx.drawImage(current, 0, 0);
|
|
} else if (effect.type === 'linear-gradient' || effect.type === 'radial-gradient') {
|
|
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, next.width, next.height);
|
|
ctx.shadowColor = 'transparent';
|
|
ctx.shadowBlur = 0;
|
|
ctx.drawImage(current, 0, 0);
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
ctx.globalCompositeOperation = 'source-over';
|
|
const contribution = maskedContribution(next, masks[index]);
|
|
const blended = copyCanvas(current);
|
|
const blendCtx = blended.getContext('2d');
|
|
if (effect.type === 'drop-shadow' && !effect.params.inside) blendCtx.globalCompositeOperation = 'destination-over';
|
|
blendCtx.globalAlpha = effect.opacity;
|
|
blendCtx.drawImage(contribution, 0, 0);
|
|
blendCtx.globalAlpha = 1;
|
|
current = blended;
|
|
}
|
|
return current;
|
|
}
|
|
|
|
self.onmessage = event => {
|
|
try {
|
|
const { source, effects, masks } = event.data;
|
|
const output = renderEffects(source, effects || [], masks || []);
|
|
const bitmap = output.transferToImageBitmap();
|
|
self.postMessage({ bitmap }, [bitmap]);
|
|
} catch (error) {
|
|
self.postMessage({ error: String(error?.message || error) });
|
|
}
|
|
};
|