Files

291 lines
17 KiB
JavaScript

import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { InlineMaskEditor } from "./editor.js?v=stability1";
import { installInpaintWorkbench } from "./inpaint_node.js?v=2.0.0";
import { bindWidgetToNodeWidth } from "./widget_layout.js?v=0.5.5";
import { routeWheelOutsideImage } from "./wheel_routing.js";
import { InlineCropController } from "./inline_crop.js?v=stability1";
import { canStoreDraft, prepareInlineDraft, syncInlineWidgets } from "./draft_guard.js?v=0.6.3";
import { T } from "./i18n.js";
import { removeLegacyFeedback } from "./legacy_feedback.js?v=0.6.4";
import { uploadImageWithProgress } from "./upload_transport.js?v=stability1";
const styleId = "adachi-inpaint-style";
const stylesheet = document.getElementById(styleId) || document.createElement("link");
stylesheet.id = styleId; stylesheet.rel = "stylesheet";
stylesheet.href = new URL("./editor.css?v=workspace-network2", import.meta.url).href;
if (!stylesheet.isConnected) document.head.append(stylesheet);
function hide(widget) {
if (!widget) return;
widget.type = "hidden";
widget.hidden = true;
// Nodes 2.0 keeps the original options object in its widget value store.
// Mutate that object so the hidden rows disappear in both renderers.
widget.options ??= {};
widget.options.hidden = true;
widget.computeSize = () => [0, -4];
const element = widget.element || widget.inputEl;
if (element) element.style.display = "none";
}
function imageUrl(value) {
let name = String(value || "");
let type = "input";
const match = name.match(/\s*\[(input|output|temp)\]$/);
if (match) { type = match[1]; name = name.slice(0, match.index); }
name = name.replaceAll("\\", "/");
const split = name.lastIndexOf("/");
const params = new URLSearchParams({ filename: name.slice(split + 1), subfolder: split < 0 ? "" : name.slice(0, split), type });
return api.apiURL(`/view?${params}`);
}
function draftOptions() {
let workspace = "personal";
try { const current = JSON.parse(sessionStorage.getItem("Comfy.Workspace.Current") || "null"); if (current?.type !== "personal" && current?.id) workspace = current.id; } catch { /* Personal workspace. */ }
return { workspace, path: app.extensionManager?.workflow?.activeWorkflow?.path || "" };
}
async function uploadEmbeddedSource(embedded) {
const blob = await (await fetch(embedded)).blob();
if (blob.size > 64 * 1024 * 1024) throw Error(T("File is too large (max 64 MB)"));
const body = new FormData();
body.append("image", blob, `source-${crypto.randomUUID()}.png`);
body.append("type", "input"); body.append("subfolder", "adachi-inpaint");
const response = await api.fetchApi("/upload/image", {method:"POST", body});
if (!response.ok) throw Error(`${T("Upload failed")} (${response.status})`);
const uploaded = await response.json();
if (typeof uploaded.name !== "string" || !uploaded.name) throw Error(T("Upload failed"));
return uploaded.subfolder ? `${uploaded.subfolder}/${uploaded.name}` : uploaded.name;
}
app.registerExtension({
name: "Adachi.InlineInpaint",
async beforeConfigureGraph(graphData) {
removeLegacyFeedback(graphData);
if (app.ui.settings.getSettingValue("Comfy.Workflow.Persist") === false) return;
try {
// The active workflow can still be the previous tab at this point;
// do not claim its replacement credit for an incoming workflow.
const options = { ...draftOptions(), path: "" };
const count = await prepareInlineDraft(graphData, {
fits: graph => canStoreDraft(graph, options), upload: uploadEmbeddedSource,
});
if (count) console.info(`[Adachi Inpaint] Saved ${count} oversized embedded source(s) as exact input files before workflow draft initialization. Masks and layers were preserved.`);
} catch (error) {
console.warn("[Adachi Inpaint] Draft preflight failed; embedded source data was retained.", error);
}
},
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name === "AdachiLayerCommit") { nodeType.skip_list = true; return; }
if (["AdachiWebUIInpaint", "AdachiInpaintPrepare"].includes(nodeData.name)) { installInpaintWorkbench(nodeType); return; }
if (nodeData.name !== "AdachiInlineMask") return;
const originalCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = originalCreated?.apply(this, arguments);
const node = this;
const markChanged = () => {
// ChangeTracker requires a balanced pair to register a DOM edit.
if (app.canvas?.emitBeforeChange && app.canvas?.emitAfterChange) {
app.canvas.emitBeforeChange();
app.canvas.emitAfterChange();
}
node.graph?.setDirtyCanvas(true, true);
};
const find = name => node.widgets?.find(widget => widget.name === name);
const imageWidget = find("image"), maskWidget = find("mask_data"), sourceWidget = find("source_data");
for (const widget of [imageWidget, maskWidget, sourceWidget]) hide(widget);
let crop;
let draftRecovery = null;
let draftNoticeTimer = 0;
let restoring = false;
let restoreFrame = 0;
let uploadVersion = 0;
let fitFrame = 0;
const fitNodeToImage = () => {
cancelAnimationFrame(fitFrame);
fitFrame = requestAnimationFrame(() => {
if (!editor.ready || editor.destroyed || !node.graph || editor.root.closest(".adachi-workspace")) return;
const width = editor.canvas.clientWidth, height = editor.canvas.clientHeight;
if (!width || !height) return;
const overhead = node.size[1] - height;
const fittedHeight = overhead + width * editor.image.height / editor.image.width;
node.setSize([node.size[0], Math.max(240, Math.min(4200, Math.ceil(fittedHeight)))]);
node.properties.adachi_compact_ui = true;
editor.schedule();
});
};
const restore = async () => {
const embedded = sourceWidget?.value;
const filename = imageWidget?.value;
if (!embedded && !filename) return;
restoring = true;
editor.embed.checked = !!embedded;
try { crop?.activate("mask"); await editor.setImage(embedded || imageUrl(filename), maskWidget?.value || "", embedded ? T("Embedded image") : filename); await crop?.restore(); }
catch (error) { console.warn("[Adachi Inpaint]", error); }
finally { restoring = false; }
};
const editor = new InlineMaskEditor({
compact: true,
onChange(data) { if (maskWidget && !restoring) maskWidget.value = data; },
onDirty() { markChanged(); },
onKeepFit(enabled) { node.properties.adachi_keep_fit = enabled; markChanged(); },
onImage() { if (!restoring || !node.properties.adachi_compact_ui) fitNodeToImage(); },
onGraphWheel: event => app.canvas?.processMouseWheel(event),
async onUpload(file, { preserveMask = false, preserveCrop = false, onProgress = null, signal } = {}) {
if (preserveMask && editor.image && !editor.ready) throw new Error(T("Wait for the current image to finish loading"));
const version = ++uploadVersion;
const body = new FormData();
body.append("image", file, file.name);
body.append("type", "input");
body.append("subfolder", "adachi-inpaint");
const uploaded = await uploadImageWithProgress(api, body, onProgress, {signal});
if (typeof uploaded?.name !== "string" || !uploaded.name) throw new Error(T("Upload failed"));
if (version !== uploadVersion || editor.destroyed) return;
signal?.throwIfAborted();
const name = uploaded.subfolder ? `${uploaded.subfolder}/${uploaded.name}` : uploaded.name;
restoring = false;
crop?.activate("mask");
let kept = false;
if (preserveMask && editor.ready) {
kept = await editor.replaceSource(imageUrl(name), file.name);
if (version !== uploadVersion || editor.destroyed) return;
// A different-sized replacement cannot reuse the old mask.
// Actually load it as a fresh image before clearing state.
if (!kept) await editor.setImage(imageUrl(name), "", file.name);
} else await editor.setImage(imageUrl(name), "", file.name);
if (version !== uploadVersion || editor.destroyed) return;
imageWidget.value = name;
maskWidget.value = kept ? editor.flush() : "";
sourceWidget.value = editor.embed.checked ? editor.sourceData() : "";
if (!preserveCrop) crop?.reset();
await beforeModeChange();
if (preserveMask && !kept) editor.notice(T("The new image has a different size, so the mask was reset"));
markChanged();
},
onEmbed(enabled) {
if (sourceWidget) sourceWidget.value = enabled ? editor.sourceData() : "";
const recovery = enabled ? beforeModeChange() : null;
if (recovery?.then) return recovery.then(() => markChanged()).catch(error => editor.status(error.message, true));
markChanged();
},
});
editor.root.dataset.adachiVersion = "0.6.7";
const beforeModeChange = () => {
if (draftRecovery) return draftRecovery;
const embedded = sourceWidget?.value;
if (!editor.ready || !embedded?.startsWith("data:") || embedded.length < 256 * 1024) return;
if (app.ui.settings.getSettingValue("Comfy.Workflow.Persist") === false) return;
const graph = app.rootGraph || node.graph;
if (canStoreDraft(graph.serialize(), draftOptions()) !== false) return;
// Persist a fresh, exact copy before dropping any embedded bytes.
// This also repairs portable workflows whose original input path is absent.
draftRecovery = (async () => {
const version = editor.loadVersion;
const name = await uploadEmbeddedSource(embedded);
if (editor.destroyed || editor.loadVersion !== version || sourceWidget.value !== embedded) return false;
imageWidget.value = name;
sourceWidget.value = ""; editor.embed.checked = false;
editor.message.textContent = T("Browser draft storage was full. The source was saved as an input file; the image, masks and layers are preserved.");
editor.message.classList.remove("dd-error"); editor.message.hidden = false;
const notice = editor.message.textContent;
clearTimeout(draftNoticeTimer);
draftNoticeTimer = setTimeout(() => { if (!editor.destroyed && editor.message.textContent === notice) editor.message.hidden = true; }, 8000);
return true;
})().finally(() => { draftRecovery = null; });
return draftRecovery;
};
crop = new InlineCropController(node, editor, {find, hide, changed:markChanged, imageUrl, beforeModeChange});
node.adachiCrop = crop;
node.adachiEditor = editor;
const stopWheelRouting = routeWheelOutsideImage(editor.root, editor.canvas, () => editor.ready);
node.adachiUseResult = async (file, origin, options={}) => {
if (editor.destroyed) throw new Error(T("The inline mask node was removed"));
if (crop.mode === "crop") return node.adachiAdoptCrop(file, origin, options);
await editor.options.onUpload(file, { preserveMask: true, signal:options.signal });
editor.view = "overlay"; editor.updateButtons(); editor.schedule();
editor.status(T("Adopted the result as the new source image; the mask and undo history were preserved"));
};
const domWidget = node.addDOMWidget("adachi_canvas", "adachi_inline_mask", editor.root, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => 170,
getMaxHeight: () => 4096,
});
domWidget.serialize = false;
bindWidgetToNodeWidth(node, domWidget, editor.root);
// Leave computeLayoutSize in charge: a fixed computeSize height
// prevents the DOM canvas from growing with the node vertically.
const resizeHandle = document.createElement("div");
resizeHandle.className = "dd-resize-handle";
resizeHandle.textContent = "↕";
resizeHandle.title = T("Drag to resize canvas height");
resizeHandle.tabIndex = 0;
resizeHandle.setAttribute("role", "separator");
resizeHandle.setAttribute("aria-orientation", "horizontal");
resizeHandle.setAttribute("aria-label", T("Resize canvas height"));
editor.root.append(resizeHandle);
let resizing = null;
const resizeHeight = height => {
node.setSize([node.size[0], Math.max(240, Math.min(4200, height))]);
node.graph?.setDirtyCanvas(true, true);
editor.schedule();
};
resizeHandle.addEventListener("pointerdown", event => {
if (event.button !== 0) return;
event.preventDefault(); event.stopPropagation();
resizing = { id: event.pointerId, y: event.clientY, height: node.size[1], scale: app.canvas?.ds?.scale || 1 };
resizeHandle.setPointerCapture(event.pointerId);
});
resizeHandle.addEventListener("pointermove", event => {
if (!resizing || event.pointerId !== resizing.id) return;
event.preventDefault(); event.stopPropagation();
resizeHeight(resizing.height + (event.clientY - resizing.y) / resizing.scale);
});
const finishResize = event => {
if (!resizing || event.pointerId !== resizing.id) return;
event.preventDefault(); event.stopPropagation();
const id = resizing.id; resizing = null;
if (resizeHandle.hasPointerCapture(id)) resizeHandle.releasePointerCapture(id);
markChanged();
};
for (const name of ["pointerup", "pointercancel", "lostpointercapture"]) resizeHandle.addEventListener(name, finishResize);
resizeHandle.addEventListener("keydown", event => {
if (!["ArrowUp", "ArrowDown"].includes(event.key)) return;
event.preventDefault(); event.stopPropagation();
resizeHeight(node.size[1] + (event.key === "ArrowDown" ? 40 : -40));
markChanged();
});
// A synchronous flush protects Run/Save while a pointer is still down.
const previousSerialize = maskWidget.serializeValue;
maskWidget.serializeValue = function () {
editor.flush();
return previousSerialize ? previousSerialize.apply(this, arguments) : maskWidget.value;
};
const previousSerializeNode = node.onSerialize;
node.onSerialize = function (data) {
const result = previousSerializeNode?.apply(this, arguments);
editor.flush();
if (data) syncInlineWidgets(data, node.widgets);
return result;
};
const previousQueued = maskWidget.beforeQueued;
maskWidget.beforeQueued = function () { editor.flush(); return previousQueued?.apply(this, arguments); };
const previousConfigure = node.onConfigure;
node.onConfigure = function () {
const configured = previousConfigure?.apply(this, arguments);
editor.setKeepFit(!!node.properties.adachi_keep_fit, false);
cancelAnimationFrame(restoreFrame); restoreFrame = requestAnimationFrame(restore);
return configured;
};
const previousImageCallback = imageWidget.callback;
imageWidget.callback = function () { previousImageCallback?.apply(this, arguments); restore(); };
const previousRemoved = node.onRemoved;
node.onRemoved = function () { node.adachiWorkspaceHistory?.clear();node.adachiWorkspaceHistory=null;clearTimeout(draftNoticeTimer); cancelAnimationFrame(restoreFrame); cancelAnimationFrame(fitFrame); stopWheelRouting(); crop.destroy(); editor.destroy(); return previousRemoved?.apply(this, arguments); };
node.setSize([Math.max(380, node.size[0]), Math.max(480, node.size[1])]);
restoreFrame = requestAnimationFrame(restore);
return result;
};
},
});