2041 lines
88 KiB
JavaScript
2041 lines
88 KiB
JavaScript
// noinspection JSUnresolvedReference
|
||
|
||
import {app} from "../../scripts/app.js";
|
||
import {api} from "../../scripts/api.js";
|
||
|
||
// Store last browsed path
|
||
let lastBrowsedPath = '';
|
||
|
||
// Registry of ImageComposer nodes for global graph change notifications
|
||
const composerNodes = new Set();
|
||
|
||
// Global graph change listener — fires when ANY connection changes in the graph.
|
||
// This catches upstream bypasses (e.g. disconnecting a loader from a KJ Set node)
|
||
// that don't trigger the Composer's own onConnectionsChange.
|
||
let graphChangeHandlerInstalled = false;
|
||
function installGraphChangeListener() {
|
||
if (graphChangeHandlerInstalled) return;
|
||
graphChangeHandlerInstalled = true;
|
||
// ComfyUI's LGraphCanvas fires "graphchange" on the canvas when connections change.
|
||
// We hook into the canvas to catch all connection changes.
|
||
const canvas = app.canvas;
|
||
if (canvas) {
|
||
const prevOnGraphChange = canvas.onGraphChange;
|
||
canvas.onGraphChange = function () {
|
||
const r = prevOnGraphChange?.apply(this, arguments);
|
||
// Notify all Composer nodes to refresh thumbnails
|
||
for (const node of composerNodes) {
|
||
if (node.refreshThumbs) {
|
||
node.refreshThumbs();
|
||
node.setDirtyCanvas?.(true, true);
|
||
}
|
||
}
|
||
return r;
|
||
};
|
||
}
|
||
}
|
||
|
||
/** Return the directory of a path string (handles Windows/Unix + [input]/[output]/[temp] suffixes). */
|
||
function dirnameOf(pathStr) {
|
||
if (!pathStr || typeof pathStr !== 'string') return '';
|
||
// Strip annotation like " foo.png [output]"
|
||
let p = pathStr.replace(/\s*\[[^\]]+\]\s*$/, '').trim();
|
||
if (!p) return '';
|
||
// Remove trailing slash
|
||
p = p.replace(/[/\\]+$/, '');
|
||
const lastSep = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
|
||
if (lastSep <= 0) return p; // bare filename or drive root
|
||
return p.substring(0, lastSep); // directory part
|
||
}
|
||
|
||
// File browser dialog
|
||
class FileBrowserDialog {
|
||
constructor(initialPath) {
|
||
// Prefer the path that is already in the node, then fall back to last browsed.
|
||
// A URL value (http/https) isn't a filesystem path — treating its host/path as a
|
||
// directory would produce an "Invalid path" error, so skip it and use the last
|
||
// browsed path (or the drive list) instead.
|
||
const fromWidget = /^https?:\/\//i.test(initialPath || '')
|
||
? ''
|
||
: dirnameOf(initialPath);
|
||
this.currentPath = fromWidget || lastBrowsedPath || '';
|
||
this.initialFilePath = initialPath || null; // ← keep the full path
|
||
this.selectedFile = null;
|
||
this.callback = null;
|
||
}
|
||
|
||
async show(callback) {
|
||
this.callback = callback;
|
||
|
||
// Create dialog overlay
|
||
const overlay = document.createElement('div');
|
||
overlay.style.cssText = `
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.8);
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
z-index: 10000;
|
||
`;
|
||
|
||
// Make overlay focusable
|
||
overlay.tabIndex = -1;
|
||
overlay.focus();
|
||
|
||
// Create dialog container
|
||
const dialog = document.createElement('div');
|
||
dialog.style.cssText = `
|
||
background: #2a2a2a;
|
||
border: 1px solid #555;
|
||
border-radius: 6px;
|
||
width: 85%;
|
||
max-width: 900px;
|
||
height: 75%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
color: #eee;
|
||
font-family: system-ui, sans-serif;
|
||
font-size: 13px;
|
||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||
`;
|
||
|
||
// Create header with view controls
|
||
const header = document.createElement('div');
|
||
header.style.cssText = `
|
||
padding: 12px 15px;
|
||
border-bottom: 1px solid #444;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
background: #333;
|
||
`;
|
||
header.innerHTML = `
|
||
<h3 style="margin: 0; font-size: 14px; font-weight: 600;">Select Image</h3>
|
||
<div style="display: flex; gap: 8px; align-items: center;">
|
||
<select id="sortMethod" style="background: #444; border: 1px solid #666; color: white; padding: 4px 8px; border-radius: 3px; font-size: 11px; cursor: pointer;">
|
||
<option value="name_asc">A → Z</option>
|
||
<option value="name_desc">Z → A</option>
|
||
<option value="date_desc">Newest</option>
|
||
<option value="date_asc">Oldest</option>
|
||
</select>
|
||
<button id="closeBrowser" style="background: #c00; border: none; color: white; padding: 4px 8px; cursor: pointer; border-radius: 3px; font-size: 12px; font-weight: bold;">✕</button>
|
||
</div>
|
||
`;
|
||
|
||
// Create path display - EDITABLE VERSION
|
||
const pathDisplay = document.createElement('div');
|
||
pathDisplay.id = 'pathDisplay';
|
||
pathDisplay.style.cssText = `
|
||
padding: 8px 15px;
|
||
background: #333;
|
||
border-bottom: 1px solid #444;
|
||
font-family: 'Courier New', monospace;
|
||
font-size: 11px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
color: #ccc;
|
||
`;
|
||
|
||
// Create browser content area
|
||
const content = document.createElement('div');
|
||
content.id = 'browserContent';
|
||
content.style.cssText = `
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 4px;
|
||
background: #1a1a1a;
|
||
`;
|
||
|
||
// Create preview area
|
||
const previewArea = document.createElement('div');
|
||
previewArea.id = 'previewArea';
|
||
previewArea.style.cssText = `
|
||
border-top: 1px solid #444;
|
||
padding: 12px 15px;
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: center;
|
||
min-height: 80px;
|
||
background: #2a2a2a;
|
||
font-size: 12px;
|
||
`;
|
||
|
||
// Create footer with buttons
|
||
const footer = document.createElement('div');
|
||
footer.style.cssText = `
|
||
padding: 12px 15px;
|
||
border-top: 1px solid #444;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
background: #333;
|
||
`;
|
||
footer.innerHTML = `
|
||
<button id="selectFile" style="background: #2a7a2a; border: none; color: white; padding: 6px 16px; cursor: pointer; border-radius: 3px; font-size: 12px;" disabled>Select</button>
|
||
<button id="cancelBrowser" style="background: #555; border: none; color: white; padding: 6px 16px; cursor: pointer; border-radius: 3px; font-size: 12px;">Cancel</button>
|
||
`;
|
||
|
||
dialog.appendChild(header);
|
||
dialog.appendChild(pathDisplay);
|
||
dialog.appendChild(content);
|
||
dialog.appendChild(previewArea);
|
||
dialog.appendChild(footer);
|
||
overlay.appendChild(dialog);
|
||
document.body.appendChild(overlay);
|
||
|
||
|
||
const escHandler = (e) => {
|
||
// Don't steal keys while typing in the path box, except Esc
|
||
const inPathInput = e.target && e.target.id === 'pathInput';
|
||
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
close();
|
||
return;
|
||
}
|
||
|
||
if (inPathInput) return; // let Enter in the path box still mean "Go"
|
||
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
if (this.selectedFile) {
|
||
selectAndClose(); // same as clicking "Select"
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (e.key === 'ArrowLeft') {
|
||
e.preventDefault();
|
||
document.getElementById('prevImg')?.click();
|
||
} else if (e.key === 'ArrowRight') {
|
||
e.preventDefault();
|
||
document.getElementById('nextImg')?.click();
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
document.getElementById('prevImg')?.click();
|
||
} else if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
document.getElementById('nextImg')?.click();
|
||
}
|
||
};
|
||
document.addEventListener('keydown', escHandler);
|
||
|
||
|
||
// Close function
|
||
const close = () => {
|
||
document.removeEventListener('keydown', escHandler);
|
||
document.body.removeChild(overlay);
|
||
};
|
||
|
||
// Event handlers
|
||
overlay.querySelector('#closeBrowser').onclick = close;
|
||
overlay.querySelector('#cancelBrowser').onclick = close;
|
||
|
||
const selectAndClose = () => {
|
||
if (this.selectedFile) {
|
||
const separator = this.selectedFile.includes('\\') ? '\\' : '/';
|
||
lastBrowsedPath = this.selectedFile.substring(0, this.selectedFile.lastIndexOf(separator));
|
||
this.callback(this.selectedFile);
|
||
close();
|
||
}
|
||
};
|
||
|
||
overlay.querySelector('#selectFile').onclick = selectAndClose;
|
||
|
||
// Add the sort change handler
|
||
const sortSelect = overlay.querySelector('#sortMethod');
|
||
sortSelect.onchange = () => {
|
||
this.loadDirectory(this.currentPath);
|
||
};
|
||
|
||
// Load initial directory
|
||
await this.loadDirectory(this.currentPath);
|
||
}
|
||
|
||
async loadDirectory(path) {
|
||
this.currentPath = path;
|
||
const content = document.getElementById('browserContent');
|
||
const pathDisplay = document.getElementById('pathDisplay');
|
||
const previewArea = document.getElementById('previewArea');
|
||
|
||
content.innerHTML = '<div style="text-align: center; padding: 40px; color: #888; font-size: 12px;">Loading...</div>';
|
||
previewArea.innerHTML = '';
|
||
this.selectedFile = null;
|
||
document.getElementById('selectFile').disabled = true;
|
||
|
||
try {
|
||
const sortMethod = document.getElementById('sortMethod') ? document.getElementById('sortMethod').value : 'name_asc';
|
||
const response = await api.fetchApi(`/noembryo/browse_directory?path=${encodeURIComponent(path)}&sort=${encodeURIComponent(sortMethod)}`);
|
||
const data = await response.json();
|
||
|
||
if (data.error) {
|
||
content.innerHTML = `<div style="color: #f55; padding: 20px; text-align: center; font-size: 12px;">Error: ${data.error}</div>`;
|
||
return;
|
||
}
|
||
|
||
const sortSelect = document.getElementById('sortMethod');
|
||
if (sortSelect && data.sort_method) {
|
||
sortSelect.value = data.sort_method;
|
||
}
|
||
|
||
// Update path display
|
||
pathDisplay.innerHTML = `
|
||
<button id="goUp" style="background: #444; border: none; color: white; padding: 3px 8px; cursor: pointer; border-radius: 2px; font-size: 11px;">↑ Up</button>
|
||
<input type="text" id="pathInput" value="${data.current_path || ''}" style="
|
||
flex: 1;
|
||
background: #1a1a1a;
|
||
border: 1px solid #555;
|
||
color: #eee;
|
||
padding: 4px 8px;
|
||
border-radius: 3px;
|
||
font-family: 'Courier New', monospace;
|
||
font-size: 11px;
|
||
" />
|
||
<button id="goPath" style="background: #444; border: none; color: white; padding: 3px 8px; cursor: pointer; border-radius: 2px; font-size: 11px;">Go</button>
|
||
`;
|
||
|
||
// Add event handler for the Go button and Enter key
|
||
const pathInput = document.getElementById('pathInput');
|
||
const goPathBtn = document.getElementById('goPath');
|
||
|
||
const goToPath = () => {
|
||
const newPath = pathInput.value.trim();
|
||
if (newPath) {
|
||
this.loadDirectory(newPath);
|
||
}
|
||
};
|
||
|
||
goPathBtn.onclick = goToPath;
|
||
pathInput.onkeypress = (e) => {
|
||
if (e.key === 'Enter') {
|
||
goToPath();
|
||
}
|
||
};
|
||
|
||
const goUpBtn = document.getElementById('goUp');
|
||
if (data.parent_path !== undefined && data.parent_path !== null) {
|
||
// "" is valid → opens the drive list on Windows
|
||
goUpBtn.onclick = () => this.loadDirectory(data.parent_path);
|
||
goUpBtn.disabled = false;
|
||
goUpBtn.style.opacity = '1';
|
||
goUpBtn.style.cursor = 'pointer';
|
||
} else {
|
||
goUpBtn.disabled = true;
|
||
goUpBtn.style.opacity = '0.4';
|
||
goUpBtn.style.cursor = 'not-allowed';
|
||
}
|
||
|
||
// Build file list - LIST VIEW ONLY
|
||
let html = '<div style="display: flex; flex-direction: column; gap: 2px;">';
|
||
|
||
// Directories in list view
|
||
for (const dir of data.directories) {
|
||
const fullPath = data.current_path ? `${data.current_path}${data.current_path.endsWith('/') || data.current_path.endsWith('\\') ? '' : (path.includes('\\') ? '\\' : '/')}${dir}` : dir;
|
||
html += `
|
||
<div class="browser-item directory" data-path="${fullPath}" style="
|
||
padding: 4px 8px;
|
||
background: #333;
|
||
border-radius: 3px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
border: 1px solid transparent;
|
||
">
|
||
<span style="font-size: 16px;">📁</span>
|
||
<span style="color: #eee;">${dir}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
this.fileList = []; // reset every time the folder changes
|
||
|
||
// Files in list view
|
||
for (const file of data.files) {
|
||
// const fullPath = data.current_path ? `${data.current_path}${data.current_path.endsWith('/') || data.current_path.endsWith('\\') ? '' : (data.current_path.includes('\\') ? '\\' : '/')}${file}` : file;
|
||
const fullPath = data.current_path
|
||
? `${data.current_path}${data.current_path.endsWith('/') || data.current_path.endsWith('\\') ? '' : (data.current_path.includes('\\') ? '\\' : '/')}${file}`
|
||
: file;
|
||
this.fileList.push(fullPath);
|
||
html += `
|
||
<div class="browser-item file" data-path="${fullPath}" style="
|
||
padding: 4px 8px;
|
||
background: #2a2a2a;
|
||
border-radius: 3px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
border: 1px solid transparent;
|
||
">
|
||
<span style="font-size: 16px;">🖼️</span>
|
||
<span style="color: #ccc;">${file}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
html += '</div>';
|
||
content.innerHTML = html;
|
||
|
||
// Add click handlers
|
||
this.attachEventHandlers(content);
|
||
|
||
// If we opened from a pasted path, pre-select that file (once)
|
||
if (this.initialFilePath) {
|
||
const target = this.initialFilePath.replace(/\s*\[[^\]]+\]\s*$/, '').trim();
|
||
const files = content.querySelectorAll('.browser-item.file');
|
||
for (const item of files) {
|
||
const p = item.getAttribute('data-path') || '';
|
||
// loose match: ignore slash direction and case (Windows)
|
||
if (p.replace(/\//g, '\\').toLowerCase() === target.replace(/\//g, '\\').toLowerCase()) {
|
||
item.click(); // reuses existing select + preview logic
|
||
item.scrollIntoView({block: 'nearest'});
|
||
break;
|
||
}
|
||
}
|
||
this.initialFilePath = null; // only on the first open
|
||
}
|
||
|
||
} catch (error) {
|
||
content.innerHTML = `<div style="color: #f55; padding: 20px; text-align: center; font-size: 12px;">Error loading directory: ${error.message}</div>`;
|
||
}
|
||
}
|
||
|
||
attachEventHandlers(content) {
|
||
// Click handlers for directories
|
||
content.querySelectorAll('.browser-item.directory').forEach(item => {
|
||
item.onclick = () => {
|
||
const path = item.getAttribute('data-path');
|
||
this.loadDirectory(path);
|
||
};
|
||
item.onmouseenter = () => {
|
||
item.style.background = '#3a3a3a';
|
||
item.style.border = '1px solid #666';
|
||
};
|
||
item.onmouseleave = () => {
|
||
item.style.background = '#333';
|
||
item.style.border = '1px solid transparent';
|
||
};
|
||
});
|
||
|
||
// Click handlers for files
|
||
content.querySelectorAll('.browser-item.file').forEach(item => {
|
||
let clickTimer;
|
||
let clickCount = 0;
|
||
|
||
const selectFile = async () => {
|
||
// Remove selection from other items
|
||
content.querySelectorAll('.browser-item.file').forEach(i => {
|
||
i.style.background = '#2a2a2a';
|
||
i.style.border = '1px solid transparent';
|
||
});
|
||
|
||
// Highlight this item
|
||
item.style.background = '#2a4a2a';
|
||
item.style.border = '1px solid #3a7a3a';
|
||
|
||
const path = item.getAttribute('data-path');
|
||
this.selectedFile = path;
|
||
document.getElementById('selectFile').disabled = false;
|
||
|
||
// Load preview
|
||
await this.loadPreview(path);
|
||
};
|
||
|
||
// Click handler for both single and double click
|
||
item.onclick = (e) => {
|
||
clickCount++;
|
||
|
||
if (clickCount === 1) {
|
||
clickTimer = setTimeout(() => {
|
||
// Single click behavior
|
||
selectFile();
|
||
clickCount = 0;
|
||
}, 300);
|
||
} else if (clickCount === 2) {
|
||
// Double click behavior
|
||
clearTimeout(clickTimer);
|
||
const path = item.getAttribute('data-path');
|
||
this.selectedFile = path;
|
||
const separator = path.includes('\\') ? '\\' : '/';
|
||
lastBrowsedPath = path.substring(0, path.lastIndexOf(separator));
|
||
this.callback(path);
|
||
document.body.querySelector('[style*="z-index: 10000"]')?.remove();
|
||
clickCount = 0;
|
||
}
|
||
};
|
||
|
||
item.onmouseenter = () => {
|
||
if (item.style.background !== '#2a4a2a') {
|
||
item.style.background = '#3a3a3a';
|
||
item.style.border = '1px solid #666';
|
||
}
|
||
};
|
||
item.onmouseleave = () => {
|
||
if (item.style.background !== '#2a4a2a') {
|
||
item.style.background = '#2a2a2a';
|
||
item.style.border = '1px solid transparent';
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
|
||
async selectByPath(path) {
|
||
const content = document.getElementById('browserContent');
|
||
if (!content) return;
|
||
const item = [...content.querySelectorAll('.browser-item.file')]
|
||
.find(el => (el.getAttribute('data-path') || '').replace(/\//g, '\\').toLowerCase()
|
||
=== path.replace(/\//g, '\\').toLowerCase());
|
||
if (!item) return;
|
||
item.click();
|
||
item.scrollIntoView({block: 'nearest'});
|
||
}
|
||
|
||
async loadPreview(path) {
|
||
const previewArea = document.getElementById('previewArea');
|
||
previewArea.innerHTML = '<div style="color: #888; font-size: 11px;">Loading preview...</div>';
|
||
|
||
try {
|
||
const response = await api.fetchApi(`/noembryo/get_image_preview?path=${encodeURIComponent(path)}`);
|
||
const data = await response.json();
|
||
|
||
if (data.error) {
|
||
previewArea.innerHTML = `<div style="color: #f55; font-size: 11px;">Preview error: ${data.error}</div>`;
|
||
return;
|
||
}
|
||
|
||
|
||
const idx = (this.fileList || []).findIndex(p =>
|
||
p.replace(/\//g, '\\').toLowerCase() === path.replace(/\//g, '\\').toLowerCase()
|
||
);
|
||
const hasPrev = idx > 0;
|
||
const hasNext = idx >= 0 && idx < (this.fileList.length - 1);
|
||
|
||
previewArea.innerHTML = `
|
||
<!--suppress ALL -->
|
||
<div style="display:flex; align-items:center; gap:6px;">
|
||
<button id="prevImg" ${hasPrev ? '' : 'disabled'}
|
||
style="background:#444; border:none; color:white; width:28px; height:28px;
|
||
border-radius:3px; cursor:${hasPrev ? 'pointer' : 'not-allowed'};
|
||
opacity:${hasPrev ? 1 : 0.35}; font-size:14px;">◀</button>
|
||
<img src="${data.preview}"
|
||
style="max-height:60px; max-width:100px; border:1px solid #555; border-radius:3px;" />
|
||
<button id="nextImg" ${hasNext ? '' : 'disabled'}
|
||
style="background:#444; border:none; color:white; width:28px; height:28px;
|
||
border-radius:3px; cursor:${hasNext ? 'pointer' : 'not-allowed'};
|
||
opacity:${hasNext ? 1 : 0.35}; font-size:14px;">▶</button>
|
||
</div>
|
||
<div style="font-size:11px; color:#ccc;">
|
||
<div>${data.width} × ${data.height}</div>
|
||
<div style="margin-top:3px; word-break:break-all; max-width:400px; color:#999;">
|
||
${path.split(/[\\/]/).pop()}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.getElementById('prevImg').onclick = () => {
|
||
if (hasPrev) this.selectByPath(this.fileList[idx - 1]);
|
||
};
|
||
document.getElementById('nextImg').onclick = () => {
|
||
if (hasNext) this.selectByPath(this.fileList[idx + 1]);
|
||
};
|
||
|
||
|
||
} catch (error) {
|
||
previewArea.innerHTML = `<div style="color: #f55; font-size: 11px;">Error loading preview</div>`;
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Interactive crop editor (adapted from obvpm/comfyui-obvpm, Apache-2.0)
|
||
// Crop, plus a max_megapixels downscale cap (downscale-only, aspect preserved).
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Return [newW, newH] if (w, h) exceeds maxMp megapixels, else null.
|
||
* Downscale-only, aspect-preserving. 1.0 MP == 1024x1024 px. maxMp <= 0 disables it. */
|
||
function computeDownscaledSize(w, h, maxMp) {
|
||
const mp = parseFloat(maxMp);
|
||
if (!mp || mp <= 0 || !w || !h) return null;
|
||
const maxPixels = mp * 1024 * 1024;
|
||
const current = w * h;
|
||
if (current <= maxPixels) return null;
|
||
const scale = Math.sqrt(maxPixels / current);
|
||
const nw = Math.max(1, Math.round(w * scale));
|
||
const nh = Math.max(1, Math.round(h * scale));
|
||
return [nw, nh];
|
||
}
|
||
|
||
const MARGIN = 10;
|
||
const HANDLE = 8;
|
||
const MIN_SEL = 6;
|
||
const MIN_EDITOR_H = 80;
|
||
const RESIZE_ZONE = 15;
|
||
const PREVIEW_TOOLTIP =
|
||
"Drag to crop · Drag inside to move · Drag corners to resize · Click outside selection to clear · Click ↻ to rotate 90°";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Image Composer — growing IMAGE inputs and a live arrangement preview.
|
||
// Mirrors the backend's natural-mode skyline packing (image_nodes.py).
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const IC_MAX_IMAGES = 16;
|
||
const IC_ALIGN = 16;
|
||
const IC_PACK_ASPECT_MIN = 0.45;
|
||
const IC_PACK_ASPECT_MAX = 2.2;
|
||
const IC_PACK_WIDTH_STEPS = 48;
|
||
const IC_EPS = 1e-9;
|
||
const IC_BACKGROUNDS = { black: "#000", grey: "#808080", white: "#fff" };
|
||
|
||
// Direct port of the backend skyline packer.
|
||
function icSkylinePack(sizes, width, gap) {
|
||
const sky = [[0, width, 0]];
|
||
const placed = [];
|
||
for (const [w, h] of sizes) {
|
||
const iw = w + gap, ih = h + gap;
|
||
if (iw > width + IC_EPS) return null;
|
||
let best = null;
|
||
for (let i = 0; i < sky.length; i++) {
|
||
const start = sky[i][0];
|
||
if (start + iw > width + IC_EPS) continue;
|
||
let y = 0, span = iw, j = i;
|
||
while (span > IC_EPS && j < sky.length) {
|
||
if (sky[j][2] > y) y = sky[j][2];
|
||
span -= sky[j][1];
|
||
j++;
|
||
}
|
||
if (span > IC_EPS) continue;
|
||
if (best === null || y < best[0] || (y === best[0] && start < best[1]))
|
||
best = [y, start];
|
||
}
|
||
if (best === null) return null;
|
||
const [y, x] = best;
|
||
placed.push([x, y, w, h]);
|
||
const cut = [], end = x + iw;
|
||
for (const [sx, sw, sy] of sky) {
|
||
if (sx + sw <= x + IC_EPS || sx >= end - IC_EPS) { cut.push([sx, sw, sy]); continue; }
|
||
if (sx < x) cut.push([sx, x - sx, sy]);
|
||
if (sx + sw > end) cut.push([end, sx + sw - end, sy]);
|
||
}
|
||
cut.push([x, iw, y + ih]);
|
||
cut.sort((a, b) => a[0] - b[0]);
|
||
const merged = [];
|
||
for (const seg of cut) {
|
||
if (merged.length && Math.abs(merged[merged.length - 1][2] - seg[2]) < IC_EPS)
|
||
merged[merged.length - 1][1] += seg[1];
|
||
else merged.push([...seg]);
|
||
}
|
||
sky.length = 0;
|
||
sky.push(...merged);
|
||
}
|
||
const w0 = Math.max(...placed.map((p) => p[0] + p[2]));
|
||
const h0 = Math.max(...placed.map((p) => p[1] + p[3]));
|
||
return [placed, w0, h0];
|
||
}
|
||
|
||
function icPackSweep(sizes, gap) {
|
||
const used = sizes.reduce((s, [w, h]) => s + w * h, 0);
|
||
const lo = Math.max(...sizes.map((s) => s[0])) + gap;
|
||
const hi = sizes.reduce((s, [w]) => s + w, 0) + gap * sizes.length;
|
||
const idx = sizes.map((_, i) => i);
|
||
const orders = [
|
||
idx,
|
||
[...idx].sort((a, b) => sizes[b][1] - sizes[a][1] || a - b),
|
||
[...idx].sort((a, b) => sizes[b][0] - sizes[a][0] || a - b),
|
||
[...idx].sort((a, b) =>
|
||
sizes[b][0] * sizes[b][1] - sizes[a][0] * sizes[a][1] || a - b),
|
||
];
|
||
// Quantized score for exact backend parity.
|
||
const q = (v) => Math.floor(v * 1e9 + 0.5);
|
||
let found = null;
|
||
for (const order of orders) {
|
||
const ordered = order.map((i) => sizes[i]);
|
||
let best = null;
|
||
for (let step = 0; step < IC_PACK_WIDTH_STEPS; step++) {
|
||
const width = lo + ((hi - lo) * step) / (IC_PACK_WIDTH_STEPS - 1);
|
||
const got = icSkylinePack(ordered, width, gap);
|
||
if (!got) continue;
|
||
const [placed, w0, h0] = got;
|
||
const fill = used / (w0 * h0);
|
||
const aspect = w0 / h0;
|
||
if (aspect < IC_PACK_ASPECT_MIN || aspect > IC_PACK_ASPECT_MAX) continue;
|
||
const key = [-q(fill), q(Math.abs(Math.log(aspect))), -q(aspect)];
|
||
if (best === null || icKeyLess(key, best.key)) {
|
||
best = { key, placed, w0, h0, order };
|
||
}
|
||
}
|
||
if (best && (!found || icKeyLess(best.key, found.key))) found = best;
|
||
}
|
||
if (!found) return null;
|
||
const boxes = new Array(sizes.length).fill(null);
|
||
found.order.forEach((slot, i) => { boxes[slot] = found.placed[i]; });
|
||
return { boxes, w0: found.w0, h0: found.h0 };
|
||
}
|
||
|
||
function icKeyLess(a, b) {
|
||
for (let i = 0; i < a.length; i++) {
|
||
if (a[i] !== b[i]) return a[i] < b[i];
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Mirror of the backend's _ic_plan_natural (integer canvas, shared scale <= 1).
|
||
// Includes the half-gap frame around the sheet, as the backend does.
|
||
function icPlanNatural(sizes, gap) {
|
||
const packed = icPackSweep(sizes, gap);
|
||
if (!packed) return null;
|
||
const { boxes, w0, h0 } = packed;
|
||
const alignUp = (v) => Math.max(IC_ALIGN, Math.ceil(v / IC_ALIGN) * IC_ALIGN);
|
||
// Frame of gap/2 around the whole sheet; 0 when gap is 0.
|
||
const frame = Math.round(gap / 2);
|
||
let width = alignUp(w0);
|
||
let height = alignUp(h0);
|
||
const out = boxes.map(([x, y, w, h]) => {
|
||
const bw = Math.max(1, Math.round(w));
|
||
const bh = Math.max(1, Math.round(h));
|
||
return [Math.round(x), Math.round(y), bw, bh];
|
||
});
|
||
// Expand the canvas by the frame and shift every box inward by it.
|
||
width += 2 * frame;
|
||
height += 2 * frame;
|
||
for (const b of out) {
|
||
b[0] += frame;
|
||
b[1] += frame;
|
||
}
|
||
return { width, height, boxes: out };
|
||
}
|
||
|
||
app.registerExtension({
|
||
name: "noEmbryo.ImageComposer",
|
||
beforeRegisterNodeDef(nodeType, nodeData) {
|
||
if (nodeData.name !== "Image Composer -noEmbryo") return;
|
||
|
||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||
nodeType.prototype.onNodeCreated = function () {
|
||
const result = onNodeCreated
|
||
? onNodeCreated.apply(this, arguments)
|
||
: undefined;
|
||
const node = this;
|
||
|
||
const mpWidget = node.widgets.find((w) => w.name === "max_megapixels");
|
||
const gapWidget = node.widgets.find((w) => w.name === "gap");
|
||
const bgWidget = node.widgets.find((w) => w.name === "background");
|
||
|
||
// Hide the managed widget (canvas + Nodes 2.0).
|
||
for (const w of [mpWidget]) {
|
||
if (w) {
|
||
w.hidden = true;
|
||
w.options = w.options || {};
|
||
w.options.hidden = true;
|
||
}
|
||
}
|
||
|
||
const state = { thumbs: new Map(), box: null };
|
||
|
||
// Register this Composer node for global graph change notifications
|
||
composerNodes.add(node);
|
||
// Expose refreshThumbs on the node so the global listener can call it
|
||
node.refreshThumbs = refreshThumbs;
|
||
// Clean up registry when node is removed
|
||
const prevOnRemoved = node.onRemoved;
|
||
node.onRemoved = function () {
|
||
composerNodes.delete(node);
|
||
return prevOnRemoved?.apply(this, arguments);
|
||
};
|
||
// Install global graph change listener (once)
|
||
installGraphChangeListener();
|
||
|
||
function connectedSlots() {
|
||
const slots = [];
|
||
for (let i = 1; i <= IC_MAX_IMAGES; i++) {
|
||
const input = node.inputs?.find((inp) => inp.name === `image${i}`);
|
||
if (input && input.link != null) slots.push(i);
|
||
}
|
||
return slots;
|
||
}
|
||
|
||
// Return only slots that have a valid upstream image path
|
||
// (not bypassed, not cleared). Used for preview packing to avoid
|
||
// showing numbered placeholders for disabled/bypassed GetNodes.
|
||
function validSlots() {
|
||
return connectedSlots().filter((i) => {
|
||
const info = upstreamPath(i);
|
||
return info && info.path;
|
||
});
|
||
}
|
||
|
||
// --- Growing inputs -----------------------------------------
|
||
// Keep exactly one trailing free slot: enough connected inputs
|
||
// to hold every link, plus one empty one to grow into.
|
||
function syncInputs() {
|
||
let connected = 0;
|
||
for (const inp of node.inputs || []) {
|
||
if (inp.link != null) connected++;
|
||
}
|
||
const want = Math.min(IC_MAX_IMAGES, connected + 1);
|
||
// Remove trailing unconnected inputs beyond the wanted count.
|
||
while (node.inputs.length > want &&
|
||
node.inputs[node.inputs.length - 1].link == null) {
|
||
node.removeInput(node.inputs.length - 1);
|
||
}
|
||
// Add free slots until we reach the wanted count.
|
||
let n = node.inputs.length;
|
||
while (n < want) {
|
||
n++;
|
||
node.addInput(`image${n}`, "IMAGE");
|
||
}
|
||
}
|
||
|
||
const prevOnConn = nodeType.prototype.onConnectionsChange;
|
||
nodeType.prototype.onConnectionsChange = function (side, slot, connect) {
|
||
const r = prevOnConn?.apply(this, arguments);
|
||
if (side === 1) {
|
||
syncInputs();
|
||
refreshThumbs();
|
||
if (!connect) {
|
||
// Connection was broken — clean up thumbnail entries
|
||
// for any inputs that are no longer connected so the
|
||
// Composer doesn't keep showing the bypassed image.
|
||
let cleaned = false;
|
||
for (const [key, entry] of state.thumbs) {
|
||
const i = Number(key);
|
||
const inp = node.inputs?.find((w) => w.name === `image${i}`);
|
||
if (!inp || inp.link == null) {
|
||
state.thumbs.delete(key);
|
||
cleaned = true;
|
||
}
|
||
}
|
||
if (cleaned) node.setDirtyCanvas?.(true, true);
|
||
}
|
||
}
|
||
return r;
|
||
};
|
||
|
||
// --- Thumbnail loading via the serve proxy -------------------
|
||
function upstreamPath(slotIdx) {
|
||
const input = node.inputs?.find((inp) => inp.name === `image${slotIdx}`);
|
||
if (!input || input.link == null) return null;
|
||
const link = app.graph.links[input.link];
|
||
if (!link) return null;
|
||
const src = app.graph._nodes_by_id?.[link.origin_id];
|
||
if (!src) return null;
|
||
|
||
console.log(`[ImageComposer] slot ${slotIdx}: src type=${src.type || "?"} ` +
|
||
`widgets=${(src.widgets || []).map(w => w.name).join(",")}`);
|
||
|
||
// Direct connection: LoadImageFromPathEnhanced
|
||
const result = icExtractFromNode(src);
|
||
if (result) {
|
||
console.log(`[ImageComposer] slot ${slotIdx}: direct match path=${result.path}`);
|
||
return result;
|
||
}
|
||
|
||
// Intermediate nodes (e.g. KJ Set/Get): trace back
|
||
const traced = icTraceBack(src);
|
||
if (traced) {
|
||
console.log(`[ImageComposer] slot ${slotIdx}: traced path=${traced.path}`);
|
||
} else {
|
||
console.log(`[ImageComposer] slot ${slotIdx}: no trace result, ` +
|
||
`checking widgets for paths...`);
|
||
const fallbackPath = icFindPathInWidgets(src);
|
||
if (fallbackPath) {
|
||
console.log(`[ImageComposer] slot ${slotIdx}: fallback path=${fallbackPath}`);
|
||
return { path: fallbackPath, rotation: 0, crop: null, maxMp: 0,
|
||
srcNode: src };
|
||
}
|
||
}
|
||
return traced;
|
||
}
|
||
|
||
// Extract path/crop/maxMp from a LoadImageFromPathEnhanced node.
|
||
function icExtractFromNode(srcNode) {
|
||
// If the source node is bypassed (mode 4), treat as no valid source.
|
||
if (srcNode.mode === 4) return null;
|
||
const pathW = srcNode.widgets?.find((w) => w.name === "image");
|
||
const cropW = srcNode.widgets?.find((w) => w.name === "crop");
|
||
if (!pathW) return null;
|
||
let crop = null, rotation = 0;
|
||
try {
|
||
const data = JSON.parse(cropW?.value || "{}") || {};
|
||
rotation = parseInt(data.rotation, 10) || 0;
|
||
if (data.w > 0 && data.h > 0)
|
||
crop = { x: +data.x, y: +data.y, w: +data.w, h: +data.h };
|
||
} catch (e) { /* ignore */ }
|
||
// The upstream megapixel cap shapes the tensor the backend
|
||
// receives, so it must shape the packing too.
|
||
const mpW = srcNode.widgets?.find((w) => w.name === "max_megapixels");
|
||
const maxMp = Math.max(0, parseFloat(mpW?.value) || 0);
|
||
return { path: String(pathW.value || "").trim(), rotation, crop,
|
||
maxMp, srcNode: srcNode };
|
||
}
|
||
|
||
// Generic fallback: check ALL widgets on a node for any string
|
||
// that looks like a file path or URL.
|
||
function icFindPathInWidgets(srcNode) {
|
||
for (const w of srcNode.widgets || []) {
|
||
const v = String(w.value || "").trim();
|
||
if (!v) continue;
|
||
if (v.startsWith("/") || v.startsWith("http") ||
|
||
v.endsWith(".png") || v.endsWith(".jpg") ||
|
||
v.endsWith(".jpeg") || v.endsWith(".webp") ||
|
||
v.endsWith(".bmp") || v.endsWith(".gif")) {
|
||
return v;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Trace back through intermediate nodes to find the original
|
||
// LoadImageFromPathEnhanced source.
|
||
function icTraceBack(node) {
|
||
// Strategy 1: follow input links backward through the graph
|
||
for (const inp of node.inputs || []) {
|
||
if (inp.link != null) {
|
||
const l = app.graph.links[inp.link];
|
||
if (l) {
|
||
const src = app.graph._nodes_by_id?.[l.origin_id];
|
||
if (src) {
|
||
const result = icExtractFromNode(src);
|
||
if (result) return result;
|
||
const recursive = icTraceBack(src);
|
||
if (recursive) return recursive;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Strategy 2: KJ GetNode — match key to find SetNode's source
|
||
const nodeType = node.type || "";
|
||
if (nodeType === "GetNode" || nodeType.includes("Get")) {
|
||
const kjResult = icTraceThroughKJ(node);
|
||
if (kjResult) return kjResult;
|
||
}
|
||
|
||
// Strategy 3: any widget on this node contains a path
|
||
const path = icFindPathInWidgets(node);
|
||
if (path) {
|
||
return { path, rotation: 0, crop: null, maxMp: 0,
|
||
srcNode: node };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// For a GetNode, find the matching SetNode by key,
|
||
// then trace to the node that feeds the SetNode's input.
|
||
function icTraceThroughKJ(getNode) {
|
||
// KJ nodes store the key in widgets[0] (first widget).
|
||
const keyW = getNode.widgets?.[0];
|
||
if (!keyW) return null;
|
||
const key = String(keyW.value ?? "");
|
||
if (!key) return null;
|
||
|
||
// Search for a SetNode with matching key
|
||
for (const nodeId in app.graph._nodes_by_id) {
|
||
const n = app.graph._nodes_by_id[nodeId];
|
||
if (n === getNode) continue;
|
||
if (n.type !== "SetNode" && !n.type.includes("Set")) continue;
|
||
// Found a SetNode — check if its key matches
|
||
const setKeyW = n.widgets?.[0];
|
||
if (!setKeyW) continue;
|
||
if (String(setKeyW.value ?? "") !== key) continue;
|
||
// If SetNode is bypassed (mode 4) or its input is disconnected,
|
||
// the loader is effectively bypassed — don't return stale value.
|
||
if (n.mode === 4) continue;
|
||
if (!(n.inputs?.[0]?.link != null)) continue;
|
||
// Key matches — follow SetNode's input (slot 0) to find source
|
||
for (const inp of n.inputs || []) {
|
||
if (inp.link != null) {
|
||
const l = app.graph.links[inp.link];
|
||
if (l) {
|
||
const src = app.graph._nodes_by_id?.[l.origin_id];
|
||
if (src) {
|
||
const result = icExtractFromNode(src);
|
||
if (result) return result;
|
||
const recursive = icTraceBack(src);
|
||
if (recursive) return recursive;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function loadThumb(slotIdx, entry) {
|
||
entry.seq = (entry.seq || 0) + 1;
|
||
const seq = entry.seq;
|
||
const p = entry.path;
|
||
if (!p) { entry.img = null; node.setDirtyCanvas?.(true, true); return; }
|
||
const url = /^https?:\/\//i.test(p)
|
||
? p
|
||
: `/noembryo/serve_image?path=${encodeURIComponent(p)}` +
|
||
`&t=${Date.now()}`;
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
if (entry.seq !== seq) return;
|
||
// Backend order: ROTATE the full image first, THEN crop —
|
||
// the crop coords are drawn on the rotated preview, so
|
||
// they only map correctly onto the rotated image.
|
||
let result = icRotate(img, entry.rotation);
|
||
const c = entry.crop;
|
||
if (c && c.w > 0 && c.h > 0) {
|
||
const rw = result.width, rh = result.height;
|
||
const cw = Math.max(1, Math.round(c.w * rw));
|
||
const ch = Math.max(1, Math.round(c.h * rh));
|
||
const cx = Math.max(0, Math.min(rw - 1,
|
||
Math.round(c.x * rw)));
|
||
const cy = Math.max(0, Math.min(rh - 1,
|
||
Math.round(c.y * rh)));
|
||
const cc = document.createElement("canvas");
|
||
cc.width = cw;
|
||
cc.height = ch;
|
||
cc.getContext("2d").drawImage(result, cx, cy, cw, ch,
|
||
0, 0, cw, ch);
|
||
result = cc;
|
||
}
|
||
// Apply the upstream megapixel cap (downscale-only,
|
||
// aspect-preserved — mirrors the backend behaviour).
|
||
const mp = entry.maxMp;
|
||
if (mp > 0) {
|
||
const maxPixels = mp * 1024 * 1024;
|
||
const cur = result.width * result.height;
|
||
if (cur > maxPixels) {
|
||
const sc = Math.sqrt(maxPixels / cur);
|
||
const sc2 = document.createElement("canvas");
|
||
sc2.width = Math.max(1, Math.round(result.width * sc));
|
||
sc2.height = Math.max(1, Math.round(result.height * sc));
|
||
sc2.getContext("2d").drawImage(
|
||
result, 0, 0, sc2.width, sc2.height);
|
||
result = sc2;
|
||
}
|
||
}
|
||
entry.img = result;
|
||
node.setDirtyCanvas?.(true, true);
|
||
};
|
||
img.onerror = () => {
|
||
if (entry.seq !== seq) return;
|
||
entry.img = null;
|
||
node.setDirtyCanvas?.(true, true);
|
||
};
|
||
img.src = url;
|
||
}
|
||
|
||
function icRotate(src, deg) {
|
||
const d = ((deg % 360) + 360) % 360;
|
||
if (!d) return src;
|
||
const c = document.createElement("canvas");
|
||
const quarter = (d / 90) % 4;
|
||
if (quarter % 2 === 1) { c.width = src.height; c.height = src.width; }
|
||
else { c.width = src.width; c.height = src.height; }
|
||
const ctx = c.getContext("2d");
|
||
ctx.translate(c.width / 2, c.height / 2);
|
||
ctx.rotate((d * Math.PI) / 180);
|
||
ctx.drawImage(src, -src.width / 2, -src.height / 2);
|
||
return c;
|
||
}
|
||
|
||
function refreshThumbs() {
|
||
for (const i of connectedSlots()) {
|
||
const info = upstreamPath(i);
|
||
const entry = state.thumbs.get(i) || {};
|
||
state.thumbs.set(i, entry);
|
||
if (info && info.path) {
|
||
const cropKey = `${info.crop ? JSON.stringify(info.crop) : ""}` +
|
||
`|${info.maxMp}`;
|
||
if (entry.path !== info.path ||
|
||
entry.rotation !== info.rotation ||
|
||
entry.cropKey !== cropKey) {
|
||
entry.path = info.path;
|
||
entry.rotation = info.rotation;
|
||
entry.crop = info.crop;
|
||
entry.maxMp = info.maxMp;
|
||
entry.cropKey = cropKey;
|
||
loadThumb(i, entry);
|
||
}
|
||
} else if (!info) {
|
||
state.thumbs.delete(i);
|
||
} else if (!info.path) {
|
||
// Upstream exists but path is empty (loader cleared).
|
||
// Drop the stale thumbnail so the Composer doesn't
|
||
// keep showing the last received image.
|
||
if (entry && entry.path) {
|
||
entry.path = "";
|
||
entry.img = null;
|
||
entry.rotation = 0;
|
||
entry.crop = null;
|
||
entry.maxMp = 0;
|
||
entry.cropKey = "";
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Live preview widget ------------------------------------
|
||
let allocHeight;
|
||
const MIN_PREVIEW_H = 80;
|
||
const MARGIN = 10;
|
||
|
||
function boxHeight(widget, widgetY, fallback) {
|
||
const nodeH = node.size?.[1];
|
||
const visible = node.widgets?.filter((w) => !w.hidden);
|
||
const isLast = !!visible && visible[visible.length - 1] === widget;
|
||
if (nodeH == null || widgetY == null || !isLast) return fallback;
|
||
return Math.max(MIN_PREVIEW_H, nodeH - widgetY);
|
||
}
|
||
|
||
const isVueMode = () =>
|
||
typeof LiteGraph !== "undefined" && !!LiteGraph.vueNodesMode;
|
||
|
||
const preview = {
|
||
name: "composer_preview",
|
||
type: "noembryo_composer_preview",
|
||
value: "",
|
||
serialize: false,
|
||
options: { serialize: false },
|
||
computeLayoutSize() {
|
||
return { minHeight: MIN_PREVIEW_H, maxHeight: 100000, minWidth: 0 };
|
||
},
|
||
draw(ctx, _node, widgetWidth, y, H, lowQuality) {
|
||
const h = boxHeight(this, y, allocHeight ?? H) - 8;
|
||
const x = MARGIN;
|
||
const nodeW = _node?.size?.[0];
|
||
const effWidth =
|
||
!isVueMode() && nodeW ? Math.min(widgetWidth, nodeW) : widgetWidth;
|
||
const w = effWidth - MARGIN * 2;
|
||
const slots = validSlots();
|
||
|
||
// Gather sizes for the packing (thumbs may still be loading —
|
||
// fall back to 1:1 aspect so the layout is stable).
|
||
const sizes = slots.map((i) => {
|
||
const t = state.thumbs.get(i);
|
||
return t?.img ? [t.img.width, t.img.height] : [64, 64];
|
||
});
|
||
const gap = Math.max(0, parseInt(gapWidget?.value, 10) || 0);
|
||
const plan = slots.length ? icPlanNatural(sizes, gap) : null;
|
||
|
||
state.box = null;
|
||
ctx.save();
|
||
ctx.fillStyle = "#00000033";
|
||
ctx.fillRect(x, y, w, h);
|
||
|
||
if (!slots.length || !plan) {
|
||
ctx.fillStyle = "#888";
|
||
ctx.font = "12px sans-serif";
|
||
ctx.textAlign = "center";
|
||
ctx.textBaseline = "middle";
|
||
ctx.fillText("Connect image inputs", x + w / 2, y + h / 2);
|
||
ctx.restore();
|
||
return;
|
||
}
|
||
|
||
// Fit the plan inside the preview area, letterboxed.
|
||
const areaH = h - 4;
|
||
const s = Math.min(w / plan.width, areaH / plan.height);
|
||
const pw = plan.width * s, ph = plan.height * s;
|
||
const px0 = x + (w - pw) / 2, py0 = y + 2 + (areaH - ph) / 2;
|
||
state.box = { px0, py0, pw, ph, s, plan, slots };
|
||
|
||
ctx.fillStyle = IC_BACKGROUNDS[bgWidget?.value] || "#000";
|
||
ctx.fillRect(px0, py0, pw, ph);
|
||
|
||
slots.forEach((slotIdx, i) => {
|
||
const [bx, by, bw, bh] = plan.boxes[i];
|
||
const t = state.thumbs.get(slotIdx);
|
||
const drawX = px0 + bx * s, drawY = py0 + by * s;
|
||
// background for slots whose thumb hasn't loaded
|
||
if (!t?.img) {
|
||
ctx.fillStyle = "#333";
|
||
ctx.fillRect(drawX, drawY, bw * s, bh * s);
|
||
ctx.fillStyle = "#888";
|
||
ctx.font = "10px sans-serif";
|
||
ctx.textAlign = "center";
|
||
ctx.textBaseline = "middle";
|
||
ctx.fillText(`${slotIdx}`, drawX + (bw * s) / 2,
|
||
drawY + (bh * s) / 2);
|
||
return;
|
||
}
|
||
// Fit inside the slot, never enlarge (natural sizing).
|
||
const scale = Math.min((bw * s) / t.img.width,
|
||
(bh * s) / t.img.height, 1);
|
||
const tw = t.img.width * scale, th = t.img.height * scale;
|
||
ctx.drawImage(t.img, drawX, drawY, tw, th);
|
||
});
|
||
|
||
// Sheet size pill
|
||
ctx.fillStyle = "rgba(0,0,0,0.6)";
|
||
ctx.font = "10px sans-serif";
|
||
ctx.textAlign = "left";
|
||
ctx.textBaseline = "alphabetic";
|
||
const label = `${plan.width} x ${plan.height} px`;
|
||
const tw = ctx.measureText(label).width;
|
||
ctx.fillRect(x + 2, y + h - 16, tw + 8, 14);
|
||
ctx.fillStyle = "#ddd";
|
||
ctx.fillText(label, x + 6, y + h - 5);
|
||
ctx.restore();
|
||
},
|
||
};
|
||
|
||
Object.defineProperty(preview, "computedHeight", {
|
||
configurable: true,
|
||
get() { return undefined; },
|
||
set(v) { allocHeight = v; },
|
||
});
|
||
Object.defineProperty(preview, "width", {
|
||
configurable: true,
|
||
get: () => undefined,
|
||
set: () => {},
|
||
});
|
||
node.addCustomWidget(preview);
|
||
|
||
// Live refresh when widgets change.
|
||
for (const w of [gapWidget, bgWidget]) {
|
||
if (w) {
|
||
const prev = w.callback;
|
||
w.callback = function () {
|
||
const r = prev?.apply(this, arguments);
|
||
refreshThumbs();
|
||
node.setDirtyCanvas?.(true, true);
|
||
return r;
|
||
};
|
||
}
|
||
}
|
||
|
||
// Watch KJ Get nodes: when their value changes, refresh thumbnails.
|
||
for (const i of connectedSlots()) {
|
||
const input = node.inputs?.find((inp) => inp.name === `image${i}`);
|
||
if (!input || input.link == null) continue;
|
||
const link = app.graph.links[input.link];
|
||
if (!link) continue;
|
||
const src = app.graph._nodes_by_id?.[link.origin_id];
|
||
if (!src) continue;
|
||
const srcType = src.type || "";
|
||
if (!srcType.includes("KJ")) continue;
|
||
for (const w of src.widgets || []) {
|
||
const prev = w.callback;
|
||
w.callback = function () {
|
||
const r = prev?.apply(this, arguments);
|
||
refreshThumbs();
|
||
node.setDirtyCanvas?.(true, true);
|
||
return r;
|
||
};
|
||
}
|
||
}
|
||
|
||
// Watch upstream nodes: crop/path/rotation edits refresh instantly.
|
||
// The Composer's own onDrawBackground runs on every canvas redraw,
|
||
// so polling there is cheap (string compares) and always fires —
|
||
// unlike hooks on the upstream node, which newer ComfyUI versions
|
||
// may simply never call.
|
||
const prevBg = node.onDrawBackground;
|
||
node.onDrawBackground = function () {
|
||
const r = prevBg?.apply(this, arguments);
|
||
let changed = false;
|
||
// Safety net: clean up thumbnail entries for any
|
||
// inputs that are no longer connected (e.g. bypassed).
|
||
for (const [key, entry] of state.thumbs) {
|
||
const i = Number(key);
|
||
const input = node.inputs?.find((inp) => inp.name === `image${i}`);
|
||
if (!input || input.link == null) {
|
||
state.thumbs.delete(key);
|
||
changed = true;
|
||
}
|
||
}
|
||
for (const i of connectedSlots()) {
|
||
const cur = upstreamPath(i);
|
||
const entry = state.thumbs.get(i);
|
||
if (!cur) {
|
||
// Upstream invalid (bypassed, broken trace): clear
|
||
// the thumbnail but keep the entry so a reconnect
|
||
// can reload it. Only clear if we had a real image
|
||
// — avoids wiping during transient null returns.
|
||
if (entry && entry.path && entry.img) {
|
||
entry.path = "";
|
||
entry.img = null;
|
||
changed = true;
|
||
}
|
||
continue;
|
||
}
|
||
// Upstream cleared (empty path): only clear the thumbnail
|
||
// when we previously had a real path — avoids wiping
|
||
// during transient states where upstreamPath returns ""
|
||
// momentarily (graph rebuilds, node moves, etc.).
|
||
if (!cur.path) {
|
||
if (entry && entry.path) {
|
||
entry.path = "";
|
||
entry.img = null;
|
||
changed = true;
|
||
}
|
||
continue;
|
||
}
|
||
const cropKey = `${cur.crop ? JSON.stringify(cur.crop) : ""}` +
|
||
`|${cur.maxMp}`;
|
||
if (entry && (entry.path !== cur.path ||
|
||
entry.rotation !== cur.rotation ||
|
||
entry.cropKey !== cropKey)) {
|
||
entry.path = cur.path;
|
||
entry.rotation = cur.rotation;
|
||
entry.crop = cur.crop;
|
||
entry.maxMp = cur.maxMp;
|
||
entry.cropKey = cropKey;
|
||
loadThumb(i, entry);
|
||
changed = true;
|
||
} else if (!entry) {
|
||
const ne = { path: cur.path, rotation: cur.rotation,
|
||
crop: cur.crop, maxMp: cur.maxMp, cropKey, img: null };
|
||
state.thumbs.set(i, ne);
|
||
loadThumb(i, ne);
|
||
changed = true;
|
||
}
|
||
}
|
||
if (changed) node.setDirtyCanvas?.(true, true);
|
||
return r;
|
||
};
|
||
|
||
setTimeout(() => {
|
||
syncInputs();
|
||
refreshThumbs();
|
||
// Set up KJ node callbacks now that inputs are synced.
|
||
for (const i of connectedSlots()) {
|
||
const input = node.inputs?.find((inp) => inp.name === `image${i}`);
|
||
if (!input || input.link == null) continue;
|
||
const link = app.graph.links[input.link];
|
||
if (!link) continue;
|
||
const src = app.graph._nodes_by_id?.[link.origin_id];
|
||
if (!src) continue;
|
||
const srcType = src.type || "";
|
||
if (!srcType.includes("KJ")) continue;
|
||
for (const w of src.widgets || []) {
|
||
if (w.callback) continue; // already set up
|
||
const prev = w.callback;
|
||
w.callback = function () {
|
||
const r = prev?.apply(this, arguments);
|
||
refreshThumbs();
|
||
node.setDirtyCanvas?.(true, true);
|
||
return r;
|
||
};
|
||
}
|
||
}
|
||
}, 0);
|
||
|
||
const prevOnConfigure = node.onConfigure;
|
||
node.onConfigure = function () {
|
||
const r = prevOnConfigure?.apply(this, arguments);
|
||
setTimeout(() => { syncInputs(); refreshThumbs(); }, 0);
|
||
return r;
|
||
};
|
||
|
||
return result;
|
||
};
|
||
},
|
||
});
|
||
|
||
app.registerExtension({
|
||
name: "noEmbryo.LoadImageFromPath",
|
||
|
||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||
if (nodeData.name !== "Load Image (from path) -noEmbryo") return;
|
||
|
||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||
|
||
nodeType.prototype.onNodeCreated = function () {
|
||
const result = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||
const node = this;
|
||
|
||
const imageWidget = node.widgets.find((w) => w.name === "image");
|
||
const cropWidget = node.widgets.find((w) => w.name === "crop");
|
||
// Looked up fresh (not cached) wherever it's read — if ComfyUI ever
|
||
// replaces node.widgets rather than mutating it in place (reconfigure,
|
||
// resize, Vue/Nodes 2.0 re-render), a cached reference here would go
|
||
// stale and silently keep reading a detached widget forever.
|
||
function getMaxMegapixels() {
|
||
const w = node.widgets?.find((x) => x.name === "max_megapixels");
|
||
return w ? parseFloat(w.value) : 0;
|
||
}
|
||
|
||
// --- TEMPORARY DIAGNOSTIC ---------------------------------------
|
||
// Logs once per distinct (dims, maxMp, result) combo to the browser
|
||
// console so we can see exactly what the widget/math report at
|
||
// draw time. Safe to delete once the label issue is confirmed fixed.
|
||
let _lastDebugKey = null;
|
||
function debugDownscale(label, w, h, maxMp, result) {
|
||
const key = `${label}|${w}x${h}|${maxMp}|${result ? result.join("x") : "none"}`;
|
||
if (key === _lastDebugKey) return;
|
||
_lastDebugKey = key;
|
||
}
|
||
// -----------------------------------------------------------------
|
||
|
||
// Hide the crop JSON widget (canvas + Nodes 2.0)
|
||
if (cropWidget) {
|
||
cropWidget.hidden = true;
|
||
cropWidget.options = cropWidget.options || {};
|
||
cropWidget.options.hidden = true;
|
||
}
|
||
|
||
const isVueMode = () =>
|
||
typeof LiteGraph !== "undefined" && !!LiteGraph.vueNodesMode;
|
||
const ui = () =>
|
||
isVueMode()
|
||
? { font: 13, row: 18, handle: 12 }
|
||
: { font: 10, row: 14, handle: HANDLE };
|
||
|
||
// Suppress stock node.imgs preview so only the crop editor draws
|
||
Object.defineProperty(node, "imgs", {
|
||
get: () => undefined,
|
||
set: () => {},
|
||
});
|
||
|
||
const state = {
|
||
img: null,
|
||
rect: null, // normalized {x,y,w,h} or null = full image
|
||
drag: null,
|
||
box: null,
|
||
hoverPreview: false, // pointer currently over the image box
|
||
rotateHover: false, // pointer currently over the ↻ button
|
||
rotateBtn: null, // {cx, cy, r} of the ↻ button, if any
|
||
};
|
||
|
||
try {
|
||
const saved = cropWidget?.value ? JSON.parse(cropWidget.value) : null;
|
||
if (saved && saved.w > 0 && saved.h > 0) state.rect = saved;
|
||
} catch (e) {
|
||
state.rect = null;
|
||
}
|
||
|
||
function syncCrop() {
|
||
if (!cropWidget) return;
|
||
const parts = [];
|
||
if (state.rect && state.rect.w > 0.001 && state.rect.h > 0.001) {
|
||
const r = state.rect;
|
||
if (!(r.x < 0.002 && r.y < 0.002 && r.w > 0.996 && r.h > 0.996)) {
|
||
parts.push(`"x":${+r.x.toFixed(4)}`);
|
||
parts.push(`"y":${+r.y.toFixed(4)}`);
|
||
parts.push(`"w":${+r.w.toFixed(4)}`);
|
||
parts.push(`"h":${+r.h.toFixed(4)}`);
|
||
}
|
||
}
|
||
const rot = getRotation();
|
||
if (rot) parts.push(`"rotation":${rot}`);
|
||
const value = parts.length ? `{${parts.join(",")}}` : "";
|
||
if (cropWidget.value !== value) {
|
||
cropWidget.value = value;
|
||
}
|
||
}
|
||
|
||
// --- Rotation (↻ button) ---------------------------------------
|
||
// Rotation lives inside the hidden crop widget's JSON as a
|
||
// "rotation":<degrees> field (0/90/180/270 clockwise), so no new
|
||
// widget/input is needed and it persists with the workflow.
|
||
function getRotation() {
|
||
try {
|
||
const v = cropWidget?.value;
|
||
if (!v) return 0;
|
||
const r = parseInt(JSON.parse(v).rotation, 10);
|
||
return isNaN(r) || r < 0 ? 0 : r % 360;
|
||
} catch (e) {
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
function setCropRotation(deg) {
|
||
if (!cropWidget) return;
|
||
let data = {};
|
||
const v = cropWidget.value;
|
||
if (v) {
|
||
try { data = JSON.parse(v) || {}; } catch (e) { data = {}; }
|
||
}
|
||
if (deg) data.rotation = deg;
|
||
else delete data.rotation;
|
||
const keys = Object.keys(data);
|
||
cropWidget.value = keys.length ? JSON.stringify(data) : "";
|
||
}
|
||
|
||
// Clockwise rotation of an Image/canvas for the preview. Returns a
|
||
// canvas matching the backend's torch.rot90(k<0) clockwise result.
|
||
function rotateForPreview(src, deg) {
|
||
const d = ((deg % 360) + 360) % 360;
|
||
if (!d) return src;
|
||
const c = document.createElement("canvas");
|
||
const q = (d / 90) % 4;
|
||
if (q % 2 === 1) {
|
||
c.width = src.height;
|
||
c.height = src.width;
|
||
} else {
|
||
c.width = src.width;
|
||
c.height = src.height;
|
||
}
|
||
const ctx = c.getContext("2d");
|
||
ctx.translate(c.width / 2, c.height / 2);
|
||
ctx.rotate((d * Math.PI) / 180); // positive = clockwise (y-down)
|
||
ctx.drawImage(src, -src.width / 2, -src.height / 2);
|
||
return c;
|
||
}
|
||
|
||
function rotate90() {
|
||
const rot = (getRotation() + 90) % 360;
|
||
state.rect = null; // existing crop is stale after rotating
|
||
setCropRotation(rot);
|
||
syncCrop();
|
||
loadImageFromPath();
|
||
node.setDirtyCanvas?.(true, true);
|
||
editorWidget.triggerDraw?.();
|
||
}
|
||
|
||
function previewHeight(width) {
|
||
if (!state.img) return 100;
|
||
return Math.round(width * (state.img.height / state.img.width));
|
||
}
|
||
|
||
function cropDims() {
|
||
const iw = state.img.width;
|
||
const ih = state.img.height;
|
||
const r = state.rect;
|
||
const x0 = Math.max(0, Math.min(iw - 1, Math.round(r.x * iw)));
|
||
const y0 = Math.max(0, Math.min(ih - 1, Math.round(r.y * ih)));
|
||
const x1 = Math.max(x0 + 1, Math.min(iw, Math.round((r.x + r.w) * iw)));
|
||
const y1 = Math.max(y0 + 1, Math.min(ih, Math.round((r.y + r.h) * ih)));
|
||
return [x1 - x0, y1 - y0];
|
||
}
|
||
|
||
function hitTest(px, py) {
|
||
if (!state.rect || !state.box) return { mode: "new" };
|
||
const handle = ui().handle;
|
||
const { bx, by, bw, bh } = state.box;
|
||
const sx = bx + state.rect.x * bw;
|
||
const sy = by + state.rect.y * bh;
|
||
const sw = state.rect.w * bw;
|
||
const sh = state.rect.h * bh;
|
||
const corners = {
|
||
nw: [sx, sy],
|
||
ne: [sx + sw, sy],
|
||
sw: [sx, sy + sh],
|
||
se: [sx + sw, sy + sh],
|
||
};
|
||
for (const [name, [cx, cy]] of Object.entries(corners)) {
|
||
if (Math.abs(px - cx) <= handle && Math.abs(py - cy) <= handle) {
|
||
return { mode: "resize", corner: name };
|
||
}
|
||
}
|
||
if (px >= sx && px <= sx + sw && py >= sy && py <= sy + sh) {
|
||
return { mode: "move", offX: px - sx, offY: py - sy };
|
||
}
|
||
return { mode: "new" };
|
||
}
|
||
|
||
let allocHeight;
|
||
|
||
function boxHeight(widget, widgetY, fallback) {
|
||
if (isVueMode()) return fallback;
|
||
const nodeH = node.size?.[1];
|
||
const visible = node.widgets?.filter((w) => !w.hidden);
|
||
const isLast = !!visible && visible[visible.length - 1] === widget;
|
||
if (nodeH == null || widgetY == null || !isLast) return fallback;
|
||
return Math.max(MIN_EDITOR_H, nodeH - widgetY);
|
||
}
|
||
|
||
const editor = {
|
||
name: "crop_editor",
|
||
type: "noembryo_cropeditor",
|
||
value: "",
|
||
serialize: false,
|
||
options: { serialize: false },
|
||
|
||
// Do not lock height to the image aspect — keep the user's
|
||
// node size and letterbox the preview inside the allocated area.
|
||
computeLayoutSize: function (n) {
|
||
return { minHeight: MIN_EDITOR_H, maxHeight: 100000, minWidth: 0 };
|
||
},
|
||
|
||
draw: function (ctx, _node, widgetWidth, y, H, lowQuality) {
|
||
const u = ui();
|
||
const h = boxHeight(this, y, allocHeight ?? H) - 8;
|
||
const x = MARGIN;
|
||
const nodeW = _node?.size?.[0];
|
||
const effWidth =
|
||
!isVueMode() && nodeW ? Math.min(widgetWidth, nodeW) : widgetWidth;
|
||
state.lastDrawW = effWidth;
|
||
const w = effWidth - MARGIN * 2;
|
||
const imgAreaH = Math.max(1, h - u.row);
|
||
|
||
ctx.save();
|
||
|
||
if (!state.img) {
|
||
ctx.fillStyle = "#00000033";
|
||
ctx.fillRect(x, y, w, h);
|
||
ctx.fillStyle = "#888";
|
||
ctx.font = `${u.font + 2}px sans-serif`;
|
||
ctx.textAlign = "center";
|
||
ctx.textBaseline = "middle";
|
||
ctx.fillText("no image", x + w / 2, y + h / 2);
|
||
ctx.restore();
|
||
return;
|
||
}
|
||
|
||
const scale = Math.min(w / state.img.width, imgAreaH / state.img.height);
|
||
const bw = state.img.width * scale;
|
||
const bh = state.img.height * scale;
|
||
const bx = x + (w - bw) / 2;
|
||
const by = y + (imgAreaH - bh) / 2;
|
||
state.box = { bx, by, bw, bh };
|
||
ctx.drawImage(state.img, bx, by, bw, bh);
|
||
|
||
if (state.rect && !lowQuality) {
|
||
const sx = bx + state.rect.x * bw;
|
||
const sy = by + state.rect.y * bh;
|
||
const sw = state.rect.w * bw;
|
||
const sh = state.rect.h * bh;
|
||
|
||
ctx.beginPath();
|
||
ctx.rect(bx, by, bw, bh);
|
||
ctx.rect(sx, sy, sw, sh);
|
||
ctx.fillStyle = "rgba(0,0,0,0.55)";
|
||
ctx.fill("evenodd");
|
||
|
||
ctx.strokeStyle = "#4af";
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeRect(sx, sy, sw, sh);
|
||
ctx.fillStyle = "#4af";
|
||
for (const [hx, hy] of [
|
||
[sx, sy],
|
||
[sx + sw, sy],
|
||
[sx, sy + sh],
|
||
[sx + sw, sy + sh],
|
||
]) {
|
||
ctx.fillRect(hx - 2.5, hy - 2.5, 5, 5);
|
||
}
|
||
|
||
ctx.font = `${u.font}px sans-serif`;
|
||
ctx.textAlign = "left";
|
||
ctx.textBaseline = "alphabetic";
|
||
const pillH = u.font + 2;
|
||
const drawPill = (segments, ty) => {
|
||
const widths = segments.map((s) => ctx.measureText(s[0]).width);
|
||
const tw = widths.reduce((a, b) => a + b, 0);
|
||
const tx = Math.max(
|
||
bx,
|
||
Math.min(sx + (sw - tw - 6) / 2, bx + bw - tw - 6)
|
||
);
|
||
ctx.fillStyle = "rgba(0,0,0,0.6)";
|
||
ctx.fillRect(tx, ty - pillH + 3, tw + 6, pillH);
|
||
let cx = tx + 3;
|
||
for (const [i, [text, color]] of segments.entries()) {
|
||
ctx.fillStyle = color;
|
||
ctx.fillText(text, cx, ty);
|
||
cx += widths[i];
|
||
}
|
||
};
|
||
const [pw, ph] = cropDims();
|
||
const _maxMp1 = getMaxMegapixels();
|
||
const cropDown = computeDownscaledSize(pw, ph, _maxMp1);
|
||
debugDownscale("crop-pill", pw, ph, _maxMp1, cropDown);
|
||
const cropSegments = cropDown
|
||
? [
|
||
["Downscaled to: ", "#fff"],
|
||
[`${cropDown[0]} x ${cropDown[1]}`, "#4af"],
|
||
]
|
||
: [[`${pw} x ${ph}`, "#fff"]];
|
||
drawPill(
|
||
cropSegments,
|
||
sy > y + pillH + 2 ? sy - 3 : sy + pillH - 1
|
||
);
|
||
}
|
||
|
||
if (!lowQuality) {
|
||
const lg = typeof LiteGraph !== "undefined" ? LiteGraph : {};
|
||
const textColor = lg.WIDGET_TEXT_COLOR || "#ddd";
|
||
const MUTED_ALPHA = 0.45;
|
||
const iw = state.img.width;
|
||
const ih = state.img.height;
|
||
const segments = [
|
||
["Full: ", true],
|
||
[`${iw} x ${ih}`, false],
|
||
];
|
||
// With no crop drawn, the full image IS the output — show the
|
||
// megapixel cap here too, same as the per-crop pill does.
|
||
if (!state.rect) {
|
||
const _maxMp2 = getMaxMegapixels();
|
||
const fullDown = computeDownscaledSize(iw, ih, _maxMp2);
|
||
debugDownscale("full-label", iw, ih, _maxMp2, fullDown);
|
||
if (fullDown) {
|
||
segments.push([" — Downscaled to: ", true]);
|
||
segments.push([
|
||
`${fullDown[0]} x ${fullDown[1]}`,
|
||
false,
|
||
]);
|
||
}
|
||
}
|
||
ctx.font = `${u.font}px sans-serif`;
|
||
ctx.textBaseline = "alphabetic";
|
||
ctx.textAlign = "left";
|
||
ctx.fillStyle = textColor;
|
||
const ty = y + h - 3;
|
||
const total = segments.reduce(
|
||
(sum, s) => sum + ctx.measureText(s[0]).width,
|
||
0
|
||
);
|
||
// Clamp instead of pure-centering (mirrors the crop pill's
|
||
// clamp) — with the downscale text appended this line can
|
||
// exceed the available width, and an unclamped center
|
||
// pushes cx negative, drawing it clipped off the node.
|
||
let cx = Math.max(
|
||
x,
|
||
Math.min(x + (w - total) / 2, x + w - total)
|
||
);
|
||
const prevAlpha = ctx.globalAlpha;
|
||
for (const [text, muted] of segments) {
|
||
ctx.globalAlpha = muted ? prevAlpha * MUTED_ALPHA : prevAlpha;
|
||
ctx.fillText(text, cx, ty);
|
||
cx += ctx.measureText(text).width;
|
||
}
|
||
ctx.globalAlpha = prevAlpha;
|
||
}
|
||
|
||
// Hover-only ↻ rotate button (top-right of the preview)
|
||
state.rotateBtn = null;
|
||
if (state.img && state.hoverPreview) {
|
||
const rbR = 10;
|
||
const rbMargin = 8;
|
||
const rcx = bx + bw - rbMargin - rbR;
|
||
const rcy = by + rbMargin + rbR;
|
||
state.rotateBtn = { cx: rcx, cy: rcy, r: rbR };
|
||
ctx.beginPath();
|
||
ctx.arc(rcx, rcy, rbR, 0, Math.PI * 2);
|
||
ctx.fillStyle = state.rotateHover
|
||
? "rgba(255,255,255,0.92)"
|
||
: "rgba(30,30,30,0.72)";
|
||
ctx.fill();
|
||
ctx.lineWidth = 1.5;
|
||
ctx.strokeStyle = state.rotateHover ? "#111" : "#fff";
|
||
ctx.stroke();
|
||
ctx.fillStyle = state.rotateHover ? "#111" : "#fff";
|
||
ctx.font = `bold ${rbR}px sans-serif`;
|
||
ctx.textAlign = "center";
|
||
ctx.textBaseline = "middle";
|
||
ctx.fillText("↻", rcx, rcy + 1);
|
||
}
|
||
|
||
ctx.restore();
|
||
},
|
||
|
||
mouse: function (event, pos, _node) {
|
||
if (!state.img || !state.box) return false;
|
||
const t = event.type;
|
||
const px = pos[0];
|
||
const py = pos[1];
|
||
const { bx, by, bw, bh } = state.box;
|
||
const clampX = (v) => Math.max(bx, Math.min(bx + bw, v));
|
||
const clampY = (v) => Math.max(by, Math.min(by + bh, v));
|
||
|
||
if (t === "pointerdown" || t === "mousedown") {
|
||
const rb = state.rotateBtn;
|
||
if (rb) {
|
||
const dx = px - rb.cx;
|
||
const dy = py - rb.cy;
|
||
if (dx * dx + dy * dy <= rb.r * rb.r) {
|
||
rotate90();
|
||
return true;
|
||
}
|
||
}
|
||
if (px < bx || px > bx + bw || py < by || py > by + bh) {
|
||
return false;
|
||
}
|
||
state.drag = {
|
||
...hitTest(px, py),
|
||
startX: px,
|
||
startY: py,
|
||
moved: false,
|
||
};
|
||
const el = event.target;
|
||
if (el?.style) {
|
||
el.style.cursor =
|
||
state.drag.mode === "move"
|
||
? "grabbing"
|
||
: state.drag.mode === "resize"
|
||
? state.drag.corner === "nw" ||
|
||
state.drag.corner === "se"
|
||
? "nwse-resize"
|
||
: "nesw-resize"
|
||
: "crosshair";
|
||
}
|
||
this.triggerDraw?.();
|
||
return true;
|
||
}
|
||
|
||
const drag = state.drag;
|
||
if (!drag) return false;
|
||
|
||
if (t === "pointermove" || t === "mousemove") {
|
||
if (Math.abs(px - drag.startX) + Math.abs(py - drag.startY) > 2) {
|
||
drag.moved = true;
|
||
}
|
||
if (drag.mode === "new") {
|
||
const x0 = clampX(Math.min(drag.startX, px));
|
||
const y0 = clampY(Math.min(drag.startY, py));
|
||
const x1 = clampX(Math.max(drag.startX, px));
|
||
const y1 = clampY(Math.max(drag.startY, py));
|
||
if (x1 - x0 >= MIN_SEL && y1 - y0 >= MIN_SEL) {
|
||
state.rect = {
|
||
x: (x0 - bx) / bw,
|
||
y: (y0 - by) / bh,
|
||
w: (x1 - x0) / bw,
|
||
h: (y1 - y0) / bh,
|
||
};
|
||
}
|
||
} else if (drag.mode === "move" && state.rect) {
|
||
let nx = (clampX(px - drag.offX) - bx) / bw;
|
||
let ny = (clampY(py - drag.offY) - by) / bh;
|
||
nx = Math.max(0, Math.min(1 - state.rect.w, nx));
|
||
ny = Math.max(0, Math.min(1 - state.rect.h, ny));
|
||
state.rect.x = nx;
|
||
state.rect.y = ny;
|
||
} else if (drag.mode === "resize" && state.rect) {
|
||
const r = state.rect;
|
||
let x0 = bx + r.x * bw;
|
||
let y0 = by + r.y * bh;
|
||
let x1 = x0 + r.w * bw;
|
||
let y1 = y0 + r.h * bh;
|
||
if (drag.corner.includes("w")) x0 = clampX(px);
|
||
if (drag.corner.includes("e")) x1 = clampX(px);
|
||
if (drag.corner.includes("n")) y0 = clampY(py);
|
||
if (drag.corner.includes("s")) y1 = clampY(py);
|
||
if (
|
||
Math.abs(x1 - x0) >= MIN_SEL &&
|
||
Math.abs(y1 - y0) >= MIN_SEL
|
||
) {
|
||
state.rect = {
|
||
x: (Math.min(x0, x1) - bx) / bw,
|
||
y: (Math.min(y0, y1) - by) / bh,
|
||
w: Math.abs(x1 - x0) / bw,
|
||
h: Math.abs(y1 - y0) / bh,
|
||
};
|
||
}
|
||
}
|
||
this.triggerDraw?.();
|
||
return true;
|
||
}
|
||
|
||
if (t === "pointerup" || t === "mouseup") {
|
||
if (drag.mode === "new" && !drag.moved) {
|
||
state.rect = null;
|
||
}
|
||
state.drag = null;
|
||
if (event.target?.style) event.target.style.cursor = "";
|
||
syncCrop();
|
||
this.triggerDraw?.();
|
||
return true;
|
||
}
|
||
return false;
|
||
},
|
||
};
|
||
|
||
// Browse button under the path field and above the crop preview.
|
||
// Must be added BEFORE the crop editor so creation order matches
|
||
// visual order (and the editor stays the last, growable widget).
|
||
node.addWidget("button", "🔍 Browse...", null, () => {
|
||
const currentPath = imageWidget ? imageWidget.value : "";
|
||
const browser = new FileBrowserDialog(currentPath);
|
||
browser.show(async (filePath) => {
|
||
if (imageWidget) {
|
||
imageWidget.value = filePath;
|
||
state.rect = null;
|
||
syncCrop();
|
||
// Keep current node size; letterbox the new image
|
||
loadImageFromPath();
|
||
app.graph.setDirtyCanvas(true, true);
|
||
}
|
||
});
|
||
});
|
||
|
||
const editorWidget = node.addCustomWidget(editor);
|
||
|
||
function cursorOutside(px, py) {
|
||
const w = node.size?.[0];
|
||
const h = node.size?.[1];
|
||
if (w == null || h == null) return "default";
|
||
if (py <= h && py >= h - RESIZE_ZONE) {
|
||
if (px >= w - RESIZE_ZONE) return "nwse-resize";
|
||
if (px <= RESIZE_ZONE) return "nesw-resize";
|
||
}
|
||
return "default";
|
||
}
|
||
function cursorFor(px, py) {
|
||
if (!state.img || !state.box) return cursorOutside(px, py);
|
||
const { bx, by, bw, bh } = state.box;
|
||
if (px < bx || px > bx + bw || py < by || py > by + bh) {
|
||
return cursorOutside(px, py);
|
||
}
|
||
const hit = hitTest(px, py);
|
||
if (hit.mode === "resize") {
|
||
return hit.corner === "nw" || hit.corner === "se"
|
||
? "nwse-resize"
|
||
: "nesw-resize";
|
||
}
|
||
if (hit.mode === "move") return "grab";
|
||
return state.rect ? "not-allowed" : "crosshair";
|
||
}
|
||
const prevMouseMove = node.onMouseMove;
|
||
node.onMouseMove = function (e, pos, graphCanvas) {
|
||
prevMouseMove?.apply(this, arguments);
|
||
const el = graphCanvas?.canvas || app.canvas?.canvas;
|
||
if (!el) return;
|
||
const x = pos[0], y = pos[1];
|
||
const prevHover = state.hoverPreview;
|
||
const prevRotHover = state.rotateHover;
|
||
if (state.img && state.box) {
|
||
const { bx, by, bw, bh } = state.box;
|
||
const over =
|
||
x >= bx && x <= bx + bw && y >= by && y <= by + bh;
|
||
state.hoverPreview = over;
|
||
el.title = over ? PREVIEW_TOOLTIP : "";
|
||
let overRotate = false;
|
||
if (over && state.rotateBtn) {
|
||
const dx = x - state.rotateBtn.cx;
|
||
const dy = y - state.rotateBtn.cy;
|
||
overRotate =
|
||
dx * dx + dy * dy <=
|
||
state.rotateBtn.r * state.rotateBtn.r;
|
||
}
|
||
state.rotateHover = overRotate;
|
||
if (!state.drag) {
|
||
el.style.cursor = overRotate
|
||
? "pointer"
|
||
: cursorFor(x, y);
|
||
}
|
||
} else {
|
||
state.hoverPreview = false;
|
||
state.rotateHover = false;
|
||
el.title = "";
|
||
}
|
||
if (
|
||
state.hoverPreview !== prevHover ||
|
||
state.rotateHover !== prevRotHover
|
||
) {
|
||
node.setDirtyCanvas?.(true, true);
|
||
}
|
||
};
|
||
const prevMouseLeave = node.onMouseLeave;
|
||
node.onMouseLeave = function () {
|
||
prevMouseLeave?.apply(this, arguments);
|
||
const el = app.canvas?.canvas;
|
||
if (el) {
|
||
el.style.cursor = "";
|
||
el.title = "";
|
||
}
|
||
state.hoverPreview = false;
|
||
state.rotateHover = false;
|
||
node.setDirtyCanvas?.(true, true);
|
||
};
|
||
|
||
Object.defineProperty(editorWidget, "computedHeight", {
|
||
configurable: true,
|
||
get() {
|
||
if (isVueMode() || allocHeight == null) return undefined;
|
||
const box = boxHeight(this, this.y, allocHeight);
|
||
return Math.max(0, box - RESIZE_ZONE);
|
||
},
|
||
set(v) {
|
||
allocHeight = v;
|
||
},
|
||
});
|
||
Object.defineProperty(editorWidget, "width", {
|
||
configurable: true,
|
||
get: () => undefined,
|
||
set: () => {},
|
||
});
|
||
|
||
// Load preview from arbitrary filesystem path via our serve endpoint
|
||
let loadSeq = 0;
|
||
function loadImageFromPath() {
|
||
const seq = ++loadSeq;
|
||
const raw = imageWidget?.value || "";
|
||
const path = String(raw).replace(/\s*\[[^\]]+\]\s*$/, "").trim();
|
||
if (!path) {
|
||
state.img = null;
|
||
node.setDirtyCanvas?.(true, true);
|
||
editorWidget.triggerDraw?.();
|
||
return;
|
||
}
|
||
// URLs are loaded directly by the browser (cross-origin <img>
|
||
// display + canvas draw works fine — no read-back is used, so
|
||
// there's no canvas taint issue). Local paths go through our
|
||
// serve proxy endpoint.
|
||
const url =
|
||
/^https?:\/\//i.test(path)
|
||
? path
|
||
: `/noembryo/serve_image?path=${encodeURIComponent(path)}` +
|
||
`&filename=${encodeURIComponent(path.split(/[\\/]/).pop())}` +
|
||
`&t=${Date.now()}`;
|
||
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
if (seq !== loadSeq) return;
|
||
// Apply the stored clockwise rotation (matches the backend).
|
||
state.img = rotateForPreview(img, getRotation());
|
||
// Never resize the node to the image ratio — letterbox only.
|
||
node.setDirtyCanvas?.(true, true);
|
||
editorWidget.triggerDraw?.();
|
||
};
|
||
img.onerror = () => {
|
||
if (seq !== loadSeq) return;
|
||
state.img = null;
|
||
node.setDirtyCanvas?.(true, true);
|
||
editorWidget.triggerDraw?.();
|
||
};
|
||
img.src = url;
|
||
}
|
||
|
||
// Redraw the preview labels when the megapixel cap changes
|
||
const maxMpWidgetForCallback = node.widgets.find(
|
||
(w) => w.name === "max_megapixels"
|
||
);
|
||
if (maxMpWidgetForCallback) {
|
||
const prevMaxMpCallback = maxMpWidgetForCallback.callback;
|
||
maxMpWidgetForCallback.callback = function () {
|
||
const r = prevMaxMpCallback?.apply(this, arguments);
|
||
node.setDirtyCanvas?.(true, true);
|
||
editorWidget.triggerDraw?.();
|
||
return r;
|
||
};
|
||
}
|
||
|
||
// Path typed / changed → clear crop and reload preview
|
||
if (imageWidget) {
|
||
const prevCallback = imageWidget.callback;
|
||
imageWidget.callback = function () {
|
||
const r = prevCallback?.apply(this, arguments);
|
||
state.rect = null;
|
||
syncCrop();
|
||
loadImageFromPath();
|
||
return r;
|
||
};
|
||
}
|
||
|
||
// Workflow load restores crop + image without widget callbacks
|
||
const prevOnConfigure = node.onConfigure;
|
||
node.onConfigure = function () {
|
||
const r = prevOnConfigure?.apply(this, arguments);
|
||
try {
|
||
const saved = cropWidget?.value ? JSON.parse(cropWidget.value) : null;
|
||
state.rect = saved && saved.w > 0 && saved.h > 0 ? saved : null;
|
||
} catch (e) {
|
||
state.rect = null;
|
||
}
|
||
loadImageFromPath();
|
||
return r;
|
||
};
|
||
|
||
loadImageFromPath();
|
||
return result;
|
||
};
|
||
},
|
||
});
|