396 lines
13 KiB
TypeScript
396 lines
13 KiB
TypeScript
import {
|
|
comboHasAnyAudioValue,
|
|
comboHasAnyImageValue,
|
|
comboHasAnyModel3DValue,
|
|
comboHasAnyVideoValue,
|
|
looksLikeAudioPath,
|
|
looksLikeImagePath,
|
|
looksLikeModel3DPath,
|
|
looksLikeVideoPath,
|
|
} from "../utils/video.js";
|
|
|
|
const highlightState = new WeakMap();
|
|
|
|
const getHighlightState = (app: any) => {
|
|
if (!app || typeof app !== "object") {
|
|
return { node: null, prev: null };
|
|
}
|
|
let state = highlightState.get(app);
|
|
if (!state) {
|
|
state = { node: null, prev: null };
|
|
highlightState.set(app, state);
|
|
}
|
|
return state;
|
|
};
|
|
|
|
export const getNodeUnderClientXY = (app: any, clientX: any, clientY: any) => {
|
|
const canvasEl = app?.canvas?.canvas || document.querySelector("canvas");
|
|
const graph = app?.canvas?.graph;
|
|
const ds = app?.canvas?.ds;
|
|
if (!canvasEl || !graph || !ds) return null;
|
|
|
|
const rect = canvasEl.getBoundingClientRect();
|
|
if (
|
|
clientX < rect.left ||
|
|
clientX > rect.right ||
|
|
clientY < rect.top ||
|
|
clientY > rect.bottom
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const scale = Number(ds.scale) || 1;
|
|
const off = ds.offset || [0, 0];
|
|
const offX = Array.isArray(off) ? Number(off[0]) || 0 : Number(off?.x) || 0;
|
|
const offY = Array.isArray(off) ? Number(off[1]) || 0 : Number(off?.y) || 0;
|
|
const x = (clientX - rect.left) / scale - offX;
|
|
const y = (clientY - rect.top) / scale - offY;
|
|
|
|
if (typeof graph.getNodeOnPos === "function") {
|
|
return graph.getNodeOnPos(x, y);
|
|
}
|
|
|
|
const nodes = graph._nodes || [];
|
|
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
const n = nodes[i];
|
|
if (!n?.pos || !n?.size) continue;
|
|
const width = n.size[0];
|
|
let height = n.size[1];
|
|
if (n.flags && n.flags.collapsed) {
|
|
height = 30;
|
|
}
|
|
if (x >= n.pos[0] && y >= n.pos[1] && x <= n.pos[0] + width && y <= n.pos[1] + height) {
|
|
return n;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
export const applyHighlight = (app: any, node: any, markCanvasDirty: any) => {
|
|
const state = getHighlightState(app);
|
|
if (!node || state.node === node) return;
|
|
clearHighlight(app, markCanvasDirty);
|
|
state.node = node;
|
|
state.prev = { color: node.color, bgcolor: node.bgcolor };
|
|
node.bgcolor = "#3355ff";
|
|
node.color = "#a9c4ff";
|
|
markCanvasDirty(app);
|
|
};
|
|
|
|
export const clearHighlight = (app: any, markCanvasDirty: any) => {
|
|
const state = getHighlightState(app);
|
|
if (!state.node) return;
|
|
try {
|
|
if (state.prev) {
|
|
state.node.color = state.prev.color;
|
|
state.node.bgcolor = state.prev.bgcolor;
|
|
}
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
state.node = null;
|
|
state.prev = null;
|
|
markCanvasDirty(app);
|
|
};
|
|
|
|
export const ensureComboHasValue = (widget: any, value: any) => {
|
|
if (!widget || widget.type !== "combo" || !widget.options) return;
|
|
let vals = widget.options.values;
|
|
let target = widget.options;
|
|
|
|
if (typeof vals === "function") {
|
|
return;
|
|
}
|
|
|
|
if (!Array.isArray(vals)) {
|
|
if (vals && typeof vals === "object" && Array.isArray(vals.values)) {
|
|
target = vals;
|
|
vals = vals.values;
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const has = vals.some((v: any) => {
|
|
if (typeof v === "string") return v === value;
|
|
return (v?.content ?? v?.value ?? v?.text) === value;
|
|
});
|
|
if (has) return;
|
|
|
|
if (vals.length === 0 || typeof vals[0] === "string") {
|
|
vals.unshift(value);
|
|
} else {
|
|
vals.unshift({ content: value, text: value, value });
|
|
}
|
|
target.values = vals;
|
|
};
|
|
|
|
// Widget types that can never hold a file path
|
|
const _REJECT_TYPES = new Set([
|
|
"number",
|
|
"int",
|
|
"float",
|
|
"boolean",
|
|
"toggle",
|
|
"checkbox",
|
|
"button",
|
|
"hidden",
|
|
]);
|
|
// Terms that indicate an output/save widget (strong negative signal)
|
|
const _OUTPUTY_TERMS = ["output", "save", "export", "folder", "dir"];
|
|
// Generic path-like widget name terms
|
|
const _PATH_TERMS = ["file", "path"];
|
|
// Terms that suggest a file-path input
|
|
const _PATH_HINT_TERMS = ["path", "file", "input", "src", "source"];
|
|
|
|
/**
|
|
* Generic scored widget picker.
|
|
* @param {object} node - LiteGraph node
|
|
* @param {string} droppedExt - Extension of the dropped file (without leading dot)
|
|
* @param {object} cfg
|
|
* @param {Set<string>} cfg.exactNames - Widget names that match perfectly (+100)
|
|
* @param {string[]} cfg.knownNodeIncludes - Node-type substrings that flag a known loader
|
|
* @param {string[]} cfg.mediaTerms - Media-specific widget name terms
|
|
* @param {Array<{terms:string[], score:number}>} cfg.extraTerms - Extra scoring groups
|
|
* @param {Set<string>} cfg.exactSingleNames - Names with value-conditional scoring
|
|
* @param {Function} cfg.looksLikeFn - (value, ext) → bool
|
|
* @param {Function} cfg.comboChecker - (widget, ext) → bool
|
|
* @param {string} cfg.scoreKey - Debug property attached to the winning widget
|
|
*/
|
|
const _pickBestPathWidget = (node: any, droppedExt: any, cfg: any) => {
|
|
const widgets = node?.widgets;
|
|
if (!Array.isArray(widgets) || !widgets.length) return null;
|
|
|
|
const ext = String(droppedExt || "")
|
|
.toLowerCase()
|
|
.replace(/^\./, "");
|
|
const nodeType = String(node?.type || "").toLowerCase();
|
|
const isKnownNode = cfg.knownNodeIncludes.some((p: any) => nodeType.includes(p));
|
|
|
|
const candidates = [];
|
|
for (const w of widgets) {
|
|
if (!w) continue;
|
|
const type = String(w?.type || "").toLowerCase();
|
|
const value = w?.value;
|
|
|
|
if (_REJECT_TYPES.has(type)) continue;
|
|
if (typeof value === "number" || typeof value === "boolean") continue;
|
|
|
|
const stringLikeByType = type === "text" || type === "string" || type === "combo";
|
|
const stringLikeByCallback = typeof w?.callback === "function" && typeof value === "string";
|
|
if (!stringLikeByType && !stringLikeByCallback) continue;
|
|
|
|
const name = String(w?.name || w?.label || "")
|
|
.toLowerCase()
|
|
.trim();
|
|
|
|
let score = 0;
|
|
if (cfg.exactNames.has(name)) score += 100;
|
|
|
|
if (name === "file" && isKnownNode && type === "combo" && cfg.comboChecker(w, ext)) {
|
|
score += 100;
|
|
}
|
|
|
|
const hasMediaHint = cfg.mediaTerms.some((t: any) => name.includes(t));
|
|
const hasPathHint = _PATH_HINT_TERMS.some((t: any) => name.includes(t));
|
|
if (hasMediaHint && hasPathHint) score += 80;
|
|
if (_PATH_TERMS.some((t: any) => name.includes(t))) score += 35;
|
|
|
|
for (const { terms, score: pts } of cfg.extraTerms) {
|
|
if (terms.some((t: any) => name.includes(t))) score += pts;
|
|
}
|
|
|
|
if (_OUTPUTY_TERMS.some((t: any) => name.includes(t))) score -= 90;
|
|
|
|
if (cfg.exactSingleNames.has(name)) {
|
|
const empty = typeof value === "string" && value.trim() === "";
|
|
if (empty) score += 25;
|
|
else if (cfg.looksLikeFn(value, ext)) score += 25;
|
|
else score -= 10;
|
|
}
|
|
|
|
if (isKnownNode) score += 15;
|
|
const emptyValue = typeof value === "string" && value.trim() === "";
|
|
if (emptyValue) score += 3;
|
|
if (type === "combo" && cfg.comboChecker(w, ext)) score += 12;
|
|
|
|
candidates.push({ w, score, emptyValue, combo: type === "combo" });
|
|
}
|
|
|
|
if (!candidates.length) return null;
|
|
candidates.sort((a: any, b: any) => {
|
|
if (b.score !== a.score) return b.score - a.score;
|
|
if (b.emptyValue !== a.emptyValue) return b.emptyValue ? 1 : -1;
|
|
if (b.combo !== a.combo) return b.combo ? 1 : -1;
|
|
return 0;
|
|
});
|
|
|
|
const best = candidates[0];
|
|
if (!best || best.score < 20) return null;
|
|
try {
|
|
best.w[cfg.scoreKey] = best.score;
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
return best.w;
|
|
};
|
|
|
|
const _VIDEO_CFG = {
|
|
exactNames: new Set([
|
|
"video_path", "input_video", "source_video", "video", "driven_video", "footage",
|
|
"input_path", "directory", "folder_path", "folder", "path", "video_directory"
|
|
]),
|
|
knownNodeIncludes: [
|
|
"loadvideo", "vhs_loadvideo", "videoloader", "sadtalker", "wav2lip", "reactor",
|
|
"multiimageloader", "ltxdirector", "ltxsequencer", "ltxkeyframer"
|
|
],
|
|
mediaTerms: ["video", "footage", "clip", "movie"],
|
|
extraTerms: [{ terms: ["media", "clip", "footage", "drive"], score: 45 }],
|
|
exactSingleNames: new Set(["video"]),
|
|
looksLikeFn: looksLikeVideoPath,
|
|
comboChecker: comboHasAnyVideoValue,
|
|
scoreKey: "__mjrVideoPickScore",
|
|
};
|
|
|
|
const _IMAGE_CFG = {
|
|
exactNames: new Set([
|
|
"image", "image_path", "input_image", "source_image", "ref_image", "pose_image",
|
|
"hint_image", "target_image", "ipadapter_image", "input_path", "directory",
|
|
"folder_path", "folder", "path", "image_directory"
|
|
]),
|
|
knownNodeIncludes: [
|
|
"loadimage", "loadimagemask", "imageloader", "reactor", "roop", "ipadapter",
|
|
"controlnet", "instantid", "pulid", "multiimageloader", "ltxdirector",
|
|
"ltxsequencer", "ltxkeyframer"
|
|
],
|
|
mediaTerms: ["image", "img", "mask", "frame", "photo", "picture", "face", "ipadapter"],
|
|
extraTerms: [{ terms: ["media", "source", "first", "last", "target", "reference"], score: 35 }],
|
|
exactSingleNames: new Set(["image", "face"]),
|
|
looksLikeFn: looksLikeImagePath,
|
|
comboChecker: comboHasAnyImageValue,
|
|
scoreKey: "__mjrImagePickScore",
|
|
};
|
|
|
|
const _AUDIO_CFG = {
|
|
exactNames: new Set([
|
|
"audio_path", "input_audio", "source_audio", "audio", "driven_audio", "voice",
|
|
"bgm", "soundtrack", "input_path", "directory", "folder_path", "folder",
|
|
"path", "audio_directory"
|
|
]),
|
|
knownNodeIncludes: [
|
|
"loadaudio",
|
|
"vhs_loadaudioupload",
|
|
"vhs_loadaudio",
|
|
"audioloader",
|
|
"inputaudio",
|
|
"sadtalker",
|
|
"wav2lip",
|
|
"multiimageloader",
|
|
"ltxdirector",
|
|
"ltxsequencer",
|
|
"ltxkeyframer"
|
|
],
|
|
mediaTerms: ["audio", "sound", "music", "voice", "speech", "wav", "mp3"],
|
|
extraTerms: [{ terms: ["media", "track", "drive"], score: 45 }],
|
|
exactSingleNames: new Set(["audio", "voice"]),
|
|
looksLikeFn: looksLikeAudioPath,
|
|
comboChecker: comboHasAnyAudioValue,
|
|
scoreKey: "__mjrAudioPickScore",
|
|
};
|
|
|
|
const _MODEL3D_CFG = {
|
|
exactNames: new Set([
|
|
"model_path",
|
|
"input_model",
|
|
"source_model",
|
|
"mesh_path",
|
|
"input_mesh",
|
|
"geometry_path",
|
|
"scene_path",
|
|
"point_cloud_path",
|
|
"splat_path",
|
|
"model",
|
|
"mesh",
|
|
"geometry",
|
|
"input_path",
|
|
"directory",
|
|
"folder_path",
|
|
"folder",
|
|
"path",
|
|
"model_directory"
|
|
]),
|
|
knownNodeIncludes: [
|
|
"load3d",
|
|
"loadmodel",
|
|
"loadmesh",
|
|
"loadobj",
|
|
"loadgltf",
|
|
"loadglb",
|
|
"loadstl",
|
|
"loadply",
|
|
"pointcloud",
|
|
"meshloader",
|
|
"modelloader",
|
|
"tripo3d",
|
|
"unique3d",
|
|
"multiimageloader",
|
|
"ltxdirector",
|
|
"ltxsequencer",
|
|
"ltxkeyframer"
|
|
],
|
|
mediaTerms: ["model", "mesh", "geometry", "scene", "object", "point", "cloud", "splat"],
|
|
extraTerms: [{ terms: ["asset", "resource"], score: 30 }],
|
|
exactSingleNames: new Set(["model", "mesh", "geometry"]),
|
|
looksLikeFn: looksLikeModel3DPath,
|
|
comboChecker: comboHasAnyModel3DValue,
|
|
scoreKey: "__mjrModel3DPickScore",
|
|
};
|
|
|
|
export const pickBestMediaPathWidget = (node: any, payload: any, droppedExt: any) => {
|
|
const kind = String(payload?.kind || "").toLowerCase();
|
|
const cfg =
|
|
kind === "model3d"
|
|
? _MODEL3D_CFG
|
|
: kind === "audio"
|
|
? _AUDIO_CFG
|
|
: kind === "image"
|
|
? _IMAGE_CFG
|
|
: _VIDEO_CFG;
|
|
return _pickBestPathWidget(node, droppedExt, cfg);
|
|
};
|
|
|
|
export const getInputSlotUnderClientXY = (app: any, node: any, clientX: any, clientY: any) => {
|
|
if (!node || !node.inputs || !node.inputs.length) return null;
|
|
const canvasEl = app?.canvas?.canvas || document.querySelector("canvas");
|
|
const ds = app?.canvas?.ds;
|
|
if (!canvasEl || !ds) return null;
|
|
|
|
const rect = canvasEl.getBoundingClientRect();
|
|
const scale = Number(ds.scale) || 1;
|
|
const off = ds.offset || [0, 0];
|
|
const offX = Array.isArray(off) ? Number(off[0]) || 0 : Number(off?.x) || 0;
|
|
const offY = Array.isArray(off) ? Number(off[1]) || 0 : Number(off?.y) || 0;
|
|
|
|
const x = (clientX - rect.left) / scale - offX;
|
|
const y = (clientY - rect.top) / scale - offY;
|
|
|
|
const nodeX = node.pos[0];
|
|
const nodeY = node.pos[1];
|
|
|
|
const titleHeight = node.constructor.title_height || 30;
|
|
const slotHeight = 20;
|
|
|
|
for (let i = 0; i < node.inputs.length; i++) {
|
|
const input = node.inputs[i];
|
|
const slotY = nodeY + titleHeight + i * slotHeight + slotHeight / 2;
|
|
|
|
const dx = x - nodeX;
|
|
const dy = y - slotY;
|
|
|
|
if (Math.abs(dx) <= 18 && Math.abs(dy) <= 12) {
|
|
return { index: i, input };
|
|
}
|
|
}
|
|
return null;
|
|
};
|