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>
211 lines
8.9 KiB
JavaScript
211 lines
8.9 KiB
JavaScript
/**
|
|
* Background Remove (rembg) + Sharpen wiring + the live edge-cleanup
|
|
* tuner that runs on the most-recent rembg cutout.
|
|
*
|
|
* rembg-run button: active layer + POST to /api/image/remove-bg with an
|
|
* optional hint_mask if the user has a wand/lasso selection
|
|
* active. Updates only that layer and binds its live edge tuner;
|
|
* other layers and their visibility remain unchanged.
|
|
*
|
|
* Live edge-cleanup tuner: snapshots the pristine cutout the
|
|
* moment it lands; subsequent feather/grow slider tweaks
|
|
* rebuild the layer's alpha from that snapshot WITHOUT
|
|
* re-running the model.
|
|
* - Grow > 0 → blur snap alpha, threshold low (32) → grow.
|
|
* - Grow < 0 → blur snap alpha, threshold high (200) → shrink.
|
|
* - Feather > 0 → blur the whole layer (alpha + RGB) so the
|
|
* edge softens AND the residual color fringe from the
|
|
* original background gets blurred away.
|
|
*
|
|
* Sharpen: small slider + button; just calls _applyImageTool
|
|
* against /api/image/sharpen.
|
|
*
|
|
* buildSelectionHintMask: pure-ish utility — returns a base64 PNG
|
|
* (no data: prefix) of the active wand or lasso selection, or
|
|
* null. Returned so other wand-rembg call sites can use it.
|
|
*
|
|
* @param {{
|
|
* applyImageTool: (endpoint, payload, layerName, btn, opts?) => Promise<void>,
|
|
* openCookbookForDependency: (pkg: string) => void,
|
|
* composite: () => void,
|
|
* renderLayerPanel: () => void,
|
|
* uiModule: object,
|
|
* }} deps
|
|
*
|
|
* @returns {{ buildSelectionHintMask: () => string | null }}
|
|
*/
|
|
import { state } from './state.js';
|
|
import { selectionMaskToDocument } from './selection-mask.js';
|
|
|
|
export function wireRembgAndSharpen({
|
|
applyImageTool, openCookbookForDependency,
|
|
composite, renderLayerPanel, uiModule,
|
|
}) {
|
|
// ── Sharpen ──
|
|
const sharpenPrev = document.getElementById('ge-sharpen-preview');
|
|
if (sharpenPrev) sharpenPrev.style.opacity = '0.5';
|
|
document.getElementById('ge-sharpen-amount')?.addEventListener('input', (e) => {
|
|
document.getElementById('ge-sharpen-label').textContent = e.target.value + '%';
|
|
if (sharpenPrev) sharpenPrev.style.opacity = (parseInt(e.target.value, 10) / 100).toFixed(2);
|
|
});
|
|
document.getElementById('ge-sharpen-run')?.addEventListener('click', () => {
|
|
const amount = parseInt(document.getElementById('ge-sharpen-amount')?.value || '50');
|
|
applyImageTool('/api/image/sharpen', { amount }, 'Sharpened', document.getElementById('ge-sharpen-run'));
|
|
});
|
|
|
|
// ── Bg Remove ──
|
|
document.getElementById('ge-rembg-install-link')?.addEventListener('click', () => {
|
|
openCookbookForDependency('rembg');
|
|
});
|
|
document.getElementById('ge-rembg-run')?.addEventListener('click', async () => {
|
|
const payload = {};
|
|
const hint = buildSelectionHintMask();
|
|
if (hint) payload.hint_mask = hint;
|
|
// NB: edge_feather / edge_grow are applied CLIENT-side so the
|
|
// sliders can re-tune the cutout without re-running the model.
|
|
const btn = document.getElementById('ge-rembg-run');
|
|
const editedLayer = await applyImageTool('/api/image/remove-bg', payload, 'BG Removed', btn);
|
|
// Reset sliders so the new cutout starts clean.
|
|
const f = document.getElementById('ge-rembg-feather');
|
|
const g = document.getElementById('ge-rembg-grow');
|
|
if (f) { f.value = 0; document.getElementById('ge-rembg-feather-label').textContent = '0px'; syncRembgFeather(0); }
|
|
if (g) { g.value = 0; document.getElementById('ge-rembg-grow-label').textContent = '0px'; syncRembgGrow(0); }
|
|
if (editedLayer) bindRembgLiveTuner(editedLayer);
|
|
});
|
|
|
|
// ── Live edge-cleanup tuner ──
|
|
// Snapshots the pristine cutout the moment it lands; slider tweaks
|
|
// rebuild alpha from that snapshot.
|
|
function bindRembgLiveTuner(layer) {
|
|
if (!layer) return;
|
|
const w = layer.canvas.width, h = layer.canvas.height;
|
|
const snap = document.createElement('canvas');
|
|
snap.width = w; snap.height = h;
|
|
snap.getContext('2d').drawImage(layer.canvas, 0, 0);
|
|
state.rembgLiveLayer = layer;
|
|
state.rembgLiveSnap = snap;
|
|
rembgApplyEdgeNow(); // initial pass (no-op at 0/0)
|
|
}
|
|
let rembgRaf = null;
|
|
function scheduleRembgApply() {
|
|
if (rembgRaf) return;
|
|
rembgRaf = requestAnimationFrame(() => { rembgRaf = null; rembgApplyEdgeNow(); });
|
|
}
|
|
function rembgApplyEdgeNow() {
|
|
if (!state.rembgLiveLayer || !state.rembgLiveSnap) return;
|
|
const feather = parseInt(document.getElementById('ge-rembg-feather')?.value || '0', 10);
|
|
const grow = parseInt(document.getElementById('ge-rembg-grow')?.value || '0', 10);
|
|
const layer = state.rembgLiveLayer;
|
|
const snap = state.rembgLiveSnap;
|
|
const w = snap.width, h = snap.height;
|
|
const lctx = layer.ctx;
|
|
|
|
// 1) Start fresh from the pristine cutout snapshot.
|
|
lctx.clearRect(0, 0, w, h);
|
|
lctx.drawImage(snap, 0, 0);
|
|
|
|
// 2) Edge ±N — dilate / erode alpha via blur+threshold:
|
|
// grow > 0 → low threshold (32) → halo counts as opaque → grows.
|
|
// grow < 0 → high threshold (200) → only solid interior → shrinks.
|
|
// RGB is kept; only alpha is replaced.
|
|
if (grow !== 0) {
|
|
const blurC = document.createElement('canvas');
|
|
blurC.width = w; blurC.height = h;
|
|
const bctx = blurC.getContext('2d');
|
|
bctx.filter = `blur(${Math.abs(grow)}px)`;
|
|
bctx.drawImage(snap, 0, 0);
|
|
bctx.filter = 'none';
|
|
const blurred = bctx.getImageData(0, 0, w, h).data;
|
|
const layerData = lctx.getImageData(0, 0, w, h);
|
|
const out = layerData.data;
|
|
const thr = grow > 0 ? 32 : 200;
|
|
for (let i = 0; i < out.length; i += 4) {
|
|
out[i + 3] = blurred[i + 3] >= thr ? 255 : 0;
|
|
}
|
|
lctx.putImageData(layerData, 0, 0);
|
|
}
|
|
|
|
// 3) Feather softens whatever edge we have now. Blur the entire
|
|
// layer (alpha + RGB) — alpha gets smooth falloff, RGB gets a
|
|
// faint blur at the edge which actually helps hide residual
|
|
// colour fringing from the original background.
|
|
if (feather > 0) {
|
|
const fC = document.createElement('canvas');
|
|
fC.width = w; fC.height = h;
|
|
const fctx = fC.getContext('2d');
|
|
fctx.filter = `blur(${feather}px)`;
|
|
fctx.drawImage(layer.canvas, 0, 0);
|
|
fctx.filter = 'none';
|
|
lctx.clearRect(0, 0, w, h);
|
|
lctx.drawImage(fC, 0, 0);
|
|
}
|
|
composite();
|
|
}
|
|
|
|
// ── Slider preview swatches + wiring ──
|
|
const rembgFeatherPrev = document.getElementById('ge-rembg-feather-preview');
|
|
const rembgGrowPrev = document.getElementById('ge-rembg-grow-preview');
|
|
function syncRembgFeather(v) {
|
|
if (!rembgFeatherPrev) return;
|
|
const inner = Math.max(0, 50 - v * 2.5);
|
|
rembgFeatherPrev.style.background = `radial-gradient(circle, var(--fg) 0%, var(--fg) ${inner}%, transparent 75%)`;
|
|
}
|
|
function syncRembgGrow(v) {
|
|
if (!rembgGrowPrev) return;
|
|
// -10..+10 → scale 0.6 .. 1.4 so the swatch visibly grows/shrinks.
|
|
const s = 1 + v * 0.04;
|
|
rembgGrowPrev.style.transform = `scale(${s})`;
|
|
rembgGrowPrev.style.background = v < 0 ? 'color-mix(in srgb, var(--fg) 40%, transparent)' : 'var(--fg)';
|
|
}
|
|
syncRembgFeather(2);
|
|
syncRembgGrow(0);
|
|
document.getElementById('ge-rembg-feather')?.addEventListener('input', (e) => {
|
|
const v = parseInt(e.target.value, 10);
|
|
document.getElementById('ge-rembg-feather-label').textContent = v + 'px';
|
|
syncRembgFeather(v);
|
|
scheduleRembgApply();
|
|
});
|
|
document.getElementById('ge-rembg-grow')?.addEventListener('input', (e) => {
|
|
const v = parseInt(e.target.value, 10);
|
|
document.getElementById('ge-rembg-grow-label').textContent = (v >= 0 ? '+' : '') + v + 'px';
|
|
syncRembgGrow(v);
|
|
scheduleRembgApply();
|
|
});
|
|
|
|
// ── Selection-hint mask builder (used here + by wand-rembg) ──
|
|
// Full-image white-on-transparent mask PNG (base64, no `data:`
|
|
// prefix) of whichever selection is active — wand first, lasso
|
|
// second. Returns null if neither has a selection.
|
|
function buildSelectionHintMask() {
|
|
const w = state.imgWidth, h = state.imgHeight;
|
|
if (state.wandMask && state.wandLayerId) {
|
|
const off = state.layerOffsets.get(state.wandLayerId) || { x: 0, y: 0 };
|
|
const c = selectionMaskToDocument(
|
|
state.wandMask,
|
|
state.wandMaskSpace || 'layer',
|
|
off,
|
|
w,
|
|
h,
|
|
);
|
|
return c.toDataURL('image/png').split(',')[1];
|
|
}
|
|
if (state.lassoPoints.length >= 3 && !state.lassoActive) {
|
|
const c = document.createElement('canvas');
|
|
c.width = w; c.height = h;
|
|
const ctx = c.getContext('2d');
|
|
ctx.fillStyle = '#fff';
|
|
ctx.beginPath();
|
|
ctx.moveTo(state.lassoPoints[0].x, state.lassoPoints[0].y);
|
|
for (let i = 1; i < state.lassoPoints.length; i++) {
|
|
ctx.lineTo(state.lassoPoints[i].x, state.lassoPoints[i].y);
|
|
}
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
return c.toDataURL('image/png').split(',')[1];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
return { buildSelectionHintMask };
|
|
}
|