Files
ShootTheSound-comfyUI-Realt…/web/js/dynamic_inputs.js
T
2025-12-16 04:38:46 +00:00

616 lines
28 KiB
JavaScript

import { app } from "../../../scripts/app.js";
// Extension for dynamic image inputs (training nodes)
app.registerExtension({
name: "RealtimeLoraTrainer.DynamicInputs",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
// Apply to trainer nodes only
if (!["RealtimeLoraTrainer", "SDXLLoraTrainer", "SD15LoraTrainer", "MusubiZImageLoraTrainer", "MusubiQwenImageLoraTrainer", "MusubiWanLoraTrainer"].includes(nodeData.name)) {
return;
}
nodeType.prototype.onNodeCreated = function () {
// Create the button widget
const button = this.addWidget("button", "Update inputs", null, () => {
if (!this.inputs) {
this.inputs = [];
}
const target_number_of_inputs = this.widgets.find(w => w.name === "inputcount")["value"];
const num_image_inputs = this.inputs.filter(input => input.type === "IMAGE").length;
if (target_number_of_inputs === num_image_inputs) return;
if (target_number_of_inputs < num_image_inputs) {
// Remove excess inputs (from the end) - both image and caption
for (let i = num_image_inputs; i > target_number_of_inputs; i--) {
// Find and remove caption_N first, then image_N
for (let j = this.inputs.length - 1; j >= 0; j--) {
if (this.inputs[j].name === `caption_${i}`) {
this.removeInput(j);
break;
}
}
for (let j = this.inputs.length - 1; j >= 0; j--) {
if (this.inputs[j].name === `image_${i}`) {
this.removeInput(j);
break;
}
}
}
} else {
// Add new inputs interleaved (image then caption for each)
for (let i = num_image_inputs + 1; i <= target_number_of_inputs; ++i) {
this.addInput(`image_${i}`, "IMAGE");
this.addInput(`caption_${i}`, "STRING");
}
}
});
// Move button to right after inputcount widget
const inputcountIndex = this.widgets.findIndex(w => w.name === "inputcount");
if (inputcountIndex !== -1) {
// Remove button from end and insert after inputcount
this.widgets.pop();
this.widgets.splice(inputcountIndex + 1, 0, button);
}
};
}
});
// Impact score color gradient (10% ranges, blue=low to red=high)
function getImpactColor(score) {
// score is 0-100
if (score < 10) return "#0066ff"; // Deep blue
if (score < 20) return "#0088ff"; // Blue
if (score < 30) return "#00aaff"; // Light blue
if (score < 40) return "#00cccc"; // Cyan
if (score < 50) return "#00cc66"; // Teal/green
if (score < 60) return "#88cc00"; // Yellow-green
if (score < 70) return "#cccc00"; // Yellow
if (score < 80) return "#ff9900"; // Orange
if (score < 90) return "#ff6600"; // Orange-red
return "#ff3300"; // Red
}
// Get analysis data from connected analysis_json input or stored on node
function getAnalysisFromInput(node) {
// First check if this node has stored analysis data
if (node._analysisData) {
return node._analysisData;
}
if (!node.inputs) return null;
// Find the analysis_json input
const analysisInput = node.inputs.find(input => input.name === "analysis_json");
if (!analysisInput || !analysisInput.link) return null;
// Get the link and find the source node
const link = node.graph?.links?.[analysisInput.link];
if (!link) return null;
const sourceNode = node.graph?.getNodeById(link.origin_id);
if (!sourceNode) return null;
// Check if source node (analyzer) has stored analysis data
if (sourceNode._lastAnalysisData) {
return sourceNode._lastAnalysisData;
}
return null;
}
// Extension for LoRA Analyzer to store analysis data after execution
app.registerExtension({
name: "LoRAAnalyzer.StoreAnalysis",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== "LoRALoaderWithAnalysis") return;
const origOnExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function(output) {
if (origOnExecuted) {
origOnExecuted.apply(this, arguments);
}
// Store the analysis_json output for connected nodes to read
if (output && output.analysis_json && output.analysis_json[0]) {
try {
this._lastAnalysisData = JSON.parse(output.analysis_json[0]);
// Trigger redraw of connected nodes
if (this.graph) {
this.graph.setDirtyCanvas(true);
}
} catch (e) {
// Silent fail - analysis coloring is optional
}
}
};
}
});
// Preset definitions for each selective loader
const SELECTIVE_LOADER_PRESETS = {
"SDXLSelectiveLoRALoader": {
blocks: ["text_encoder_1", "text_encoder_2", "input_4", "input_5", "input_7", "input_8", "unet_mid", "output_0", "output_1", "output_2", "output_3", "output_4", "output_5", "other_weights"],
presets: {
"Default": { enabled: "ALL", strength: 1.0 },
"All Off": { enabled: [], strength: 0.0 },
"Half Strength": { enabled: "ALL", strength: 0.5 },
"UNet Only": { enabled: ["input_4", "input_5", "input_7", "input_8", "unet_mid", "output_0", "output_1", "output_2", "output_3", "output_4", "output_5", "other_weights"], strength: 1.0 },
"High Impact": { enabled: ["input_7", "input_8", "unet_mid", "output_0", "output_1", "output_2"], strength: 1.0 },
"Text Encoders Only": { enabled: ["text_encoder_1", "text_encoder_2"], strength: 1.0 },
"Decoders Only": { enabled: ["output_0", "output_1", "output_2", "output_3", "output_4", "output_5"], strength: 1.0 },
"Encoders Only": { enabled: ["input_4", "input_5", "input_7", "input_8"], strength: 1.0 },
"Style Focus": { enabled: ["output_1", "output_2"], strength: 1.0 },
"Composition Focus": { enabled: ["input_8", "unet_mid", "output_0"], strength: 1.0 },
"Face Focus": { enabled: ["input_7", "input_8", "unet_mid", "output_0", "output_1", "output_2", "output_3"], strength: 1.0 },
}
},
"ZImageSelectiveLoRALoader": {
blocks: [...Array.from({length: 30}, (_, i) => `layer_${i}`), "other_weights"],
presets: {
"Default": { enabled: "ALL", strength: 1.0 },
"All Off": { enabled: [], strength: 0.0 },
"Half Strength": { enabled: "ALL", strength: 0.5 },
"Late Only (20-29)": { enabled: [...Array.from({length: 10}, (_, i) => `layer_${i + 20}`), "other_weights"], strength: 1.0 },
"Mid-Late (15-29)": { enabled: [...Array.from({length: 15}, (_, i) => `layer_${i + 15}`), "other_weights"], strength: 1.0 },
"Skip Early (10-29)": { enabled: [...Array.from({length: 20}, (_, i) => `layer_${i + 10}`), "other_weights"], strength: 1.0 },
"Mid Only (10-19)": { enabled: Array.from({length: 10}, (_, i) => `layer_${i + 10}`), strength: 1.0 },
"Peak Impact (18-25)": { enabled: Array.from({length: 8}, (_, i) => `layer_${i + 18}`), strength: 1.0 },
}
},
"FLUXSelectiveLoRALoader": {
blocks: [
...Array.from({length: 19}, (_, i) => `double_${i}`),
...Array.from({length: 38}, (_, i) => `single_${i}`),
"other_weights"
],
presets: {
"Default": { enabled: "ALL", strength: 1.0 },
"All Off": { enabled: [], strength: 0.0 },
"Half Strength": { enabled: "ALL", strength: 0.5 },
"Double Blocks Only": { enabled: [...Array.from({length: 19}, (_, i) => `double_${i}`), "other_weights"], strength: 1.0 },
"Single Blocks Only": { enabled: [...Array.from({length: 38}, (_, i) => `single_${i}`), "other_weights"], strength: 1.0 },
"High Impact Double": { enabled: Array.from({length: 13}, (_, i) => `double_${i + 6}`), strength: 1.0 },
"Core Double": { enabled: Array.from({length: 10}, (_, i) => `double_${i + 8}`), strength: 1.0 },
"Face Focus": { enabled: ["double_7", "double_12", "double_16", "single_7", "single_12", "single_16", "single_20"], strength: 1.0 },
"Face Aggressive": { enabled: ["double_4", "double_7", "double_8", "double_12", "double_15", "double_16", "single_4", "single_7", "single_8", "single_12", "single_15", "single_16", "single_19", "single_20"], strength: 1.0 },
"Style Only (No Face)": {
enabled: [
...Array.from({length: 19}, (_, i) => `double_${i}`),
...Array.from({length: 38}, (_, i) => `single_${i}`)
].filter(b => !["double_7", "double_12", "double_16", "single_7", "single_12", "single_16", "single_20"].includes(b)),
strength: 1.0
},
}
},
"WanSelectiveLoRALoader": {
blocks: [...Array.from({length: 40}, (_, i) => `block_${i}`), "other_weights"],
presets: {
"Default": { enabled: "ALL", strength: 1.0 },
"All Off": { enabled: [], strength: 0.0 },
"Half Strength": { enabled: "ALL", strength: 0.5 },
"Late Only (30-39)": { enabled: [...Array.from({length: 10}, (_, i) => `block_${i + 30}`), "other_weights"], strength: 1.0 },
"Mid-Late (20-39)": { enabled: [...Array.from({length: 20}, (_, i) => `block_${i + 20}`), "other_weights"], strength: 1.0 },
"Skip Early (10-39)": { enabled: [...Array.from({length: 30}, (_, i) => `block_${i + 10}`), "other_weights"], strength: 1.0 },
"Mid Only (15-25)": { enabled: Array.from({length: 11}, (_, i) => `block_${i + 15}`), strength: 1.0 },
"Early Only (0-19)": { enabled: Array.from({length: 20}, (_, i) => `block_${i}`), strength: 1.0 },
}
},
"QwenSelectiveLoRALoader": {
blocks: [...Array.from({length: 60}, (_, i) => `block_${i}`), "other_weights"],
presets: {
"Default": { enabled: "ALL", strength: 1.0 },
"All Off": { enabled: [], strength: 0.0 },
"Half Strength": { enabled: "ALL", strength: 0.5 },
"Late Only (45-59)": { enabled: [...Array.from({length: 15}, (_, i) => `block_${i + 45}`), "other_weights"], strength: 1.0 },
"Mid-Late (30-59)": { enabled: [...Array.from({length: 30}, (_, i) => `block_${i + 30}`), "other_weights"], strength: 1.0 },
"Skip Early (15-59)": { enabled: [...Array.from({length: 45}, (_, i) => `block_${i + 15}`), "other_weights"], strength: 1.0 },
"Mid Only (20-40)": { enabled: Array.from({length: 21}, (_, i) => `block_${i + 20}`), strength: 1.0 },
"Early Only (0-29)": { enabled: Array.from({length: 30}, (_, i) => `block_${i}`), strength: 1.0 },
}
}
};
// Extension for combining block toggle + strength widgets (selective loaders)
app.registerExtension({
name: "SelectiveLoRA.CombinedWidgets",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
// Apply to selective loader nodes
const selectiveLoaders = [
"SDXLSelectiveLoRALoader",
"ZImageSelectiveLoRALoader",
"FLUXSelectiveLoRALoader",
"WanSelectiveLoRALoader",
"QwenSelectiveLoRALoader"
];
if (!selectiveLoaders.includes(nodeData.name)) {
return;
}
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
if (origOnNodeCreated) {
origOnNodeCreated.apply(this, arguments);
}
// Capture 'this' in closure to prevent race conditions with multiple nodes
const node = this;
// After node is created, combine toggle + strength widget pairs, add preset dropdown, set width
setTimeout(() => {
// Guard against running multiple times (e.g., when loading old workflows)
if (node._selectiveLoraInitialized) return;
node._selectiveLoraInitialized = true;
node.combineBlockWidgets();
node.addPresetWidget(nodeData.name);
// Double the default width for better slider usability
const minWidth = 500;
if (node.size[0] < minWidth) {
node.size[0] = minWidth;
node.setDirtyCanvas(true);
}
}, 50);
};
// Handle workflow restoration - fixes corrupt state when loading saved workflows
const origOnConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function(info) {
if (origOnConfigure) {
origOnConfigure.apply(this, arguments);
}
const node = this;
// Sanitize strength widget values during restoration (only if corrupt)
// ComfyUI deserializes as strings, but parseFloat handles this correctly
setTimeout(() => {
const config = SELECTIVE_LOADER_PRESETS[nodeData.name];
if (!config) return;
for (const blockName of config.blocks) {
const strWidget = node.widgets.find(w => w.name === blockName + "_str");
if (strWidget) {
// Only fix truly corrupt values (non-numeric strings)
// Preserve valid numbers including 0.0, negatives, etc.
let val = parseFloat(strWidget.value);
if (isNaN(val)) {
// Value is corrupt (e.g., "Custom" from old workflows)
// Only NOW do we default to 1.0
val = 1.0;
strWidget.value = val;
}
// If val is a valid number (including 0.0), keep the original value
// Don't overwrite it
}
}
// Note: We no longer need to force preset to "Custom" since we're using
// the existing Python preset widget directly. ComfyUI will restore its
// saved value correctly.
node.setDirtyCanvas(true);
}, 150); // Longer delay to ensure ComfyUI finishes deserializing first
};
// Hook onExecuted to store analysis data when we receive it
const origOnExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function(output) {
if (origOnExecuted) {
origOnExecuted.apply(this, arguments);
}
// Store analysis_json from our UI output
if (output && output.analysis_json && output.analysis_json[0]) {
try {
const jsonStr = output.analysis_json[0];
if (jsonStr && jsonStr.length > 2) { // Not empty "{}"
this._analysisData = JSON.parse(jsonStr);
this.setDirtyCanvas(true);
}
} catch (e) {
// Silent fail - analysis coloring is optional
}
}
};
nodeType.prototype.combineBlockWidgets = function() {
// Find all widget pairs (toggle + _str)
const widgetPairs = [];
const strWidgetNames = new Set();
for (const widget of this.widgets) {
if (widget.name.endsWith('_str')) {
strWidgetNames.add(widget.name);
}
}
for (const widget of this.widgets) {
const strName = widget.name + '_str';
if (strWidgetNames.has(strName)) {
const strWidget = this.widgets.find(w => w.name === strName);
if (strWidget) {
widgetPairs.push({
toggle: widget,
strength: strWidget,
name: widget.name
});
}
}
}
// Create combined widgets
for (const pair of widgetPairs) {
this.createCombinedWidget(pair);
}
// Resize node to fit
this.setSize(this.computeSize());
this.setDirtyCanvas(true);
};
nodeType.prototype.createCombinedWidget = function(pair) {
const { toggle, strength, name } = pair;
// Hide original widgets by replacing their draw methods
const originalToggleDraw = toggle.draw?.bind(toggle);
const originalStrengthDraw = strength.draw?.bind(strength);
// Combined draw function for toggle widget (strength widget will be hidden)
toggle.draw = function(ctx, node, widgetWidth, y, widgetHeight) {
const margin = 10;
const checkboxSize = 14;
const labelWidth = 95; // Fixed label width
const valueWidth = 38; // Value display
const gap = 6;
// Slider takes remaining space
const sliderWidth = widgetWidth - margin - checkboxSize - gap - labelWidth - gap - valueWidth - margin - gap;
// Calculate positions
const checkboxX = margin;
const labelX = checkboxX + checkboxSize + gap;
const sliderX = labelX + labelWidth + gap;
const valueX = sliderX + sliderWidth + gap;
const enabled = Boolean(toggle.value);
// Read strength value (ComfyUI may deserialize as string)
let strengthVal = parseFloat(strength.value);
if (isNaN(strengthVal)) {
// Fallback for corrupt data - should rarely happen after onConfigure fix
strengthVal = 1.0;
}
// Get impact score from analysis if available
let impactScore = null;
const analysis = getAnalysisFromInput(node);
if (analysis && analysis.blocks) {
// Try to find this block's score
// Map widget name to analysis key (other_weights -> other)
const analysisKey = name === "other_weights" ? "other" : name;
const blockData = analysis.blocks[analysisKey];
if (blockData && typeof blockData.score === 'number') {
impactScore = blockData.score;
}
}
// Determine checkbox color based on impact score or default
let checkboxColor = "#5599ff"; // Default blue
if (impactScore !== null) {
checkboxColor = getImpactColor(impactScore);
}
// Background
ctx.fillStyle = enabled ? "#2a2a2a" : "#1e1e1e";
ctx.fillRect(0, y, widgetWidth, widgetHeight);
// Checkbox
ctx.strokeStyle = enabled ? checkboxColor : "#555";
ctx.lineWidth = 1.5;
ctx.strokeRect(checkboxX, y + (widgetHeight - checkboxSize) / 2, checkboxSize, checkboxSize);
if (enabled) {
ctx.fillStyle = checkboxColor;
ctx.fillRect(checkboxX + 2, y + (widgetHeight - checkboxSize) / 2 + 2, checkboxSize - 4, checkboxSize - 4);
}
// Label
ctx.fillStyle = enabled ? "#ddd" : "#666";
ctx.font = "11px Arial";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
// Truncate label if too long
let displayLabel = name;
const maxLabelWidth = labelWidth - 4;
if (ctx.measureText(displayLabel).width > maxLabelWidth) {
while (ctx.measureText(displayLabel + "…").width > maxLabelWidth && displayLabel.length > 3) {
displayLabel = displayLabel.slice(0, -1);
}
displayLabel += "…";
}
ctx.fillText(displayLabel, labelX, y + widgetHeight / 2);
// Slider track
const trackY = y + widgetHeight / 2;
const trackHeight = 4;
const min = strength.options?.min ?? -2.0;
const max = strength.options?.max ?? 2.0;
const range = max - min;
const normalizedStrength = (strengthVal - min) / range;
const zeroPos = (0 - min) / range;
ctx.fillStyle = "#333";
ctx.beginPath();
ctx.roundRect(sliderX, trackY - trackHeight / 2, sliderWidth, trackHeight, 2);
ctx.fill();
// Slider fill (from zero point)
ctx.fillStyle = enabled ? (strengthVal >= 0 ? "#5599ff" : "#ff6655") : "#444";
if (min < 0) {
const zeroX = sliderX + zeroPos * sliderWidth;
const strengthX = sliderX + normalizedStrength * sliderWidth;
const fillStart = Math.min(zeroX, strengthX);
const fillWidth = Math.abs(strengthX - zeroX);
ctx.beginPath();
ctx.roundRect(fillStart, trackY - trackHeight / 2, fillWidth, trackHeight, 2);
ctx.fill();
} else {
ctx.beginPath();
ctx.roundRect(sliderX, trackY - trackHeight / 2, normalizedStrength * sliderWidth, trackHeight, 2);
ctx.fill();
}
// Slider handle
const handleX = sliderX + normalizedStrength * sliderWidth;
const handleRadius = 5;
ctx.fillStyle = enabled ? "#fff" : "#666";
ctx.beginPath();
ctx.arc(handleX, trackY, handleRadius, 0, Math.PI * 2);
ctx.fill();
// Value text
ctx.fillStyle = enabled ? "#ddd" : "#555";
ctx.textAlign = "right";
ctx.font = "10px Arial";
ctx.fillText(strengthVal.toFixed(2), widgetWidth - margin, y + widgetHeight / 2);
};
// Store layout info for mouse handling
toggle.sliderInfo = {
margin: 10,
checkboxSize: 14,
labelWidth: 95,
valueWidth: 38,
gap: 6,
min: -2.0,
max: 2.0,
step: 0.05, // Hardcoded - ComfyUI widget options not reliably accessible
getLayout: function(widgetWidth) {
const sliderWidth = widgetWidth - this.margin - this.checkboxSize - this.gap - this.labelWidth - this.gap - this.valueWidth - this.margin - this.gap;
const checkboxX = this.margin;
const labelX = checkboxX + this.checkboxSize + this.gap;
const sliderX = labelX + this.labelWidth + this.gap;
const valueX = sliderX + sliderWidth + this.gap;
return { sliderWidth, checkboxX, sliderX, valueX };
}
};
// Mouse handling for slider - let default toggle behavior work for other clicks
const originalMouse = toggle.mouse?.bind(toggle);
toggle.mouse = function(event, pos, node) {
const widgetWidth = node.size[0];
const info = toggle.sliderInfo;
const layout = info.getLayout(widgetWidth);
const localX = pos[0];
// Slider interaction - intercept drag on slider area
if (localX >= layout.sliderX - 5 && localX <= layout.sliderX + layout.sliderWidth + 5) {
if (event.type === "pointerdown" || event.type === "pointermove") {
let normalized = (localX - layout.sliderX) / layout.sliderWidth;
normalized = Math.max(0, Math.min(1, normalized));
let newStrength = info.min + normalized * (info.max - info.min);
// Snap to step
newStrength = Math.round(newStrength / info.step) * info.step;
newStrength = Math.max(info.min, Math.min(info.max, newStrength));
strength.value = newStrength;
node.setDirtyCanvas(true);
return true;
}
}
// Let default behavior handle toggle clicks
if (originalMouse) {
return originalMouse(event, pos, node);
}
return false;
};
// Hide the strength widget completely
strength.draw = function() {};
strength.computeSize = function() { return [0, -4]; }; // Negative height to collapse
};
// Add preset dropdown widget
nodeType.prototype.addPresetWidget = function(nodeName) {
const config = SELECTIVE_LOADER_PRESETS[nodeName];
if (!config) return;
const presetNames = Object.keys(config.presets);
const node = this;
// Find the Python preset widget and convert it to our JS preset widget
// This avoids adding a new widget which would cause index mismatch
const presetWidget = this.widgets.find(w => w.name === "preset");
if (!presetWidget) return;
// Convert the existing combo widget to work with our JS preset system
presetWidget.options = presetWidget.options || {};
presetWidget.options.values = presetNames;
// Set initial value to "Default" (or current value if it's valid)
if (!presetNames.includes(presetWidget.value)) {
presetWidget.value = "Default";
}
// Override callback to apply preset when changed
const origCallback = presetWidget.callback;
presetWidget.callback = function(value) {
// When user changes preset via UI, apply it
node.applyPreset(nodeName, value);
// Call original callback if it exists
if (origCallback) {
origCallback.call(this, value);
}
};
// CRITICAL FIX: Override serializeValue to always return "Custom" for Python
// This way Python always uses individual toggle values, while JavaScript
// manages its own independent preset system in the UI
const origSerializeValue = presetWidget.serializeValue;
presetWidget.serializeValue = function() {
return "Custom";
};
// Store reference for later
this.presetWidget = presetWidget;
// Resize node
this.setSize(this.computeSize());
this.setDirtyCanvas(true);
};
// Apply a preset to all block toggles and strengths
nodeType.prototype.applyPreset = function(nodeName, presetName) {
const config = SELECTIVE_LOADER_PRESETS[nodeName];
if (!config) return;
const preset = config.presets[presetName];
if (!preset) return;
const allBlocks = config.blocks;
const enabledBlocks = preset.enabled === "ALL" ? allBlocks : preset.enabled;
const enabledSet = new Set(enabledBlocks);
const strength = preset.strength;
// Update all toggle and strength widgets
for (const blockName of allBlocks) {
const toggleWidget = this.widgets.find(w => w.name === blockName);
const strWidget = this.widgets.find(w => w.name === blockName + "_str");
if (toggleWidget) {
toggleWidget.value = enabledSet.has(blockName);
}
if (strWidget) {
strWidget.value = strength;
}
}
this.setDirtyCanvas(true);
};
}
});