Files
odysseus/static/js/editor/ai-tools-misc.js
T
pewdiepie-archdaemonandClaude Opus 5.5 5fac693c1c feat(photo-editor): Ajax Image Editor client (Photoshop-parity tools)
Photoshop-parity editor client: layers, selection/masking, brushes,
smart filters, LUT import, Quick Edit, mobile gestures, workspaces,
batch operations, and the service-worker versioning (v978->v1006).

Source: vendor/photocraft (ad1cff44) photo-editor client files, merged
onto upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/A_conflicts.md; editor files taken from vendor/photocraft
(upstream's editor is an earlier snapshot of the same feature).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:57:35 +00:00

290 lines
15 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 buildFxMenu = (anchor) => {
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-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>
<span class="ge-add-layer-divider"></span>
<button type="button" data-adjustment-type="invert"><span class="ge-add-layer-symbol">◑</span><span>Invert</span></button>
<button type="button" data-adjustment-type="posterize"><span class="ge-add-layer-symbol">▤</span><span>Posterize</span></button>
<button type="button" data-adjustment-type="threshold"><span class="ge-add-layer-symbol">◧</span><span>Threshold</span></button>
<button type="button" data-adjustment-type="photo-filter"><span class="ge-add-layer-symbol">◍</span><span>Photo Filter</span></button>
<button type="button" data-adjustment-type="channel-mixer"><span class="ge-add-layer-symbol">⇄</span><span>Channel Mixer</span></button>
<button type="button" data-adjustment-type="color-lookup"><span class="ge-add-layer-symbol">▦</span><span>Color Lookup</span></button>
`;
document.body.appendChild(menu);
const rect = anchor.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 !== anchor) 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.adjustmentType) openAdjustmentLayer?.(button.dataset.adjustmentType, anchor);
close();
});
};
// "+" adds a plain pixel layer directly.
const addButton = document.getElementById('ge-add-layer');
addButton?.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
document.querySelector('.ge-add-layer-menu')?.remove();
addEmptyLayer();
});
// "fx" opens the adjustment-layer chooser.
const addFxButton = document.getElementById('ge-add-fx');
addFxButton?.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
buildFxMenu(addFxButton);
});
return { addEmptyLayer };
}