Files

1624 lines
83 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", "MusubiZImageBaseLoraTrainer", "MusubiFluxKleinLoraTrainer", "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);
}
};
}
});
// ============================================================================
// STRENGTH SCHEDULE PRESETS (must match Python SCHEDULE_PRESETS)
// ============================================================================
const SCHEDULE_PRESETS = {
// === No Schedule ===
"Custom": "",
"Constant 1.0 (No Change)": "0:1, 1:1",
// === Basic Fades ===
"Linear In (0→1)": "0:0, 1:1",
"Linear Out (1→0)": "0:1, 1:0",
// === Ease Curves ===
"Ease In (slow start)": "0:0, 0.3:0.1, 0.7:0.5, 1:1",
"Ease Out (slow end)": "0:1, 0.3:0.5, 0.7:0.9, 1:0",
"Ease In-Out": "0:0, 0.3:0.1, 0.7:0.9, 1:1",
// === Bell Curves ===
"Bell Curve (peak middle)": "0:0, 0.5:1, 1:0",
"Wide Bell": "0:0, 0.3:0.8, 0.7:0.8, 1:0",
"Sharp Bell": "0:0, 0.4:0.2, 0.5:1, 0.6:0.2, 1:0",
// === Structure LoRA Favorites ===
"High Start → Cut Low": "0:1, 0.3:1, 0.35:0.2, 1:0.2",
"High Start → Cut Off": "0:1, 0.3:1, 0.35:0, 1:0",
"High Early → Fade": "0:1, 0.2:0.8, 0.5:0.3, 1:0",
// === Detail LoRA Favorites ===
"Low Start → Ramp Late": "0:0, 0.6:0.1, 0.8:0.7, 1:1",
"Off → Kick In Late": "0:0, 0.7:0, 0.75:0.8, 1:1",
"Low → Boost End": "0:0.2, 0.5:0.2, 0.7:0.6, 1:1",
// === Step Functions ===
"Step Up Mid": "0:0.2, 0.45:0.2, 0.55:0.8, 1:0.8",
"Step Down Mid": "0:0.8, 0.45:0.8, 0.55:0.2, 1:0.2",
"Two Steps Up": "0:0, 0.3:0, 0.35:0.5, 0.65:0.5, 0.7:1, 1:1",
// === Pulses ===
"Pulse Early": "0:1, 0.25:1, 0.35:0.2, 1:0.2",
"Pulse Mid": "0:0.2, 0.4:0.2, 0.45:1, 0.55:1, 0.6:0.2, 1:0.2",
"Pulse Late": "0:0.2, 0.65:0.2, 0.75:1, 1:1",
// === Constant (for testing) ===
"Constant Half": "0:0.5, 1:0.5",
"Constant Low": "0:0.3, 1:0.3",
// =========================================================================
// INVERTED VERSIONS (same order as above)
// =========================================================================
// === Basic Fades (Inverted) ===
"INV: Linear In (1→0)": "0:1, 1:0",
"INV: Linear Out (0→1)": "0:0, 1:1",
// === Ease Curves (Inverted) ===
"INV: Ease In": "0:1, 0.3:0.9, 0.7:0.5, 1:0",
"INV: Ease Out": "0:0, 0.3:0.5, 0.7:0.1, 1:1",
"INV: Ease In-Out": "0:1, 0.3:0.9, 0.7:0.1, 1:0",
// === Bell Curves (Inverted) ===
"INV: Bell (dip middle)": "0:1, 0.5:0, 1:1",
"INV: Wide Bell": "0:1, 0.3:0.2, 0.7:0.2, 1:1",
"INV: Sharp Bell": "0:1, 0.4:0.8, 0.5:0, 0.6:0.8, 1:1",
// === Structure Inverted ===
"INV: Low Start → Boost High": "0:0, 0.3:0, 0.35:0.8, 1:0.8",
"INV: Low Start → Full On": "0:0, 0.3:0, 0.35:1, 1:1",
"INV: Low Early → Build": "0:0, 0.2:0.2, 0.5:0.7, 1:1",
// === Detail Inverted ===
"INV: High Start → Drop Late": "0:1, 0.6:0.9, 0.8:0.3, 1:0",
"INV: Full → Cut Off Late": "0:1, 0.7:1, 0.75:0.2, 1:0",
"INV: High → Drop End": "0:0.8, 0.5:0.8, 0.7:0.4, 1:0",
// === Step Functions (Inverted) ===
"INV: Step Down Mid": "0:0.8, 0.45:0.8, 0.55:0.2, 1:0.2",
"INV: Step Up Mid": "0:0.2, 0.45:0.2, 0.55:0.8, 1:0.8",
"INV: Two Steps Down": "0:1, 0.3:1, 0.35:0.5, 0.65:0.5, 0.7:0, 1:0",
// === Pulses (Inverted) ===
"INV: Dip Early": "0:0, 0.25:0, 0.35:0.8, 1:0.8",
"INV: Dip Mid": "0:0.8, 0.4:0.8, 0.45:0, 0.55:0, 0.6:0.8, 1:0.8",
"INV: Dip Late": "0:0.8, 0.65:0.8, 0.75:0, 1:0",
};
// 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) {
// Apply to both V1 and V2 analyzers
if (nodeData.name !== "LoRALoaderWithAnalysis" && nodeData.name !== "LoRALoaderWithAnalysisV2") 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 },
"Early Only (0-9)": { enabled: Array.from({length: 10}, (_, i) => `layer_${i}`), strength: 1.0 },
"Peak Impact (18-25)": { enabled: Array.from({length: 8}, (_, i) => `layer_${i + 18}`), strength: 1.0 },
"Face Priority (16-24)": { enabled: Array.from({length: 9}, (_, i) => `layer_${i + 16}`), strength: 1.0 },
"Face Priority Aggressive (14-25)": { enabled: Array.from({length: 12}, (_, i) => `layer_${i + 14}`), strength: 1.0 },
"Evens Only": { enabled: Array.from({length: 15}, (_, i) => `layer_${i * 2}`), strength: 1.0 },
"Odds Only": { enabled: Array.from({length: 15}, (_, i) => `layer_${i * 2 + 1}`), 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
},
"Evens Only": { enabled: [...Array.from({length: 10}, (_, i) => `double_${i * 2}`), ...Array.from({length: 19}, (_, i) => `single_${i * 2}`)], strength: 1.0 },
"Odds Only": { enabled: [...Array.from({length: 9}, (_, i) => `double_${i * 2 + 1}`), ...Array.from({length: 19}, (_, i) => `single_${i * 2 + 1}`)], 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 },
"Evens Only": { enabled: Array.from({length: 20}, (_, i) => `block_${i * 2}`), strength: 1.0 },
"Odds Only": { enabled: Array.from({length: 20}, (_, i) => `block_${i * 2 + 1}`), 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 },
"Evens Only": { enabled: Array.from({length: 30}, (_, i) => `block_${i * 2}`), strength: 1.0 },
"Odds Only": { enabled: Array.from({length: 30}, (_, i) => `block_${i * 2 + 1}`), strength: 1.0 },
}
},
"Krea2SelectiveLoRALoader": {
blocks: [...Array.from({length: 28}, (_, 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 (21-27)": { enabled: [...Array.from({length: 7}, (_, i) => `block_${i + 21}`), "other_weights"], strength: 1.0 },
"Mid-Late (14-27)": { enabled: [...Array.from({length: 14}, (_, i) => `block_${i + 14}`), "other_weights"], strength: 1.0 },
"Skip Early (7-27)": { enabled: [...Array.from({length: 21}, (_, i) => `block_${i + 7}`), "other_weights"], strength: 1.0 },
"Mid Only (9-18)": { enabled: Array.from({length: 10}, (_, i) => `block_${i + 9}`), strength: 1.0 },
"Early Only (0-8)": { enabled: Array.from({length: 9}, (_, i) => `block_${i}`), strength: 1.0 },
"Evens Only": { enabled: Array.from({length: 14}, (_, i) => `block_${i * 2}`), strength: 1.0 },
"Odds Only": { enabled: Array.from({length: 14}, (_, i) => `block_${i * 2 + 1}`), strength: 1.0 },
}
},
"MiniMaxH3SelectiveLoRALoader": {
blocks: [...Array.from({length: 50}, (_, i) => `block_${i}`), "other_weights"],
presets: {
"Default": { enabled: "ALL", strength: 1.0 },
"All Blocks": { enabled: "ALL", strength: 1.0 },
"All Off": { enabled: [], strength: 0.0 },
"Half Strength": { enabled: "ALL", strength: 0.5 },
"Late Only (38-49)": { enabled: [...Array.from({length: 12}, (_, i) => `block_${i + 38}`), "other_weights"], strength: 1.0 },
"Mid-Late (25-49)": { enabled: [...Array.from({length: 25}, (_, i) => `block_${i + 25}`), "other_weights"], strength: 1.0 },
"Skip Early (13-49)": { enabled: [...Array.from({length: 37}, (_, i) => `block_${i + 13}`), "other_weights"], strength: 1.0 },
"Mid Only (17-32)": { enabled: [...Array.from({length: 16}, (_, i) => `block_${i + 17}`)], strength: 1.0 },
"Early Only (0-16)": { enabled: [...Array.from({length: 17}, (_, i) => `block_${i}`)], strength: 1.0 },
"Evens Only": { enabled: Array.from({length: 25}, (_, i) => `block_${i * 2}`), strength: 1.0 },
"Odds Only": { enabled: Array.from({length: 25}, (_, i) => `block_${i * 2 + 1}`), strength: 1.0 },
}
},
// V2 Combined Analyzer + Selective Loaders
"ZImageAnalyzerSelectiveLoaderV2": {
blocks: [...Array.from({length: 30}, (_, i) => `layer_${i}`), "context_refiner", "noise_refiner", "final_layer", "x_embedder", "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 },
"Early Only (0-9)": { enabled: Array.from({length: 10}, (_, i) => `layer_${i}`), strength: 1.0 },
"Peak Impact (18-25)": { enabled: Array.from({length: 8}, (_, i) => `layer_${i + 18}`), strength: 1.0 },
"Face Priority (16-24)": { enabled: Array.from({length: 9}, (_, i) => `layer_${i + 16}`), strength: 1.0 },
"Face Priority Aggressive (14-25)": { enabled: Array.from({length: 12}, (_, i) => `layer_${i + 14}`), strength: 1.0 },
"Evens Only": { enabled: Array.from({length: 15}, (_, i) => `layer_${i * 2}`), strength: 1.0 },
"Odds Only": { enabled: Array.from({length: 15}, (_, i) => `layer_${i * 2 + 1}`), strength: 1.0 },
}
},
"SDXLAnalyzerSelectiveLoaderV2": {
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 },
}
},
"FLUXAnalyzerSelectiveLoaderV2": {
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
},
"Evens Only": { enabled: [...Array.from({length: 10}, (_, i) => `double_${i * 2}`), ...Array.from({length: 19}, (_, i) => `single_${i * 2}`)], strength: 1.0 },
"Odds Only": { enabled: [...Array.from({length: 9}, (_, i) => `double_${i * 2 + 1}`), ...Array.from({length: 19}, (_, i) => `single_${i * 2 + 1}`)], strength: 1.0 },
}
},
"WanAnalyzerSelectiveLoaderV2": {
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 },
"Evens Only": { enabled: Array.from({length: 20}, (_, i) => `block_${i * 2}`), strength: 1.0 },
"Odds Only": { enabled: Array.from({length: 20}, (_, i) => `block_${i * 2 + 1}`), strength: 1.0 },
}
},
"QwenAnalyzerSelectiveLoaderV2": {
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 },
"Evens Only": { enabled: Array.from({length: 30}, (_, i) => `block_${i * 2}`), strength: 1.0 },
"Odds Only": { enabled: Array.from({length: 30}, (_, i) => `block_${i * 2 + 1}`), strength: 1.0 },
}
},
"Krea2AnalyzerSelectiveLoaderV2": {
blocks: [...Array.from({length: 28}, (_, 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 (21-27)": { enabled: [...Array.from({length: 7}, (_, i) => `block_${i + 21}`), "other_weights"], strength: 1.0 },
"Mid-Late (14-27)": { enabled: [...Array.from({length: 14}, (_, i) => `block_${i + 14}`), "other_weights"], strength: 1.0 },
"Skip Early (7-27)": { enabled: [...Array.from({length: 21}, (_, i) => `block_${i + 7}`), "other_weights"], strength: 1.0 },
"Mid Only (9-18)": { enabled: Array.from({length: 10}, (_, i) => `block_${i + 9}`), strength: 1.0 },
"Early Only (0-8)": { enabled: Array.from({length: 9}, (_, i) => `block_${i}`), strength: 1.0 },
"Evens Only": { enabled: Array.from({length: 14}, (_, i) => `block_${i * 2}`), strength: 1.0 },
"Odds Only": { enabled: Array.from({length: 14}, (_, i) => `block_${i * 2 + 1}`), strength: 1.0 },
}
},
"MiniMaxH3AnalyzerSelectiveLoaderV2": {
blocks: [...Array.from({length: 50}, (_, 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 (38-49)": { enabled: [...Array.from({length: 12}, (_, i) => `block_${i + 38}`), "other_weights"], strength: 1.0 },
"Mid-Late (25-49)": { enabled: [...Array.from({length: 25}, (_, i) => `block_${i + 25}`), "other_weights"], strength: 1.0 },
"Skip Early (13-49)": { enabled: [...Array.from({length: 37}, (_, i) => `block_${i + 13}`), "other_weights"], strength: 1.0 },
"Mid Only (17-32)": { enabled: [...Array.from({length: 16}, (_, i) => `block_${i + 17}`)], strength: 1.0 },
"Early Only (0-16)": { enabled: [...Array.from({length: 17}, (_, i) => `block_${i}`)], strength: 1.0 },
"Evens Only": { enabled: Array.from({length: 25}, (_, i) => `block_${i * 2}`), strength: 1.0 },
"Odds Only": { enabled: Array.from({length: 25}, (_, i) => `block_${i * 2 + 1}`), strength: 1.0 },
}
},
// FLUX Klein 4B (5 double + 20 single blocks)
"FluxKlein4BAnalyzerSelectiveLoaderV2": {
blocks: [
...Array.from({length: 5}, (_, i) => `double_${i}`),
...Array.from({length: 20}, (_, 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 Only": { enabled: [...Array.from({length: 5}, (_, i) => `double_${i}`), "other_weights"], strength: 1.0 },
"Single Only": { enabled: [...Array.from({length: 20}, (_, i) => `single_${i}`), "other_weights"], strength: 1.0 },
"High Impact (Double 2-4)": { enabled: Array.from({length: 3}, (_, i) => `double_${i + 2}`), strength: 1.0 },
"Late Singles (10-19)": { enabled: Array.from({length: 10}, (_, i) => `single_${i + 10}`), strength: 1.0 },
"Evens Only": { enabled: [...Array.from({length: 3}, (_, i) => `double_${i * 2}`), ...Array.from({length: 10}, (_, i) => `single_${i * 2}`)], strength: 1.0 },
"Odds Only": { enabled: [...Array.from({length: 2}, (_, i) => `double_${i * 2 + 1}`), ...Array.from({length: 10}, (_, i) => `single_${i * 2 + 1}`)], strength: 1.0 },
}
},
// FLUX Klein 9B (8 double + 24 single blocks)
"FluxKlein9BAnalyzerSelectiveLoaderV2": {
blocks: [
...Array.from({length: 8}, (_, i) => `double_${i}`),
...Array.from({length: 24}, (_, 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 Only": { enabled: [...Array.from({length: 8}, (_, i) => `double_${i}`), "other_weights"], strength: 1.0 },
"Single Only": { enabled: [...Array.from({length: 24}, (_, i) => `single_${i}`), "other_weights"], strength: 1.0 },
"High Impact (Double 4-7)": { enabled: Array.from({length: 4}, (_, i) => `double_${i + 4}`), strength: 1.0 },
"Late Singles (12-23)": { enabled: Array.from({length: 12}, (_, i) => `single_${i + 12}`), strength: 1.0 },
"Evens Only": { enabled: [...Array.from({length: 4}, (_, i) => `double_${i * 2}`), ...Array.from({length: 12}, (_, i) => `single_${i * 2}`)], strength: 1.0 },
"Odds Only": { enabled: [...Array.from({length: 4}, (_, i) => `double_${i * 2 + 1}`), ...Array.from({length: 12}, (_, i) => `single_${i * 2 + 1}`)], strength: 1.0 },
}
},
// Model Layer Editor nodes (base model per-block control)
"SDXLModelLayerEditor": {
blocks: [...Array.from({length: 12}, (_, i) => `input_${i}`), "mid", ...Array.from({length: 12}, (_, i) => `output_${i}`), "other"],
presets: {
"Default": { enabled: "ALL", strength: 1.0 },
"All Off": { enabled: [], strength: 0.0 },
"Half Strength": { enabled: "ALL", strength: 0.5 },
"Outputs Only": { enabled: [...Array.from({length: 12}, (_, i) => `output_${i}`), "mid", "other"], strength: 1.0 },
"Inputs Only": { enabled: [...Array.from({length: 12}, (_, i) => `input_${i}`), "mid", "other"], strength: 1.0 },
"Custom": { enabled: "ALL", strength: 1.0 },
}
},
"SD15ModelLayerEditor": {
blocks: [...Array.from({length: 12}, (_, i) => `input_${i}`), "mid", ...Array.from({length: 12}, (_, i) => `output_${i}`), "other"],
presets: {
"Default": { enabled: "ALL", strength: 1.0 },
"All Off": { enabled: [], strength: 0.0 },
"Half Strength": { enabled: "ALL", strength: 0.5 },
"Outputs Only": { enabled: [...Array.from({length: 12}, (_, i) => `output_${i}`), "mid", "other"], strength: 1.0 },
"Inputs Only": { enabled: [...Array.from({length: 12}, (_, i) => `input_${i}`), "mid", "other"], strength: 1.0 },
"Custom": { enabled: "ALL", strength: 1.0 },
}
},
"FLUXModelLayerEditor": {
blocks: [...Array.from({length: 19}, (_, i) => `double_${i}`), ...Array.from({length: 38}, (_, i) => `single_${i}`), "other"],
presets: {
"Default": { enabled: "ALL", strength: 1.0 },
"All Off": { enabled: [], strength: 0.0 },
"Half Strength": { enabled: "ALL", strength: 0.5 },
"Double Only": { enabled: [...Array.from({length: 19}, (_, i) => `double_${i}`), "other"], strength: 1.0 },
"Single Only": { enabled: [...Array.from({length: 38}, (_, i) => `single_${i}`), "other"], strength: 1.0 },
"Custom": { enabled: "ALL", strength: 1.0 },
}
},
"ZImageModelLayerEditor": {
blocks: [...Array.from({length: 30}, (_, i) => `layer_${i}`), "other"],
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"], strength: 1.0 },
"Mid-Late (15-29)": { enabled: [...Array.from({length: 15}, (_, i) => `layer_${i + 15}`), "other"], strength: 1.0 },
"Custom": { enabled: "ALL", strength: 1.0 },
}
},
"WanModelLayerEditor": {
blocks: [...Array.from({length: 40}, (_, i) => `block_${i}`), "other"],
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"], strength: 1.0 },
"Custom": { enabled: "ALL", strength: 1.0 },
}
},
"QwenModelLayerEditor": {
blocks: [...Array.from({length: 60}, (_, i) => `block_${i}`), "other"],
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"], strength: 1.0 },
"Custom": { enabled: "ALL", 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 (including V2 combined nodes)
const selectiveLoaders = [
"SDXLSelectiveLoRALoader",
"ZImageSelectiveLoRALoader",
"FLUXSelectiveLoRALoader",
"WanSelectiveLoRALoader",
"QwenSelectiveLoRALoader",
"Krea2SelectiveLoRALoader",
"MiniMaxH3SelectiveLoRALoader",
// V2 Combined Analyzer + Selective Loaders
"ZImageAnalyzerSelectiveLoaderV2",
"SDXLAnalyzerSelectiveLoaderV2",
"FLUXAnalyzerSelectiveLoaderV2",
"WanAnalyzerSelectiveLoaderV2",
"QwenAnalyzerSelectiveLoaderV2",
"Krea2AnalyzerSelectiveLoaderV2",
"MiniMaxH3AnalyzerSelectiveLoaderV2",
"FluxKlein4BAnalyzerSelectiveLoaderV2",
"FluxKlein9BAnalyzerSelectiveLoaderV2",
// Model Layer Editor nodes (base model per-block control)
"SDXLModelLayerEditor",
"SD15ModelLayerEditor",
"FLUXModelLayerEditor",
"ZImageModelLayerEditor",
"WanModelLayerEditor",
"QwenModelLayerEditor"
];
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);
node.setupSchedulePresetWidget();
node.setupBidirectionalSync(nodeData.name);
setTimeout(() => {
if (node.updateStringFromUI) {
node.updateStringFromUI();
}
}, 150);
// 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.
// Merge workflow-embedded presets into localStorage (for Model Layer Editor)
// This ensures old workflow presets appear in dropdown immediately on load
if (nodeData.name.includes("ModelLayerEditor")) {
const browserPresetsWidget = node.widgets.find(w => w.name === "browser_presets_json");
if (browserPresetsWidget && browserPresetsWidget.value && browserPresetsWidget.value !== "{}") {
try {
const workflowPresets = JSON.parse(browserPresetsWidget.value);
if (workflowPresets && typeof workflowPresets === 'object') {
const localPresets = node.loadUserPresets ? node.loadUserPresets(nodeData.name) : {};
let merged = false;
for (const [name, data] of Object.entries(workflowPresets)) {
if (name && !localPresets[name] && name.toLowerCase() !== 'true' && name.toLowerCase() !== 'false') {
localPresets[name] = data;
merged = true;
}
}
if (merged && node.saveUserPreset) {
// Save merged presets to localStorage
const storageKey = `comfyui_model_layer_presets_${nodeData.name}`;
localStorage.setItem(storageKey, JSON.stringify(localPresets));
console.log(`[Model Layer Editor] Merged workflow presets into browser storage`);
// Refresh dropdown to include new presets
const presetWidget = node.widgets.find(w => w.name === "preset");
if (presetWidget && presetWidget.options) {
for (const name of Object.keys(localPresets)) {
if (!presetWidget.options.values.includes(name)) {
const customIdx = presetWidget.options.values.indexOf("Custom");
if (customIdx >= 0) {
presetWidget.options.values.splice(customIdx, 0, name);
} else {
presetWidget.options.values.push(name);
}
}
}
}
}
}
} catch (e) {
// Silent fail - workflow preset merge is optional
}
}
}
if (node.updateStringFromUI) {
node.updateStringFromUI();
}
node.setDirtyCanvas(true);
}, 150); // Longer delay to ensure ComfyUI finishes deserializing first
};
const origOnMouseUp = nodeType.prototype.onMouseUp;
nodeType.prototype.onMouseUp = function(e, localPos, graphCanvas) {
const result = origOnMouseUp ? origOnMouseUp.apply(this, arguments) : false;
if (this.updateStringFromUI) {
this.updateStringFromUI();
}
return result;
};
// Hook onExecuted to store analysis data and sync user presets
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 "{}"
const analysisData = JSON.parse(jsonStr);
this._analysisData = analysisData;
this.setDirtyCanvas(true);
// Sync ALL user presets from Python config file to localStorage
// This handles new computer / cleared storage scenarios
if (analysisData.user_presets) {
const { node_id, presets } = analysisData.user_presets;
const storageKey = `comfyui_model_layer_presets_${node_id}`;
try {
localStorage.setItem(storageKey, JSON.stringify(presets));
const count = Object.keys(presets).length;
if (count > 0) {
console.log(`[Model Layer Editor] Synced ${count} user preset(s) to browser storage`);
}
} catch (e) {
console.warn("[Model Layer Editor] Failed to sync presets:", e);
}
}
// Handle deleted preset - remove from localStorage
if (analysisData.deleted_preset) {
const { node_id, preset_name } = analysisData.deleted_preset;
const storageKey = `comfyui_model_layer_presets_${node_id}`;
try {
const storedPresets = JSON.parse(localStorage.getItem(storageKey) || "{}");
if (storedPresets[preset_name]) {
delete storedPresets[preset_name];
localStorage.setItem(storageKey, JSON.stringify(storedPresets));
console.log(`[Model Layer Editor] Deleted preset '${preset_name}' from browser storage`);
}
} catch (e) {
// Ignore errors
}
}
// Log confirmation for just-saved preset
if (analysisData.saved_preset) {
const { preset_name } = analysisData.saved_preset;
console.log(`[Model Layer Editor] Saved preset '${preset_name}' - available after restart`);
}
// CRITICAL: Clear the hidden save/delete fields after processing
// This prevents accidental re-saves on subsequent executions
const saveNameWidget = this.widgets?.find(w => w.name === "save_preset_name");
const deleteNameWidget = this.widgets?.find(w => w.name === "delete_preset_name");
if (saveNameWidget) saveNameWidget.value = "";
if (deleteNameWidget) deleteNameWidget.value = "";
}
} 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) {
// Store draw position for precise slider hit detection.
toggle._lastDrawY = y;
toggle._lastDrawHeight = 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);
// Always show impact color, but dimmed when disabled
ctx.globalAlpha = enabled ? 1.0 : 0.35;
ctx.fillStyle = checkboxColor;
ctx.fillRect(checkboxX + 2, y + (widgetHeight - checkboxSize) / 2 + 2, checkboxSize - 4, checkboxSize - 4);
ctx.globalAlpha = 1.0;
// 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: strength.options?.min ?? -2.0,
max: strength.options?.max ?? 2.0,
// ComfyUI can expose stale widget metadata here; block values
// are declared in 0.05 increments across the selective loaders.
step: 0.05,
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 with precise Y-axis hit detection and click-to-jump.
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];
const absoluteY = pos[1];
if (toggle._lastDrawY === undefined || toggle._lastDrawHeight === undefined) {
return originalMouse ? originalMouse(event, pos, node) : false;
}
const widgetY = toggle._lastDrawY;
const widgetHeight = toggle._lastDrawHeight;
const localY = absoluteY - widgetY;
const trackCenterY = widgetHeight / 2;
const verticalTolerance = 4;
const inSliderX = localX >= layout.sliderX && localX <= layout.sliderX + layout.sliderWidth;
const inSliderY = Math.abs(localY - trackCenterY) <= verticalTolerance;
const setStrengthFromX = () => {
let normalized = (localX - layout.sliderX) / layout.sliderWidth;
normalized = Math.max(0, Math.min(1, normalized));
let newStrength = info.min + normalized * (info.max - info.min);
newStrength = Math.round(newStrength / info.step) * info.step;
newStrength = Math.max(info.min, Math.min(info.max, newStrength));
strength.value = newStrength;
node.setDirtyCanvas(true);
};
if (event.type === "pointermove") {
if (toggle._wasDragging || (inSliderX && inSliderY)) {
toggle._wasDragging = true;
setStrengthFromX();
return true;
}
}
if (inSliderX && inSliderY && event.type === "pointerup") {
if (!toggle._wasDragging) {
let strengthVal = parseFloat(strength.value);
if (isNaN(strengthVal)) strengthVal = 1.0;
const range = info.max - info.min;
const normalizedStrength = (strengthVal - info.min) / range;
const handleX = layout.sliderX + normalizedStrength * layout.sliderWidth;
const handleRadius = 6;
const distanceToHandle = Math.abs(localX - handleX);
if (distanceToHandle > handleRadius) {
// LiteGraph may already toggle on track click; undo that and jump the slider.
toggle.value = !toggle.value;
setStrengthFromX();
}
}
toggle._wasDragging = false;
return true;
}
if (event.type === "pointerup") {
toggle._wasDragging = false;
}
if (localX >= layout.sliderX - 5 && localX <= layout.sliderX + layout.sliderWidth + 5 && inSliderY) {
if (event.type === "pointerdown") {
setStrengthFromX();
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;
// Start with hardcoded presets
const presetNames = Object.keys(config.presets);
// Add user presets from localStorage (for Model Layer Editor nodes)
if (nodeName.includes("ModelLayerEditor")) {
const userPresets = this.loadUserPresets(nodeName);
for (const name of Object.keys(userPresets)) {
if (!presetNames.includes(name)) {
// Insert before "Custom" if present, otherwise at end
const customIdx = presetNames.indexOf("Custom");
if (customIdx >= 0) {
presetNames.splice(customIdx, 0, name);
} else {
presetNames.push(name);
}
}
}
}
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;
// IMPORTANT: Start with Python's preset values (includes file-saved user presets)
// Then merge with our JS presets + localStorage presets
const pythonPresets = presetWidget.options?.values || [];
const allPresetNames = [...presetNames];
// Add any Python presets not already in our list (these are file-saved user presets)
const addedFromPython = [];
for (const name of pythonPresets) {
if (!allPresetNames.includes(name)) {
addedFromPython.push(name);
// Insert before "Custom" if present
const customIdx = allPresetNames.indexOf("Custom");
if (customIdx >= 0) {
allPresetNames.splice(customIdx, 0, name);
} else {
allPresetNames.push(name);
}
}
}
if (addedFromPython.length > 0) {
console.log(`[Model Layer Editor] Added ${addedFromPython.length} preset(s) from Python config file: ${addedFromPython.join(", ")}`);
}
// Convert the existing combo widget to work with our JS preset system
presetWidget.options = presetWidget.options || {};
presetWidget.options.values = allPresetNames;
// Set initial value to "Default" (or current value if it's valid)
if (!allPresetNames.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;
// Add Save Preset button for Model Layer Editor nodes
if (nodeName.includes("ModelLayerEditor")) {
this.addSavePresetButton(nodeName);
}
// Resize node
this.setSize(this.computeSize());
this.setDirtyCanvas(true);
};
// ============================================================================
// USER PRESET SYSTEM
// ============================================================================
// Save/delete user presets with dual persistence (localStorage + file)
// Preset system: localStorage for instant UI updates, Python config file for persistence
// Add Save Preset button for Model Layer Editor nodes
nodeType.prototype.addSavePresetButton = function(nodeName) {
const node = this;
const config = SELECTIVE_LOADER_PRESETS[nodeName];
if (!config) return;
// Find and HIDE the save_preset_name, delete_preset_name, and browser_presets_json widgets
// They're still used internally for Python sync, but hidden from UI
const saveNameWidget = this.widgets.find(w => w.name === "save_preset_name");
const deleteNameWidget = this.widgets.find(w => w.name === "delete_preset_name");
const browserPresetsWidget = this.widgets.find(w => w.name === "browser_presets_json");
if (saveNameWidget) {
saveNameWidget.draw = function() {};
saveNameWidget.computeSize = function() { return [0, -4]; };
}
if (deleteNameWidget) {
deleteNameWidget.draw = function() {};
deleteNameWidget.computeSize = function() { return [0, -4]; };
}
if (browserPresetsWidget) {
browserPresetsWidget.draw = function() {};
browserPresetsWidget.computeSize = function() { return [0, -4]; };
// Override serializeValue to send all localStorage presets to Python
const nodeNameCopy = nodeName; // Capture for closure
browserPresetsWidget.serializeValue = function() {
try {
const storageKey = `comfyui_model_layer_presets_${nodeNameCopy}`;
const saved = localStorage.getItem(storageKey);
const presets = saved ? JSON.parse(saved) : {};
const json = JSON.stringify(presets);
console.log(`[Model Layer Editor] Serializing ${Object.keys(presets).length} preset(s) for sync`);
return json;
} catch (e) {
console.warn("[Model Layer Editor] Failed to serialize presets:", e);
return "{}";
}
};
// Also set the value directly so it's available immediately
browserPresetsWidget.value = browserPresetsWidget.serializeValue();
} else {
console.warn("[Model Layer Editor] browser_presets_json widget not found!");
}
// Add the save button - uses prompt() for name input
const saveButton = this.addWidget("button", "💾 Save Preset", null, () => {
// Prompt for preset name
const presetName = prompt("Enter preset name:")?.trim();
if (!presetName) {
return; // User cancelled or empty name
}
// Gather current values from widgets
const enabledBlocks = [];
const overrides = {};
for (const blockName of config.blocks) {
const toggleWidget = node.widgets.find(w => w.name === blockName);
const strWidget = node.widgets.find(w => w.name === blockName + "_str");
if (toggleWidget && toggleWidget.value) {
enabledBlocks.push(blockName);
}
if (strWidget && strWidget.value !== 1.0) {
overrides[blockName] = strWidget.value;
}
}
// Build preset data
const preset = {
enabled: enabledBlocks.length === config.blocks.length ? "ALL" : enabledBlocks,
strength: 1.0,
overrides: overrides
};
// Save to localStorage
node.saveUserPreset(nodeName, presetName, preset);
// Update browser_presets_json widget value for Python sync
const browserPresetsWidgetSave = node.widgets.find(w => w.name === "browser_presets_json");
if (browserPresetsWidgetSave) {
const allPresets = node.loadUserPresets(nodeName);
browserPresetsWidgetSave.value = JSON.stringify(allPresets);
console.log(`[Model Layer Editor] Updated sync widget: ${Object.keys(allPresets).length} presets`);
}
// Update preset dropdown to include the new preset
const presetWidget = node.widgets.find(w => w.name === "preset");
if (presetWidget && presetWidget.options && presetWidget.options.values) {
if (!presetWidget.options.values.includes(presetName)) {
// Insert before "Custom" if it exists, otherwise at end
const customIdx = presetWidget.options.values.indexOf("Custom");
if (customIdx >= 0) {
presetWidget.options.values.splice(customIdx, 0, presetName);
} else {
presetWidget.options.values.push(presetName);
}
}
// Set dropdown to the new preset
presetWidget.value = presetName;
}
node.setDirtyCanvas(true);
console.log(`[Model Layer Editor] Saved preset '${presetName}' to browser storage`);
});
// Add delete button that deletes currently selected preset (if it's a user preset)
const deleteButton = this.addWidget("button", "🗑️ Delete Selected Preset", null, () => {
const presetWidget = node.widgets.find(w => w.name === "preset");
if (!presetWidget) return;
const presetName = presetWidget.value;
// Check if it's a built-in preset (can't delete those)
if (config.presets[presetName]) {
alert(`Cannot delete built-in preset '${presetName}'`);
return;
}
// Check if it's a user preset
const userPresets = node.loadUserPresets(nodeName);
if (!userPresets[presetName]) {
alert(`'${presetName}' is not a user preset`);
return;
}
// Confirm deletion
if (!confirm(`Delete preset '${presetName}'?`)) {
return;
}
// Remove from localStorage
delete userPresets[presetName];
const storageKey = `comfyui_model_layer_presets_${nodeName}`;
localStorage.setItem(storageKey, JSON.stringify(userPresets));
console.log(`[Model Layer Editor] Deleted preset '${presetName}' from browser storage`);
// Update browser_presets_json widget value for Python sync
const browserPresetsWidgetDel = node.widgets.find(w => w.name === "browser_presets_json");
if (browserPresetsWidgetDel) {
browserPresetsWidgetDel.value = JSON.stringify(userPresets);
console.log(`[Model Layer Editor] Updated sync widget: ${Object.keys(userPresets).length} presets`);
}
// Remove from dropdown
if (presetWidget.options && presetWidget.options.values) {
const idx = presetWidget.options.values.indexOf(presetName);
if (idx >= 0) {
presetWidget.options.values.splice(idx, 1);
}
}
// Reset to Default
presetWidget.value = "Default";
node.applyPreset(nodeName, "Default");
node.setDirtyCanvas(true);
});
// Leave buttons at the very end of the widget array where addWidget() put them
// DO NOT reposition - inserting anywhere else shifts widget indices and breaks serialization
};
// Setup schedule preset widget to populate strength_schedule text field
nodeType.prototype.setupSchedulePresetWidget = function() {
const node = this;
// Find the schedule_preset dropdown widget
const schedulePresetWidget = this.widgets.find(w => w.name === "schedule_preset");
if (!schedulePresetWidget) return;
// Find the strength_schedule text widget
const strengthScheduleWidget = this.widgets.find(w => w.name === "strength_schedule");
if (!strengthScheduleWidget) return;
// Override callback to fill strength_schedule when preset is selected
const origCallback = schedulePresetWidget.callback;
schedulePresetWidget.callback = function(value) {
// Look up the preset value and fill the text field
const scheduleValue = SCHEDULE_PRESETS[value];
if (scheduleValue !== undefined) {
strengthScheduleWidget.value = scheduleValue;
node.setDirtyCanvas(true);
}
// Call original callback if it exists
if (origCallback) {
origCallback.call(this, value);
}
};
// Store reference for later
this.schedulePresetWidget = schedulePresetWidget;
this.strengthScheduleWidget = strengthScheduleWidget;
};
// Load user presets from localStorage (part of dual-persistence preset system)
nodeType.prototype.loadUserPresets = function(nodeName) {
const storageKey = `comfyui_model_layer_presets_${nodeName}`;
try {
const saved = localStorage.getItem(storageKey);
const presets = saved ? JSON.parse(saved) : {};
const count = Object.keys(presets).length;
if (count > 0) {
console.log(`[Model Layer Editor] Loaded ${count} preset(s) from browser storage for ${nodeName}`);
}
return presets;
} catch (e) {
console.warn(`[Model Layer Editor] Failed to load presets from browser storage:`, e);
return {};
}
};
// Save user presets to localStorage (called from Python via message)
nodeType.prototype.saveUserPreset = function(nodeName, presetName, presetData) {
const storageKey = `comfyui_model_layer_presets_${nodeName}`;
try {
const presets = this.loadUserPresets(nodeName);
presets[presetName] = presetData;
localStorage.setItem(storageKey, JSON.stringify(presets));
console.log(`[Model Layer Editor] Saved user preset '${presetName}' for ${nodeName}`);
} catch (e) {
console.warn("[Model Layer Editor] Failed to save user preset:", e);
}
};
// Apply a preset to all block toggles and strengths
nodeType.prototype.applyPreset = function(nodeName, presetName) {
const config = SELECTIVE_LOADER_PRESETS[nodeName];
if (!config) return;
// First check hardcoded presets
let preset = config.presets[presetName];
// If not found, check user presets in localStorage
if (!preset) {
const userPresets = this.loadUserPresets(nodeName);
preset = userPresets[presetName];
}
// If still not found, check if it's likely a file-saved preset that needs syncing
if (!preset) {
// Check if this preset name is in the dropdown (meaning Python knows about it from file)
const presetWidget = this.widgets.find(w => w.name === "preset");
const isInDropdown = presetWidget?.options?.values?.includes(presetName);
if (isInDropdown && nodeName.includes("ModelLayerEditor")) {
// It's a file-saved preset that hasn't been synced to browser storage yet
console.log(`[Model Layer Editor] Preset '${presetName}' exists in file but not browser storage. Run workflow to sync.`);
// Don't show alert - just keep current values until sync happens
} else {
console.log(`[Model Layer Editor] Preset '${presetName}' not found, keeping current values`);
}
return;
}
const allBlocks = config.blocks;
const enabledBlocks = preset.enabled === "ALL" ? allBlocks : preset.enabled;
const enabledSet = new Set(enabledBlocks);
const baseStrength = preset.strength;
const overrides = preset.overrides || {}; // Per-block strength overrides
// 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) {
// Use override if present, otherwise base strength
strWidget.value = overrides.hasOwnProperty(blockName) ? overrides[blockName] : baseStrength;
}
}
if (this.updateStringFromUI) {
this.updateStringFromUI();
}
this.setDirtyCanvas(true);
};
nodeType.prototype.setupBidirectionalSync = function(nodeName) {
const node = this;
const config = SELECTIVE_LOADER_PRESETS[nodeName];
if (!config) return;
const stringWidget = node.widgets.find(w => w.name === "block_weights_string");
if (!stringWidget) return;
node._lastStringValue = stringWidget.value || "";
node.parseStringToUI = function() {
const widget = node.widgets.find(w => w.name === "block_weights_string");
if (!widget || !widget.value?.trim() || widget._updating) return;
const input = widget.value.trim();
if (input.startsWith("%")) {
return;
}
const values = input.split(",").map(v => parseFloat(v.trim())).filter(v => !Number.isNaN(v));
if (values.length !== config.blocks.length) {
return;
}
for (let i = 0; i < config.blocks.length; i++) {
const blockName = config.blocks[i];
const toggleWidget = node.widgets.find(w => w.name === blockName);
const strWidget = node.widgets.find(w => w.name === `${blockName}_str`);
if (!toggleWidget || !strWidget) continue;
const value = values[i];
toggleWidget.value = value !== 0;
strWidget.value = value !== 0 ? value : 1.0;
}
node.setDirtyCanvas(true);
};
node.updateStringFromUI = function() {
const widget = node.widgets.find(w => w.name === "block_weights_string");
if (!widget || widget._updating) return;
const values = config.blocks.map((blockName) => {
const toggleWidget = node.widgets.find(w => w.name === blockName);
const strWidget = node.widgets.find(w => w.name === `${blockName}_str`);
if (!toggleWidget || !strWidget) {
return "1.00";
}
return toggleWidget.value ? Number(strWidget.value).toFixed(2) : "0.00";
});
widget._updating = true;
widget.value = values.join(", ");
widget._updating = false;
node._lastStringValue = widget.value;
};
node.checkAndSyncTextChanges = function() {
const widget = node.widgets.find(w => w.name === "block_weights_string");
if (!widget || widget._updating) return;
if (widget.value !== node._lastStringValue) {
node._lastStringValue = widget.value;
node.parseStringToUI();
}
};
const origOnMouseDown = node.onMouseDown;
node.onMouseDown = function(e, pos, canvas) {
node.checkAndSyncTextChanges();
if (origOnMouseDown) {
return origOnMouseDown.apply(this, arguments);
}
};
if (stringWidget.inputEl) {
stringWidget.inputEl.addEventListener("blur", () => {
node.checkAndSyncTextChanges();
});
stringWidget.inputEl.addEventListener("change", () => {
node.checkAndSyncTextChanges();
});
stringWidget.inputEl.addEventListener("input", () => {
node._lastStringValue = stringWidget.value;
node.parseStringToUI();
});
}
setTimeout(() => {
for (const blockName of config.blocks) {
const toggleWidget = node.widgets.find(w => w.name === blockName);
const strWidget = node.widgets.find(w => w.name === `${blockName}_str`);
if (!toggleWidget || !strWidget) continue;
const origToggleCallback = toggleWidget.callback;
toggleWidget.callback = function(value) {
if (origToggleCallback) {
origToggleCallback.call(this, value);
}
if (node.updateStringFromUI) {
node.updateStringFromUI();
}
};
const origStrCallback = strWidget.callback;
strWidget.callback = function(value) {
if (origStrCallback) {
origStrCallback.call(this, value);
}
if (node.updateStringFromUI) {
node.updateStringFromUI();
}
};
}
}, 200);
};
}
});
// Extension for ScheduledLoRALoader - just the schedule preset dropdown
app.registerExtension({
name: "ScheduledLoRA.PresetDropdown",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== "ScheduledLoRALoader") {
return;
}
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
if (origOnNodeCreated) {
origOnNodeCreated.apply(this, arguments);
}
const node = this;
// Setup schedule preset dropdown after node is created
setTimeout(() => {
// Find the schedule_preset dropdown widget
const schedulePresetWidget = node.widgets.find(w => w.name === "schedule_preset");
if (!schedulePresetWidget) return;
// Find the strength_schedule text widget
const strengthScheduleWidget = node.widgets.find(w => w.name === "strength_schedule");
if (!strengthScheduleWidget) return;
// Override callback to fill strength_schedule when preset is selected
const origCallback = schedulePresetWidget.callback;
schedulePresetWidget.callback = function(value) {
// Look up the preset value and fill the text field
const scheduleValue = SCHEDULE_PRESETS[value];
if (scheduleValue !== undefined) {
strengthScheduleWidget.value = scheduleValue;
node.setDirtyCanvas(true);
}
// Call original callback if it exists
if (origCallback) {
origCallback.call(this, value);
}
};
}, 50);
};
}
});