Files
odysseus/static/js/editor/ai-tools-misc.js
T
pewdiepie-archdaemon 2e8413a54a Preserve preview harness, editor, email and task improvements
Snapshot current maintainer-preview application changes and regression fixtures for integration into lab. Excludes local runtime data, evaluation outputs and source backups. Focused Python regression selection: 140 passed; full suite not certified.
2026-10-01 01:34:26 +00:00

272 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Misc AI-tool wiring — the three AI tools that don't share the
* inpaint pipeline:
*
* Harmonize: Reinhard color transfer on a body mask (no AI redraw)
* + an optional narrow inpaint on a seam mask if the
* "Seam fix" slider > 0.
* Canvas 2×/4× Upscale: in-browser bicubic resampling, no server.
* AI Upscale: Real-ESRGAN via /api/image/upscale-local.
* Style Transfer: img2img via /api/gallery/style-transfer.
*
* Plus the small `_addEmptyLayer` helper and its toolbar wiring,
* since it lived next to these.
*
* @param {{
* apiBase: string,
* buildLayerBodyMask: (featherPx: number) => string | null,
* buildSeamMask: (featherPx: number) => string | null,
* applyImageTool: (endpoint, payload, layerName, btn, opts?) => Promise<void>,
* flatten: () => HTMLCanvasElement,
* saveState: (label?: string) => void,
* fitZoom: () => void,
* composite: () => void,
* createLayer: (name, w, h) => object,
* renderLayerPanel: () => void,
* spinnerModule: object,
* uiModule: object,
* openAdjustmentLayer: (type: string, anchor: HTMLElement) => void,
* }} deps
*
* @returns {{ addEmptyLayer: () => void }}
*/
import { state } from './state.js';
import { beginAIOperation, decodeAIImage } from './ai-operation.js';
export function wireAIToolsMisc({
apiBase, buildLayerBodyMask, buildSeamMask, applyImageTool,
flatten, saveState, fitZoom, composite, createLayer, renderLayerPanel,
spinnerModule, uiModule, openAdjustmentLayer,
}) {
// ── Harmonize sliders — Color match + Seam fix ──
const harmColorPrev = document.getElementById('ge-harmonize-color-preview');
const harmSeamPrev = document.getElementById('ge-harmonize-seam-preview');
document.getElementById('ge-harmonize-color')?.addEventListener('input', (e) => {
document.getElementById('ge-harmonize-color-label').textContent = (e.target.value / 100).toFixed(2);
if (harmColorPrev) harmColorPrev.style.opacity = (parseInt(e.target.value, 10) / 100).toFixed(2);
});
document.getElementById('ge-harmonize-seam')?.addEventListener('input', (e) => {
document.getElementById('ge-harmonize-seam-label').textContent = (e.target.value / 100).toFixed(2);
if (harmSeamPrev) harmSeamPrev.style.opacity = (parseInt(e.target.value, 10) / 100).toFixed(2);
});
// Harmonize button — two-stage:
// 1) Reinhard color transfer on body mask (no AI redraw)
// 2) Optional narrow inpaint on seam mask (if seam_fix > 0)
document.getElementById('ge-harmonize-run')?.addEventListener('click', () => {
const prompt = document.getElementById('ge-harmonize-prompt')?.value?.trim() || 'photorealistic, natural lighting, seamless blend';
const color_match = (parseInt(document.getElementById('ge-harmonize-color')?.value || '65')) / 100;
const seam_fix = (parseInt(document.getElementById('ge-harmonize-seam')?.value || '0')) / 100;
const bodyFeather = Math.max(6, Math.round(Math.min(state.imgWidth, state.imgHeight) * 0.012));
const seamFeather = Math.max(8, Math.round(Math.min(state.imgWidth, state.imgHeight) * 0.015));
const body_mask = buildLayerBodyMask(bodyFeather);
const seam_mask = seam_fix > 0.01 ? buildSeamMask(seamFeather) : null;
// Harmonize needs a non-base layer to color-match against the
// background. Without one, the server would fall back to legacy
// whole-image img2img — i.e. regenerate the whole photo. Block
// that and tell the user what's missing.
if (!body_mask) {
if (uiModule) uiModule.showToast('Harmonize needs a second layer pasted/imported over the base photo — nothing to color-match against.', 6000);
return;
}
const payload = { prompt, color_match, seam_fix, body_mask };
if (seam_mask) payload.seam_mask = seam_mask;
applyImageTool('/api/image/harmonize', payload, 'Harmonized', document.getElementById('ge-harmonize-run'));
});
// ── Canvas upscale (bicubic) ──
function canvasUpscale(factor) {
saveState(`Upscale ${factor}×`);
const newW = state.imgWidth * factor;
const newH = state.imgHeight * factor;
state.layers.forEach(l => {
const tmp = document.createElement('canvas');
tmp.width = newW; tmp.height = newH;
const tCtx = tmp.getContext('2d');
tCtx.imageSmoothingEnabled = true;
tCtx.imageSmoothingQuality = 'high';
tCtx.drawImage(l.canvas, 0, 0, newW, newH);
l.canvas.width = newW; l.canvas.height = newH;
l.ctx.drawImage(tmp, 0, 0);
});
if (state.maskCanvas) { state.maskCanvas.width = newW; state.maskCanvas.height = newH; }
state.imgWidth = newW; state.imgHeight = newH;
state.mainCanvas.width = newW; state.mainCanvas.height = newH;
const sizeLabel = document.getElementById('ge-canvas-size');
if (sizeLabel) sizeLabel.textContent = `${newW}×${newH}`;
fitZoom();
composite();
uiModule.showToast(`Upscaled ${factor}× to ${newW}×${newH}`);
}
document.getElementById('ge-upscale-2x')?.addEventListener('click', () => canvasUpscale(2));
document.getElementById('ge-upscale-4x')?.addEventListener('click', () => canvasUpscale(4));
// ── AI upscale (Real-ESRGAN, no diffusion server required) ──
document.getElementById('ge-upscale-ai')?.addEventListener('click', async () => {
const btn = document.getElementById('ge-upscale-ai');
const origHTML = btn.innerHTML;
const operation = beginAIOperation(btn, () => uiModule?.showToast('Upscale cancelled'));
let upWp = null;
try {
upWp = spinnerModule.createWhirlpool(14);
upWp.element.style.cssText = 'display:inline-block;vertical-align:middle;position:relative;top:1px;margin-right:6px;width:14px;height:14px;';
btn.innerHTML = '';
btn.appendChild(upWp.element);
const lbl = document.createElement('span');
lbl.textContent = 'Upscaling…';
btn.appendChild(lbl);
} catch (_) { btn.textContent = 'Upscaling…'; }
try {
const flat = flatten();
const imageB64 = flat.toDataURL('image/png').split(',')[1];
const res = await fetch('/api/image/upscale-local', {
signal: operation.signal,
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: imageB64, scale: 2 }),
});
if (!res.ok) throw new Error('Server returned ' + res.status);
const data = await res.json();
if (data.image) {
const img = await decodeAIImage(data.image, operation.signal);
operation.signal.throwIfAborted();
{
if (!state.editorOpen) return;
saveState();
const newW = img.width, newH = img.height;
const layer = createLayer('AI Upscaled', newW, newH);
layer.ctx.drawImage(img, 0, 0);
state.layers.push(layer);
state.activeLayerId = layer.id;
state.imgWidth = newW; state.imgHeight = newH;
state.mainCanvas.width = newW; state.mainCanvas.height = newH;
if (state.maskCanvas) { state.maskCanvas.width = newW; state.maskCanvas.height = newH; }
const sizeLabel = document.getElementById('ge-canvas-size');
if (sizeLabel) sizeLabel.textContent = `${newW}×${newH}`;
fitZoom();
composite();
renderLayerPanel();
uiModule.showToast(`AI upscaled to ${newW}×${newH}`);
}
} else {
throw new Error(data.error || 'No image returned');
}
} catch (e) {
if (!operation.signal.aborted) uiModule.showToast('AI upscale failed: ' + e.message);
} finally {
operation.finish();
try { upWp?.destroy(); } catch (_) {}
btn.disabled = false;
btn.innerHTML = origHTML;
}
});
// ── Style transfer ──
document.getElementById('ge-style-strength')?.addEventListener('input', (e) => {
document.getElementById('ge-style-strength-label').textContent = (parseInt(e.target.value) / 100).toFixed(2);
});
document.getElementById('ge-style-run')?.addEventListener('click', async () => {
const btn = document.getElementById('ge-style-run');
const prompt = document.getElementById('ge-style-prompt').value.trim();
if (!prompt) { uiModule.showToast('Enter a style prompt'); return; }
const strength = parseInt(document.getElementById('ge-style-strength').value) / 100;
const originalHTML = btn.innerHTML;
const operation = beginAIOperation(btn, () => uiModule?.showToast('Style transfer cancelled'));
btn.textContent = 'Applying...';
try {
const flat = flatten();
const blob = await new Promise(r => flat.toBlob(r, 'image/png'));
const fd = new FormData();
fd.append('image', blob, 'style.png');
fd.append('prompt', prompt);
fd.append('strength', String(strength));
const res = await fetch(`${apiBase}/api/gallery/style-transfer`, { method: 'POST', credentials: 'same-origin', body: fd, signal: operation.signal });
if (!res.ok) throw new Error('Server returned ' + res.status);
const data = await res.json();
if (data.image) {
const img = await decodeAIImage(data.image, operation.signal);
operation.signal.throwIfAborted();
{
if (!state.editorOpen) return;
saveState();
const layer = createLayer('Styled: ' + prompt.substring(0, 20), state.imgWidth, state.imgHeight);
layer.ctx.drawImage(img, 0, 0, state.imgWidth, state.imgHeight);
state.layers.push(layer);
state.activeLayerId = layer.id;
composite();
renderLayerPanel();
uiModule.showToast('Style applied');
}
} else {
throw new Error(data.error || 'No image returned');
}
} catch (e) {
if (!operation.signal.aborted) uiModule.showToast('Style transfer failed: ' + e.message);
} finally {
operation.finish();
btn.disabled = false;
btn.innerHTML = originalHTML;
}
});
// ── Add empty layer (used by the layer-panel header button + the
// Ctrl+Alt+J keyboard shortcut). Returned so keyboard-shortcuts.js
// can call it through the same path. ──
function addEmptyLayer() {
saveState('Add layer');
const layer = createLayer('Layer ' + state.layers.length, state.imgWidth, state.imgHeight);
state.layers.push(layer);
state.activeLayerId = layer.id;
renderLayerPanel();
composite();
}
const addButton = document.getElementById('ge-add-layer');
addButton?.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
document.querySelector('.ge-add-layer-menu')?.remove();
const menu = document.createElement('div');
menu.className = 'ge-add-layer-menu ge-frosted';
menu.innerHTML = `
<button type="button" data-layer-kind="raster"><span class="ge-add-layer-symbol">+</span><span>Pixel Layer</span></button>
<span class="ge-add-layer-divider"></span>
<button type="button" data-adjustment-type="levels"><span class="ge-add-layer-symbol">▥</span><span>Levels</span></button>
<button type="button" data-adjustment-type="curves"><span class="ge-add-layer-symbol">⌁</span><span>Curves</span></button>
<button type="button" data-adjustment-type="exposure"><span class="ge-add-layer-symbol">☼</span><span>Exposure</span></button>
<button type="button" data-adjustment-type="white-balance"><span class="ge-add-layer-symbol">◑</span><span>White Balance</span></button>
<button type="button" data-adjustment-type="brightness-contrast"><span class="ge-add-layer-symbol">◐</span><span>Brightness / Contrast</span></button>
<button type="button" data-adjustment-type="hue-saturation"><span class="ge-add-layer-symbol">◉</span><span>Hue / Saturation</span></button>
<button type="button" data-adjustment-type="vibrance"><span class="ge-add-layer-symbol">⌁</span><span>Vibrance</span></button>
<button type="button" data-adjustment-type="black-white"><span class="ge-add-layer-symbol">◐</span><span>Black &amp; White</span></button>
<button type="button" data-adjustment-type="shadows-highlights"><span class="ge-add-layer-symbol">◐</span><span>Shadows / Highlights</span></button>
<button type="button" data-adjustment-type="color-balance"><span class="ge-add-layer-symbol">◒</span><span>Color Balance</span></button>
<button type="button" data-adjustment-type="selective-color"><span class="ge-add-layer-symbol">◎</span><span>Selective Color</span></button>
<button type="button" data-adjustment-type="gradient-map"><span class="ge-add-layer-symbol">▰</span><span>Gradient Map</span></button>
`;
document.body.appendChild(menu);
const rect = addButton.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
menu.style.left = `${Math.max(8, Math.min(window.innerWidth - menuRect.width - 8, rect.right - menuRect.width))}px`;
menu.style.top = `${Math.max(8, Math.min(window.innerHeight - menuRect.height - 8, rect.bottom + 5))}px`;
const close = () => {
menu.remove();
document.removeEventListener('pointerdown', away, true);
};
const away = pointerEvent => {
if (!menu.contains(pointerEvent.target) && pointerEvent.target !== addButton) close();
};
requestAnimationFrame(() => document.addEventListener('pointerdown', away, true));
menu.addEventListener('click', clickEvent => {
const button = clickEvent.target.closest('button');
if (!button) return;
clickEvent.preventDefault();
clickEvent.stopPropagation();
if (button.dataset.layerKind === 'raster') addEmptyLayer();
else if (button.dataset.adjustmentType) openAdjustmentLayer?.(button.dataset.adjustmentType, addButton);
close();
});
});
return { addEmptyLayer };
}