Revert image drag-and-drop override

This commit is contained in:
filliptm
2026-08-01 10:58:28 -07:00
parent f561e4a9fd
commit beb32a0750
3 changed files with 1 additions and 873 deletions
+1 -13
View File
@@ -1,5 +1,5 @@
# 🏵️ Fill-Nodes
#Fix image and workflow drag-and-drop on newer ComfyUI frontend#
If you enjoy this project, consider supporting me on Patreon!
<p align="left">
<a href="https://www.patreon.com/c/Machinedelusions">
@@ -572,17 +572,5 @@ Fill-Nodes is a versatile collection of custom nodes for ComfyUI that extends fu
| `FL_KartelJobOutput` | POSTs an image batch back to a Kartel callback URL as multipart PNGs with a JSON metadata field (job_id, user_id, user_email, app_name). Skips the POST silently when no callback URL is provided or the input is a 1×1 placeholder. |
</details>
### Drag and Drop Support
Fill Nodes includes a compatibility fix for drag-and-drop behavior on newer ComfyUI frontend versions.
Supported drop actions:
- Drop an image directly onto a `LoadImage` or `LoadImageMask` node
- Drop an image onto empty canvas space to create a new `LoadImage` node
- Drop JSON workflow files onto empty canvas space
- Drop PNG/WebP/AVIF images with embedded workflow metadata
- Drop MP4/MOV/M4V files with embedded workflow metadata
The workflow loader also cleans up some stale preview references, such as temporary rgthree comparison images and duplicated media subfolder paths, before loading the graph.
---
-181
View File
@@ -1,188 +1,7 @@
import logging
import json
import struct
from io import BytesIO
from aiohttp import web
from PIL import Image
import server
logger = logging.getLogger("fl_fill_nodes")
def _fl_is_api_json(data):
return isinstance(data, dict) and data and all(
isinstance(v, dict) and "class_type" in v for v in data.values()
)
def _fl_decode_jsonish(value):
if isinstance(value, (dict, list)):
return value
if isinstance(value, bytes):
value = value.decode("utf-8", errors="ignore")
if isinstance(value, str):
return json.loads(value)
return None
def _fl_metadata_from_json(data):
parsed = _fl_decode_jsonish(data)
if not isinstance(parsed, dict):
return {}
if any(k in parsed for k in ("workflow", "Workflow", "prompt", "Prompt", "parameters", "templates")):
return {
"workflow": parsed.get("workflow", parsed.get("Workflow")),
"prompt": parsed.get("prompt", parsed.get("Prompt")),
"parameters": parsed.get("parameters"),
"templates": parsed.get("templates"),
}
if parsed.get("templates"):
return {"templates": parsed["templates"]}
if _fl_is_api_json(parsed):
return {"prompt": parsed}
return {"workflow": parsed}
def _fl_metadata_from_image(data):
try:
image = Image.open(BytesIO(data))
info = dict(image.info or {})
except Exception:
return {}
result = {}
for source_key, target_key in (
("workflow", "workflow"),
("Workflow", "workflow"),
("prompt", "prompt"),
("Prompt", "prompt"),
("parameters", "parameters"),
("parametersText", "parameters"),
("templates", "templates"),
):
if source_key not in info:
continue
value = info[source_key]
if isinstance(value, bytes):
value = value.decode("utf-8", errors="ignore")
result[target_key] = value
return result
def _fl_read_u32(data, pos):
if pos + 4 > len(data):
return 0
return struct.unpack(">I", data[pos:pos + 4])[0]
def _fl_find_box(data, start, end, box_type):
pos = start
marker = box_type.encode("ascii")
while pos < end - 8:
size = _fl_read_u32(data, pos)
if size < 8:
pos += 1
continue
if data[pos + 4:pos + 8] == marker:
return pos + 8, min(pos + size, len(data))
if pos + size > end:
return None
pos += size
return None
def _fl_extract_json_from_range(data, start, end):
json_start = data.find(b"{", start, end)
if json_start < 0:
return None
try:
return json.loads(data[json_start:end].decode("utf-8", errors="ignore"))
except Exception:
return None
def _fl_parse_keys_box(data, start, end):
keys = {}
pos = start + 4
if pos + 4 > end:
return keys
count = _fl_read_u32(data, pos)
pos += 4
for index in range(1, count + 1):
if pos + 8 > end:
break
key_size = _fl_read_u32(data, pos)
key_name_start = pos + 8
key_name_end = pos + key_size
if key_size < 8 or key_name_end > end:
break
keys[index] = data[key_name_start:key_name_end].decode("utf-8", errors="ignore")
pos = key_name_end
return keys
def _fl_metadata_from_isobmff(data):
data = data[:64 * 1024 * 1024]
udta = _fl_find_box(data, 0, len(data), "udta")
if not udta:
moov = _fl_find_box(data, 0, len(data), "moov")
if moov:
udta = _fl_find_box(data, moov[0], moov[1], "udta")
if not udta:
return {}
meta = _fl_find_box(data, udta[0], udta[1], "meta")
if not meta:
return {}
meta_start = meta[0] + 4
keys_box = _fl_find_box(data, meta_start, meta[1], "keys")
ilst_box = _fl_find_box(data, meta_start, meta[1], "ilst")
if not keys_box or not ilst_box:
return {}
keys = _fl_parse_keys_box(data, keys_box[0], keys_box[1])
result = {}
pos = ilst_box[0]
while pos < ilst_box[1] - 8:
item_size = _fl_read_u32(data, pos)
if item_size <= 8 or pos + item_size > ilst_box[1]:
break
index = _fl_read_u32(data, pos + 4)
key = (keys.get(index) or "").lower()
data_box = _fl_find_box(data, pos + 8, pos + item_size, "data")
if data_box and key in ("workflow", "prompt"):
value = _fl_extract_json_from_range(data, data_box[0] + 8, data_box[1])
if value is not None:
result[key] = value
pos += item_size
return result
@server.PromptServer.instance.routes.post("/load_image_drop_fix/workflow_metadata")
async def _fl_load_image_drop_fix_workflow_metadata(request):
post = await request.post()
upload = post.get("file") or post.get("image")
if upload is None or not hasattr(upload, "file"):
return web.json_response({}, status=400)
filename = (getattr(upload, "filename", "") or "").lower()
data = upload.file.read()
try:
if filename.endswith(".json"):
metadata = _fl_metadata_from_json(data)
elif filename.endswith((".png", ".webp", ".avif")):
metadata = _fl_metadata_from_image(data)
elif filename.endswith((".mp4", ".mov", ".m4v")):
metadata = _fl_metadata_from_isobmff(data)
else:
metadata = {}
except Exception as exc:
logger.warning("Load image drop metadata parse failed: %s", exc)
metadata = {}
return web.json_response(metadata)
# AI NODES
from .nodes.ai.FL_Fal_Gemini_ImageEdit import FL_Fal_Gemini_ImageEdit
from .nodes.ai.FL_Fal_GPTImage2_Edit import FL_Fal_GPTImage2_Edit
-679
View File
@@ -1,679 +0,0 @@
import { app } from "/scripts/app.js";
import {
getAvifMetadata,
getPngMetadata,
getWebpMetadata,
} from "/scripts/pnginfo.js";
app.registerExtension({
name: "Local.LoadImageDropFix.FillNodesPath",
setup() {
console.warn("[LoadImageDropFix] extension loaded v2026-07-20-workflow-sanitize");
let lastDragOverLogKey = null;
let patchedDragOverNode = null;
let lastPatchedDrag = null;
const findNodeById = (graph, id) => {
if (!graph || id == null) return null;
const node = graph.getNodeById?.(id);
if (node) return node;
for (const subgraph of graph.subgraphs?.values?.() ?? []) {
const subgraphNode = findNodeById(subgraph, id);
if (subgraphNode) return subgraphNode;
}
return null;
};
const getNodeFromDomEvent = (event) => {
const element = event.target?.closest?.("[data-node-id]");
if (!element) return null;
return findNodeById(app.rootGraph ?? app.canvas?.graph, element.dataset.nodeId);
};
const getNodeUnderEvent = (event) => {
const domNode = getNodeFromDomEvent(event);
if (domNode) return domNode;
const canvas = app.canvas;
const graph = canvas?.graph;
if (!canvas || !graph) return null;
canvas.adjustMouseEvent?.(event);
return graph.getNodeOnPos?.(event.canvasX, event.canvasY) ?? null;
};
const isLoadImageNode = (node) => {
return node?.type === "LoadImage" || node?.type === "LoadImageMask";
};
const isImageFile = (file) => file?.type?.startsWith?.("image/");
const isWorkflowFile = (file) => {
return (
file?.type === "application/json" ||
file?.name?.toLowerCase?.().endsWith(".json")
);
};
const isWorkflowMetadataCandidate = (file) => {
const name = file?.name?.toLowerCase?.() ?? "";
return (
isWorkflowFile(file) ||
isImageFile(file) ||
file?.type === "video/mp4" ||
file?.type === "video/quicktime" ||
name.endsWith(".mp4") ||
name.endsWith(".mov") ||
name.endsWith(".m4v")
);
};
const getFiles = (event) => {
return Array.from(event.dataTransfer?.files ?? []);
};
const getItems = (event) => {
return Array.from(event.dataTransfer?.items ?? []);
};
const isDraggingFiles = (event) => {
return (
getItems(event).some((item) => item.kind === "file") ||
Array.from(event.dataTransfer?.types ?? []).includes("Files")
);
};
const isDraggingImages = (event) => {
const items = getItems(event).filter((item) => item.kind === "file");
if (!items.length) return isDraggingFiles(event);
return items.some(
(item) => !item.type || item.type.startsWith("image/"),
);
};
const hasWorkflowMetadata = async (file) => {
try {
const workflowData = await getWorkflowData(file);
return !!(
workflowData?.workflow ||
workflowData?.Workflow ||
workflowData?.prompt ||
workflowData?.Prompt ||
workflowData?.parameters ||
workflowData?.templates
);
} catch (error) {
console.warn("[LoadImageDropFix] workflow metadata check failed", error);
return false;
}
};
const getImageMetadata = async (file) => {
let metadata = {};
try {
if (file.type === "image/png") {
metadata = await getPngMetadata(file);
} else if (file.type === "image/webp") {
metadata = await getWebpMetadata(file);
} else if (file.type === "image/avif") {
metadata = await getAvifMetadata(file);
}
} catch (error) {
console.warn("[LoadImageDropFix] metadata read failed", error);
}
return metadata;
};
const readTextFile = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result ?? ""));
reader.onerror = () => reject(reader.error ?? new Error("read failed"));
reader.onabort = () => reject(new Error("read aborted"));
reader.readAsText(file);
});
};
const getWorkflowDataFromServer = async (file) => {
if (!isWorkflowMetadataCandidate(file)) return {};
const body = new FormData();
body.append("file", file);
return await new Promise((resolve) => {
const request = new XMLHttpRequest();
request.open("POST", "/load_image_drop_fix/workflow_metadata");
request.onload = () => {
if (request.status < 200 || request.status >= 300) {
resolve({});
return;
}
try {
resolve(JSON.parse(request.responseText || "{}"));
} catch {
resolve({});
}
};
request.onerror = () => resolve({});
request.onabort = () => resolve({});
request.send(body);
});
};
const parseJson = (value) => {
if (!value) return undefined;
if (typeof value === "object") return value;
if (typeof value !== "string") return undefined;
return JSON.parse(value);
};
const cloneJsonData = (value) => {
if (!value || typeof value !== "object") return value;
try {
return structuredClone(value);
} catch {
return JSON.parse(JSON.stringify(value));
}
};
const stripPreviewUrl = (value) => {
if (typeof value !== "string") return value;
return value.includes("rgthree.compare._temp_") ? "" : value;
};
const normalizeMediaReference = (value) => {
if (!value || typeof value !== "object" || Array.isArray(value)) return 0;
let changed = 0;
const filename = value.filename;
const subfolder = value.subfolder;
if (typeof filename === "string" && typeof subfolder === "string" && subfolder) {
const normalizedSubfolder = subfolder.replace(/^\/+|\/+$/g, "");
const prefix = `${normalizedSubfolder}/`;
if (filename.startsWith(prefix)) {
value.filename = filename.slice(prefix.length);
changed++;
}
}
return changed;
};
const sanitizeWorkflowForDrop = (workflow) => {
const sanitized = cloneJsonData(workflow);
const stats = { normalizedMediaRefs: 0, clearedTempPreviews: 0 };
const walk = (value) => {
if (!value || typeof value !== "object") return value;
stats.normalizedMediaRefs += normalizeMediaReference(value);
if (Array.isArray(value)) {
for (let i = value.length - 1; i >= 0; i--) {
if (typeof value[i] === "string" && value[i].includes("rgthree.compare._temp_")) {
value.splice(i, 1);
stats.clearedTempPreviews++;
} else {
const next = walk(value[i]);
if (next !== value[i]) value[i] = next;
}
}
return value;
}
for (const [key, child] of Object.entries(value)) {
if (typeof child === "string") {
const stripped = stripPreviewUrl(child);
if (stripped !== child) {
value[key] = stripped;
stats.clearedTempPreviews++;
}
} else {
const next = walk(child);
if (next !== child) value[key] = next;
}
}
return value;
};
walk(sanitized);
for (const node of sanitized?.nodes ?? []) {
if (node?.type === "Image Comparer (rgthree)" && Array.isArray(node.widgets_values)) {
for (let i = 0; i < node.widgets_values.length; i++) {
const widgetValue = node.widgets_values[i];
const asJson = JSON.stringify(widgetValue ?? "");
if (asJson.includes("rgthree.compare._temp_")) {
node.widgets_values[i] = [];
stats.clearedTempPreviews++;
}
}
}
}
if (stats.normalizedMediaRefs || stats.clearedTempPreviews) {
console.warn("[LoadImageDropFix] sanitized dropped workflow", stats);
}
return sanitized;
};
const focusLoadedWorkflow = () => {
requestAnimationFrame(() => {
const canvas = app.canvas;
const graph = canvas?.graph ?? app.rootGraph;
const firstNode = graph?.nodes?.[0];
if (firstNode && canvas?.centerOnNode) {
canvas.centerOnNode(firstNode);
}
canvas?.setDirty?.(true, true);
});
};
const isApiJson = (data) => {
return (
data &&
typeof data === "object" &&
!Array.isArray(data) &&
Object.keys(data).length > 0 &&
Object.values(data).every((node) => node?.class_type)
);
};
const getWorkflowData = async (file) => {
const serverWorkflowData = await getWorkflowDataFromServer(file);
if (
serverWorkflowData?.workflow ||
serverWorkflowData?.prompt ||
serverWorkflowData?.parameters ||
serverWorkflowData?.templates
) {
return serverWorkflowData;
}
if (isWorkflowFile(file)) {
const data = parseJson(await readTextFile(file));
if (data?.workflow || data?.Workflow || data?.prompt || data?.Prompt) {
return {
workflow: data.workflow ?? data.Workflow,
prompt: data.prompt ?? data.Prompt,
parameters: data.parameters,
templates: data.templates,
};
}
if (data?.templates) return { templates: data.templates };
if (isApiJson(data)) return { prompt: data };
return { workflow: data };
}
if (!isImageFile(file)) return {};
const metadata = await getImageMetadata(file);
return !!(
metadata?.workflow ||
metadata?.Workflow ||
metadata?.prompt ||
metadata?.Prompt ||
metadata?.parameters ||
metadata?.parametersText ||
metadata?.templates
)
? {
workflow: metadata.workflow ?? metadata.Workflow,
prompt: metadata.prompt ?? metadata.Prompt,
parameters: metadata.parameters ?? metadata.parametersText,
templates: metadata.templates,
}
: {};
};
const hasWorkflowData = (workflowData) => {
return !!(
workflowData?.workflow ||
workflowData?.prompt ||
workflowData?.parameters ||
workflowData?.templates
);
};
const loadWorkflowFromFile = async (file, workflowData) => {
workflowData ??= await getWorkflowData(file);
const fileName = file.name.replace(/\.\w+$/, "");
if (workflowData?.templates && app.loadTemplateData) {
app.loadTemplateData({ templates: workflowData.templates });
return true;
}
if (workflowData?.workflow) {
const workflow = parseJson(workflowData.workflow);
if (workflow && typeof workflow === "object" && !Array.isArray(workflow)) {
const sanitizedWorkflow = sanitizeWorkflowForDrop(workflow);
console.warn("[LoadImageDropFix] workflow data parsed", {
file: file.name,
nodes: sanitizedWorkflow.nodes?.length ?? 0,
version: sanitizedWorkflow.version,
});
await app.loadGraphData(sanitizedWorkflow, true, true, fileName, {
openSource: "file_drop",
deferWarnings: true,
skipAssetScans: true,
silentAssetErrors: true,
});
console.warn("[LoadImageDropFix] workflow loaded", file.name);
focusLoadedWorkflow();
return true;
}
}
if (workflowData?.prompt && app.loadApiJson) {
const prompt = parseJson(workflowData.prompt);
if (prompt) {
console.warn("[LoadImageDropFix] api json parsed", {
file: file.name,
nodes: Object.keys(prompt).length,
});
await app.loadApiJson(prompt, fileName);
console.warn("[LoadImageDropFix] api json loaded", file.name);
focusLoadedWorkflow();
return true;
}
}
if (workflowData?.prompt) {
console.warn("[LoadImageDropFix] api json fallback to native loader", file.name);
await app.handleFile(file, "file_drop", { deferWarnings: true });
focusLoadedWorkflow();
return true;
}
if (workflowData?.parameters) {
await app.handleFile(file, "file_drop", { deferWarnings: true });
return true;
}
return false;
};
const handleWorkflowFile = async (file, workflowData) => {
try {
if (await loadWorkflowFromFile(file, workflowData)) return true;
} catch (error) {
console.warn("[LoadImageDropFix] direct workflow load failed", error);
}
const originalLoadGraphData = app.loadGraphData;
try {
app.loadGraphData = function (
graphData,
clean,
restoreView,
workflow,
options = {},
) {
return originalLoadGraphData.call(
this,
graphData,
clean,
restoreView,
workflow,
{
...options,
deferWarnings: true,
skipAssetScans: true,
silentAssetErrors: true,
},
);
};
await app.handleFile(file, "file_drop", { deferWarnings: true });
return true;
} catch (error) {
console.warn("[LoadImageDropFix] native workflow load failed", error);
return false;
} finally {
app.loadGraphData = originalLoadGraphData;
}
};
const uploadImage = async (file) => {
const body = new FormData();
body.append("image", file);
body.append("type", "input");
body.append("overwrite", "true");
const data = await new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("POST", "/upload/image");
request.onload = () => {
if (request.status < 200 || request.status >= 300) {
reject(new Error(`${request.status} ${request.statusText}`));
return;
}
try {
resolve(JSON.parse(request.responseText));
} catch (error) {
reject(error);
}
};
request.onerror = () => reject(new Error("image upload failed"));
request.onabort = () => reject(new Error("image upload aborted"));
request.send(body);
});
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name;
};
const addToComboValues = (widget, value) => {
const values = widget?.options?.values;
if (!Array.isArray(values)) return;
const valuesToAdd = Array.isArray(value) ? value : [value];
for (const item of valuesToAdd) {
if (!values.includes(item)) values.push(item);
}
};
const setNodeImage = (node, value) => {
const widget = node?.widgets?.find?.((widget) => widget.name === "image");
if (!widget) {
throw new Error(`LoadImage node ${node?.id ?? ""} has no image widget`);
}
const oldValue = widget.value;
addToComboValues(widget, value);
widget.value = value;
widget.callback?.(value);
node.onWidgetChanged?.(widget.name, value, oldValue, widget);
node.imgs = undefined;
node.graph?.setDirtyCanvas?.(true, true);
app.canvas?.setDirty?.(true, true);
};
const createLoadImageNode = (event) => {
const liteGraph = window.LiteGraph;
const graph = app.canvas?.graph ?? app.rootGraph;
if (!liteGraph?.createNode || !graph?.add) {
throw new Error("LiteGraph is not ready");
}
app.canvas?.adjustMouseEvent?.(event);
const node = liteGraph.createNode("LoadImage");
if (!node) throw new Error("Failed to create LoadImage node");
node.pos = [
event.canvasX ?? app.canvas?.graph_mouse?.[0] ?? 0,
event.canvasY ?? app.canvas?.graph_mouse?.[1] ?? 0,
];
graph.add(node);
return node;
};
const uploadImagesToNode = async (files, node, event) => {
const imageFiles = files.filter(isImageFile);
if (!imageFiles.length) return false;
node ??= createLoadImageNode(event);
node.isUploading = true;
try {
node.imgs = undefined;
node.graph?.setDirtyCanvas?.(true, true);
const paths = [];
for (const file of imageFiles) {
paths.push(await uploadImage(file));
}
setNodeImage(node, paths[0]);
return true;
} finally {
node.isUploading = false;
node.graph?.setDirtyCanvas?.(true, true);
}
};
const clearPatchedDragOverNode = () => {
if (patchedDragOverNode && app.dragOverNode === patchedDragOverNode) {
app.dragOverNode = null;
}
patchedDragOverNode = null;
lastPatchedDrag = null;
};
const getDropNode = (event) => {
const node = getNodeUnderEvent(event);
if (isLoadImageNode(node)) return node;
if (!patchedDragOverNode || !lastPatchedDrag) return null;
const elapsed = performance.now() - lastPatchedDrag.time;
const distance = Math.hypot(
event.clientX - lastPatchedDrag.clientX,
event.clientY - lastPatchedDrag.clientY,
);
if (elapsed < 1000 && distance < 48) return patchedDragOverNode;
return null;
};
const handleDragOver = (event) => {
const node = getNodeUnderEvent(event);
const canDrop =
(isLoadImageNode(node) && isDraggingImages(event)) ||
(!node && isDraggingFiles(event));
if (isLoadImageNode(node)) {
const key = `${node.id}:${canDrop}`;
if (key !== lastDragOverLogKey) {
lastDragOverLogKey = key;
console.warn("[LoadImageDropFix] dragover LoadImage", {
id: node.id,
canDrop,
});
}
}
if (!canDrop) {
clearPatchedDragOverNode();
return;
}
patchedDragOverNode = node;
lastPatchedDrag = {
clientX: event.clientX,
clientY: event.clientY,
time: performance.now(),
};
app.dragOverNode = node;
event.preventDefault();
requestAnimationFrame(() => app.canvas?.setDirty?.(false, true));
};
const handleDrop = async (event) => {
const node = getDropNode(event);
const files = getFiles(event);
if (!files.length) {
clearPatchedDragOverNode();
return;
}
if (isLoadImageNode(node)) {
event.preventDefault();
event.stopPropagation();
try {
console.warn("[LoadImageDropFix] upload image to node", node.id);
await uploadImagesToNode(files, node, event);
} finally {
clearPatchedDragOverNode();
app.canvas?.setDirty?.(false, true);
}
return;
}
if (node) {
clearPatchedDragOverNode();
return;
}
event.preventDefault();
event.stopPropagation();
try {
app.canvas?.adjustMouseEvent?.(event);
if (app.canvas?.graph_mouse) {
app.canvas.graph_mouse[0] = event.canvasX;
app.canvas.graph_mouse[1] = event.canvasY;
}
for (const file of files) {
const workflowData = isWorkflowMetadataCandidate(file)
? await getWorkflowData(file)
: {};
if (hasWorkflowData(workflowData)) {
console.warn("[LoadImageDropFix] load workflow from drop", file.name);
await handleWorkflowFile(file, workflowData);
} else if (isImageFile(file)) {
console.warn("[LoadImageDropFix] create LoadImage from drop", file.name);
await uploadImagesToNode([file], createLoadImageNode(event), event);
} else {
await handleWorkflowFile(file, workflowData);
}
}
} catch (error) {
console.warn("[LoadImageDropFix] drop handling failed", error);
for (const file of files.filter(isImageFile)) {
try {
await uploadImagesToNode([file], createLoadImageNode(event), event);
} catch (uploadError) {
console.warn("[LoadImageDropFix] image fallback failed", uploadError);
}
}
} finally {
clearPatchedDragOverNode();
app.canvas?.setDirty?.(true, true);
}
};
document.addEventListener("dragover", handleDragOver, true);
document.addEventListener(
"drop",
(event) => {
handleDrop(event).catch((error) => {
console.warn("[LoadImageDropFix] unhandled drop failure", error);
clearPatchedDragOverNode();
app.canvas?.setDirty?.(true, true);
});
},
true,
);
},
});