Fix: Image drag and drop

This commit is contained in:
constantine
2026-07-21 00:17:17 +09:00
parent d2453f02a1
commit 6910a2b938
2 changed files with 860 additions and 0 deletions
+181
View File
@@ -1,7 +1,188 @@
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
@@ -0,0 +1,679 @@
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,
);
},
});