217 lines
9.9 KiB
JavaScript
217 lines
9.9 KiB
JavaScript
/**
|
||
* 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,
|
||
* }} deps
|
||
*
|
||
* @returns {{ addEmptyLayer: () => void }}
|
||
*/
|
||
import { state } from './state.js';
|
||
|
||
export function wireAIToolsMisc({
|
||
apiBase, buildLayerBodyMask, buildSeamMask, applyImageTool,
|
||
flatten, saveState, fitZoom, composite, createLayer, renderLayerPanel,
|
||
spinnerModule, uiModule,
|
||
}) {
|
||
// ── 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;
|
||
btn.disabled = true;
|
||
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', {
|
||
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 = new Image();
|
||
img.onload = () => {
|
||
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}`);
|
||
};
|
||
img.src = 'data:image/png;base64,' + data.image;
|
||
} else {
|
||
throw new Error(data.error || 'No image returned');
|
||
}
|
||
} catch (e) {
|
||
uiModule.showToast('AI upscale failed: ' + e.message);
|
||
}
|
||
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;
|
||
btn.disabled = true; 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 });
|
||
if (!res.ok) throw new Error('Server returned ' + res.status);
|
||
const data = await res.json();
|
||
if (data.image) {
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
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');
|
||
};
|
||
img.src = 'data:image/png;base64,' + data.image;
|
||
} else {
|
||
throw new Error(data.error || 'No image returned');
|
||
}
|
||
} catch (e) {
|
||
uiModule.showToast('Style transfer failed: ' + e.message);
|
||
}
|
||
btn.disabled = false; btn.textContent = 'Apply Style';
|
||
});
|
||
|
||
// ── 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();
|
||
}
|
||
document.getElementById('ge-add-layer')?.addEventListener('click', addEmptyLayer);
|
||
|
||
return { addEmptyLayer };
|
||
}
|