feat: preview panel uses /configbuilder/preview endpoint
Replaces synchronous JS convertStateToConfigs (~280 lines) with a debounced (800ms) fetch to the new endpoint. Preview now renders literally what generate_config produces — drift impossible by construction. Stale-state badge surfaces network failures without blocking editing. Loading placeholder appears during the first-paint debounce window. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
9ff76dd8ab
commit
b6e2344f9b
@@ -9,7 +9,7 @@ import {
|
||||
parseLoraString,
|
||||
buildLoraString,
|
||||
getIterationCount,
|
||||
convertStateToConfigs,
|
||||
|
||||
countPromptCombinations,
|
||||
expandPromptPreview,
|
||||
getAvailableVAEs,
|
||||
@@ -3790,8 +3790,8 @@ function createVAEElement(node, vaeName, arrayIdx, vaeIdx, vaeList, vFolders) {
|
||||
|
||||
// --- LTX VIDEO SETTINGS SECTION ---
|
||||
|
||||
// Derive model_type from a configArray's models[] (matches the convention in
|
||||
// conf-builder-utilities.js:convertStateToConfigs). Returns "checkpoint" by default.
|
||||
// Derive model_type from a configArray's models[] (matches the Python-side
|
||||
// state_to_configs_json convention in config_builder_node.py). Returns "checkpoint" by default.
|
||||
function getConfigArrayModelType(configArray) {
|
||||
let modelType = "checkpoint";
|
||||
for (const m of (configArray?.models || [])) {
|
||||
@@ -6403,43 +6403,115 @@ export function renderRunSettingsSection(node, container) {
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
export function renderPreviewSection(container) {
|
||||
const section = document.createElement("div");
|
||||
section.className = "cb-section full-width";
|
||||
section.id = "cb-sec-preview";
|
||||
// Persisted preview panel height per node (keyed by node.id, so each node
|
||||
// remembers its own ratio across renderUI() rebuilds within a session).
|
||||
const _previewHeights = new Map();
|
||||
|
||||
// Uniform section header
|
||||
const header = createSectionHeader("📄", "JSON Preview", "preview");
|
||||
section.appendChild(header);
|
||||
export function renderPreviewSection(container, node) {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "cb-preview-panel";
|
||||
panel.id = "cb-sec-preview";
|
||||
|
||||
const preview = document.createElement("pre");
|
||||
preview.className = "cb-preview";
|
||||
preview.id = "json-preview";
|
||||
section.appendChild(preview);
|
||||
container.appendChild(section);
|
||||
// Restore prior height if user dragged it during this session
|
||||
const savedH = node && _previewHeights.get(node.id);
|
||||
if (savedH) panel.style.flexBasis = savedH + "px";
|
||||
|
||||
// Drag handle (top edge) — drag UP to grow the panel, DOWN to shrink it
|
||||
const handle = document.createElement("div");
|
||||
handle.className = "cb-preview-resize-handle";
|
||||
handle.title = "Drag to resize preview panel";
|
||||
panel.appendChild(handle);
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "cb-preview-panel-header";
|
||||
const title = document.createElement("span");
|
||||
title.className = "cb-preview-panel-title";
|
||||
title.textContent = "📄 Output Preview (configs_json)";
|
||||
const meta = document.createElement("span");
|
||||
meta.className = "cb-preview-panel-meta";
|
||||
meta.id = "json-preview-meta";
|
||||
header.appendChild(title);
|
||||
header.appendChild(meta);
|
||||
panel.appendChild(header);
|
||||
|
||||
const pre = document.createElement("pre");
|
||||
pre.className = "cb-preview-panel-content";
|
||||
pre.id = "json-preview";
|
||||
pre.textContent = "Loading preview…";
|
||||
panel.appendChild(pre);
|
||||
|
||||
container.appendChild(panel);
|
||||
|
||||
// Wire resize handle
|
||||
handle.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault();
|
||||
const startY = e.clientY;
|
||||
const startH = panel.getBoundingClientRect().height;
|
||||
handle.classList.add("dragging");
|
||||
const onMove = (ev) => {
|
||||
const dy = startY - ev.clientY; // up = larger
|
||||
const newH = Math.max(120, Math.min(2000, startH + dy));
|
||||
panel.style.flexBasis = newH + "px";
|
||||
if (node) _previewHeights.set(node.id, newH);
|
||||
};
|
||||
const onUp = () => {
|
||||
handle.classList.remove("dragging");
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
});
|
||||
}
|
||||
|
||||
function _setPreviewStale(node, errMsg) {
|
||||
const meta = node.htmlContainer?.querySelector("#json-preview-meta");
|
||||
if (meta) {
|
||||
meta.textContent = "⚠ stale: " + (errMsg || "").slice(0, 60);
|
||||
meta.style.color = "#c33";
|
||||
}
|
||||
}
|
||||
|
||||
function _setPreviewMeta(node, data) {
|
||||
const meta = node.htmlContainer?.querySelector("#json-preview-meta");
|
||||
if (!meta) return;
|
||||
let n = 0;
|
||||
try {
|
||||
const parsed = JSON.parse(data.configs_json);
|
||||
n = (parsed.configs || []).length;
|
||||
} catch (_) { /* ignore — server returned non-JSON; stale handler covers it */ }
|
||||
meta.textContent = `${n} config${n === 1 ? '' : 's'}`;
|
||||
meta.style.color = "";
|
||||
}
|
||||
|
||||
export function updatePreview(node) {
|
||||
const preview = node.htmlContainer.querySelector("#json-preview");
|
||||
const preview = node.htmlContainer?.querySelector("#json-preview");
|
||||
if (!preview) return;
|
||||
const configs = convertStateToConfigs(node.state);
|
||||
|
||||
// NOTE: This preview shows what convertStateToConfigs() produces (JS-side).
|
||||
// The ACTUAL configs_json output comes from generate_config() in config_builder_node.py.
|
||||
// Both must output the same fields — see SYNC WARNING in each file.
|
||||
// Build the full output object matching what configs_json will contain
|
||||
const output = { configs };
|
||||
if (node.state.distribution_enabled) {
|
||||
output._distribution = {
|
||||
enabled: true,
|
||||
worker_urls: (node.state.worker_urls || []).filter(u => u && u.trim()),
|
||||
claim_timeout: node.state.claim_timeout || 600,
|
||||
use_master_encoding: node.state.use_master_encoding || false,
|
||||
sync_models_to_workers: node.state.sync_models_to_workers || false
|
||||
};
|
||||
}
|
||||
if (node._previewTimer) clearTimeout(node._previewTimer);
|
||||
node._previewSeq = (node._previewSeq || 0) + 1;
|
||||
const seq = node._previewSeq;
|
||||
|
||||
preview.textContent = JSON.stringify(output, null, 2);
|
||||
node._previewTimer = setTimeout(async () => {
|
||||
try {
|
||||
const resp = await fetch("/configbuilder/preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ state: node.state }),
|
||||
});
|
||||
if (seq !== node._previewSeq) return; // newer request superseded us
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text();
|
||||
throw new Error(errText || `HTTP ${resp.status}`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
preview.textContent = data.configs_json;
|
||||
_setPreviewMeta(node, data);
|
||||
} catch (e) {
|
||||
if (seq !== node._previewSeq) return;
|
||||
_setPreviewStale(node, e.message || String(e));
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
|
||||
export async function renderUI(node, availableLoras, modelLists, loraFolders, availableSessions, availableConfigs, refreshAllConfigBuilders) {
|
||||
@@ -6688,8 +6760,10 @@ export async function renderUI(node, availableLoras, modelLists, loraFolders, av
|
||||
renderCooldownSection(node, mainContent);
|
||||
renderRunSettingsSection(node, mainContent);
|
||||
|
||||
// JSON Preview Section
|
||||
renderPreviewSection(mainContent);
|
||||
// Output Preview — rendered as a SIBLING of layoutWrapper (not inside
|
||||
// mainContent), so it sits as a fixed/resizable panel at the bottom of
|
||||
// the node, always visible regardless of scroll position.
|
||||
renderPreviewSection(root, node);
|
||||
updatePreview(node);
|
||||
|
||||
// Restore scroll position on main content
|
||||
|
||||
@@ -607,289 +607,7 @@ export function getIterationCount(configArray) {
|
||||
return m_count * l_count * v_count * s_count * sch_count * st_count * c_count * p_count * a_count * r_count;
|
||||
}
|
||||
|
||||
// --- CONFIG CONVERSION ---
|
||||
// ============================================================================
|
||||
// SYNC WARNING: This function MUST stay in sync with the Python-side
|
||||
// generate_config() in config_builder_node.py.
|
||||
// That function produces the actual configs_json consumed by the sampler node.
|
||||
// If you add a new config field here, add it there too (and vice versa).
|
||||
// Fields consumed by config_utils.expand_configs() must be output by BOTH.
|
||||
// ============================================================================
|
||||
|
||||
export function convertStateToConfigs(state) {
|
||||
const configs = [];
|
||||
const split = (val) => {
|
||||
if (Array.isArray(val)) return val.filter(s => s);
|
||||
return String(val).split(",").map(s => s.trim()).filter(s => s);
|
||||
};
|
||||
|
||||
// Global prompts from state (used when per-config prompts not set)
|
||||
const globalPositiveGroups = state.global_positive_groups || [];
|
||||
const globalNegative = state.global_negative || "";
|
||||
|
||||
state.config_arrays.forEach(configArray => {
|
||||
// Process LoRAs - filter out bypassed entries
|
||||
let loras = configArray.loras.filter(l => {
|
||||
if (!l || l === "None") return false;
|
||||
const parsed = parseLoraString(l);
|
||||
return !configArray.lora_bypass_states?.[parsed.name];
|
||||
});
|
||||
|
||||
// Apply weight arrays (bracket notation) for LoRAs with multiple strengths
|
||||
const wa = configArray.lora_weight_arrays || {};
|
||||
loras = loras.map(l => {
|
||||
const parsed = parseLoraString(l);
|
||||
const modelArr = wa[parsed.name + "_model"];
|
||||
const clipArr = wa[parsed.name + "_clip"];
|
||||
if (modelArr && modelArr.length > 1) {
|
||||
const modelPart = "[" + modelArr.join(", ") + "]";
|
||||
const clipPart = (clipArr && clipArr.length > 1) ? "[" + clipArr.join(", ") + "]" : parsed.clip_str.toFixed(2);
|
||||
return parsed.name + ":" + modelPart + ":" + clipPart;
|
||||
}
|
||||
if (clipArr && clipArr.length > 1) {
|
||||
return parsed.name + ":" + parsed.model_str.toFixed(2) + ":[" + clipArr.join(", ") + "]";
|
||||
}
|
||||
return l;
|
||||
});
|
||||
|
||||
// Convert loras array to proper format
|
||||
let loraValue;
|
||||
if (loras.length === 0) {
|
||||
loraValue = "None";
|
||||
} else if (loras.length === 1) {
|
||||
loraValue = loras[0];
|
||||
} else {
|
||||
// Multiple loras: combine with " + " separator
|
||||
loraValue = loras.join(" + ");
|
||||
}
|
||||
|
||||
// Process Models - handle object format {path, type} and string format
|
||||
let modelType = "checkpoint";
|
||||
let finalModels = [];
|
||||
(configArray.models || []).forEach(m => {
|
||||
if (typeof m === 'object' && m !== null) {
|
||||
// Trust the user's type selection even if no file is picked yet
|
||||
// (otherwise switching to LTX/diffusion_model type without yet
|
||||
// selecting a file silently falls back to checkpoint).
|
||||
if (m.type) modelType = m.type;
|
||||
if (m.path && m.path !== "None" && !configArray.model_bypass_states?.[m.path]) {
|
||||
finalModels.push(m.path);
|
||||
}
|
||||
} else if (typeof m === 'string' && m && m !== "None" && !configArray.model_bypass_states?.[m]) {
|
||||
finalModels.push(m);
|
||||
}
|
||||
});
|
||||
|
||||
// Process VAEs - filter out bypassed entries
|
||||
const vaes = (configArray.vaes || []).filter(v => v && v !== "None" && !configArray.vae_bypass_states?.[v]);
|
||||
|
||||
const config = {
|
||||
sampler: split(configArray.samplers),
|
||||
scheduler: split(configArray.schedulers),
|
||||
steps: configArray.steps.split(",").map(s => parseInt(s)),
|
||||
cfg: configArray.cfg.split(",").map(s => parseFloat(s)),
|
||||
lora: loraValue,
|
||||
model: finalModels.length > 1 ? finalModels : finalModels[0] || "None"
|
||||
};
|
||||
|
||||
// Add attention_mode if not just "default"
|
||||
const attentionModes = (configArray.attention_modes || ["default"]).filter(a => a);
|
||||
if (attentionModes.length > 0 && !(attentionModes.length === 1 && attentionModes[0] === "default")) {
|
||||
config.attention_mode = attentionModes.length > 1 ? attentionModes : attentionModes[0];
|
||||
}
|
||||
|
||||
// Per-config resolutions (override sampler's resolutions_json)
|
||||
if (configArray.resolutions && configArray.resolutions.length > 0) {
|
||||
config.resolutions = configArray.resolutions.map(r => {
|
||||
const parts = r.split("x").map(Number);
|
||||
return [parts[0], parts[1]];
|
||||
});
|
||||
}
|
||||
|
||||
// Add extra model & sampling options if enabled
|
||||
if (configArray.model_sampling_override && configArray.model_sampling_override !== "none") {
|
||||
config.model_sampling_override = configArray.model_sampling_override;
|
||||
if (configArray.model_sampling_override === "flux") {
|
||||
config.model_sampling_flux_max_shift = configArray.model_sampling_flux_max_shift || "1.15";
|
||||
config.model_sampling_flux_base_shift = configArray.model_sampling_flux_base_shift || "0.5";
|
||||
} else {
|
||||
config.model_sampling_shift = configArray.model_sampling_shift || "1.73";
|
||||
}
|
||||
}
|
||||
if (configArray.use_advanced_sampling) {
|
||||
config.use_advanced_sampling = true;
|
||||
config.advanced_guider = configArray.advanced_guider || "cfg_guider";
|
||||
config.advanced_scheduler = configArray.advanced_scheduler || "basic";
|
||||
}
|
||||
if (configArray.use_flux_guidance) {
|
||||
config.use_flux_guidance = true;
|
||||
config.flux_guidance_value = configArray.flux_guidance_value || "3.5";
|
||||
}
|
||||
|
||||
// Add VAE if any are selected (not "None")
|
||||
if (vaes.length > 0) {
|
||||
config.vae = vaes.length > 1 ? vaes : vaes[0];
|
||||
}
|
||||
|
||||
// Add model_type and related fields for non-checkpoint models
|
||||
if (modelType !== "checkpoint") {
|
||||
config.model_type = modelType;
|
||||
const textEncoders = (configArray.text_encoders || []).filter(te => te && te !== "None" && !configArray.te_bypass_states?.[te]);
|
||||
if (textEncoders.length > 0) config.text_encoders = textEncoders;
|
||||
if (configArray.clip_type) config.clip_type = configArray.clip_type;
|
||||
if (modelType === "gguf" && configArray.gguf_options) {
|
||||
config.gguf_options = configArray.gguf_options;
|
||||
}
|
||||
if (modelType === "ltx_video" && configArray.ltx_video) {
|
||||
// Flatten LTX-specific fields into the top-level config so the orchestrator gets them.
|
||||
const ltx = configArray.ltx_video;
|
||||
if (ltx.clip_models) config.clip_models = ltx.clip_models;
|
||||
if (ltx.vae_video) config.vae_video = ltx.vae_video;
|
||||
if (ltx.vae_audio) config.vae_audio = ltx.vae_audio;
|
||||
if (ltx.latent_upscaler) config.latent_upscaler = ltx.latent_upscaler;
|
||||
if (ltx.duration_seconds != null) config.duration_seconds = ltx.duration_seconds;
|
||||
if (ltx.frame_rate != null) config.frame_rate = ltx.frame_rate;
|
||||
if (ltx.sampler_stage1) config.sampler_stage1 = ltx.sampler_stage1;
|
||||
if (ltx.sampler_stage2) config.sampler_stage2 = ltx.sampler_stage2;
|
||||
if (ltx.sigmas_stage1) config.sigmas_stage1 = ltx.sigmas_stage1;
|
||||
if (ltx.sigmas_stage2) config.sigmas_stage2 = ltx.sigmas_stage2;
|
||||
if (ltx.input_image != null) config.input_image = ltx.input_image;
|
||||
if (ltx.image_strength_stage1 != null) config.image_strength_stage1 = ltx.image_strength_stage1;
|
||||
if (ltx.image_strength_stage2 != null) config.image_strength_stage2 = ltx.image_strength_stage2;
|
||||
if (ltx.img_compression != null) config.img_compression = ltx.img_compression;
|
||||
if (ltx.audio_mode) config.audio_mode = ltx.audio_mode;
|
||||
}
|
||||
}
|
||||
|
||||
// Add lora_omit_triggers if present
|
||||
if (configArray.lora_omit_triggers && configArray.lora_omit_triggers.length > 0) {
|
||||
config.lora_omit_triggers = configArray.lora_omit_triggers;
|
||||
}
|
||||
|
||||
// Add lora_triggerwords_append_settings if any placements are configured
|
||||
if (configArray.lora_triggerwords_append_settings && Object.keys(configArray.lora_triggerwords_append_settings).length > 0) {
|
||||
const settings = {};
|
||||
for (const [loraName, placement] of Object.entries(configArray.lora_triggerwords_append_settings)) {
|
||||
if (placement !== "none") {
|
||||
settings[loraName] = placement;
|
||||
}
|
||||
}
|
||||
if (Object.keys(settings).length > 0) {
|
||||
config.lora_triggerwords_append_settings = settings;
|
||||
}
|
||||
}
|
||||
|
||||
// Add lora_bypass_states if any are set
|
||||
if (configArray.lora_bypass_states && Object.keys(configArray.lora_bypass_states).length > 0) {
|
||||
config.lora_bypass_states = configArray.lora_bypass_states;
|
||||
}
|
||||
|
||||
// Add model_bypass_states if any are set
|
||||
if (configArray.model_bypass_states && Object.keys(configArray.model_bypass_states).length > 0) {
|
||||
config.model_bypass_states = configArray.model_bypass_states;
|
||||
}
|
||||
|
||||
// Add lora_strength_lock if any are set
|
||||
if (configArray.lora_strength_lock && Object.keys(configArray.lora_strength_lock).length > 0) {
|
||||
config.lora_strength_lock = configArray.lora_strength_lock;
|
||||
}
|
||||
|
||||
// vae_bypass_states and te_bypass_states are internal UI state only.
|
||||
// They control filtering (lines 500, 545) but should NOT be in config output.
|
||||
// Bypass state persistence is handled by node.saveState() separately.
|
||||
// if (configArray.vae_bypass_states && Object.keys(configArray.vae_bypass_states).length > 0) {
|
||||
// config.vae_bypass_states = configArray.vae_bypass_states;
|
||||
// }
|
||||
// if (configArray.te_bypass_states && Object.keys(configArray.te_bypass_states).length > 0) {
|
||||
// config.te_bypass_states = configArray.te_bypass_states;
|
||||
// }
|
||||
|
||||
// Add seed_behavior if set to randomize
|
||||
if (configArray.seed_behavior === "randomize") {
|
||||
config.seed_behavior = "randomize";
|
||||
}
|
||||
|
||||
// Add full_run_seed_behavior if not fixed
|
||||
if (configArray.full_run_seed_behavior && configArray.full_run_seed_behavior !== "fixed") {
|
||||
config.full_run_seed_behavior = configArray.full_run_seed_behavior;
|
||||
}
|
||||
|
||||
// Add full_run_seed if set (overrides node seed)
|
||||
if (configArray.full_run_seed && configArray.full_run_seed > 0) {
|
||||
config.full_run_seed = configArray.full_run_seed;
|
||||
}
|
||||
|
||||
// ==== PROMPT HANDLING ====
|
||||
// Priority: per-config > global > omit (node inputs used as fallback)
|
||||
if (configArray.use_custom_prompts && configArray.positive_prompt_groups && configArray.positive_prompt_groups.length > 0) {
|
||||
config.positive = configArray.positive_prompt_groups;
|
||||
if (configArray.negative_prompt) {
|
||||
config.negative = configArray.negative_prompt;
|
||||
}
|
||||
config._prompt_source = "custom";
|
||||
} else if (globalPositiveGroups.length > 0) {
|
||||
config.positive = globalPositiveGroups;
|
||||
if (globalNegative) {
|
||||
config.negative = globalNegative;
|
||||
}
|
||||
config._prompt_source = "global";
|
||||
}
|
||||
|
||||
// ==== MODEL PROMPT PREFIX/SUFFIX ====
|
||||
// Quality tags prepended/appended to ALL prompts for this config
|
||||
if (configArray.model_prompt_prefix && configArray.model_prompt_prefix.trim()) {
|
||||
config.model_prompt_prefix = configArray.model_prompt_prefix.trim();
|
||||
}
|
||||
if (configArray.model_prompt_suffix && configArray.model_prompt_suffix.trim()) {
|
||||
config.model_prompt_suffix = configArray.model_prompt_suffix.trim();
|
||||
}
|
||||
|
||||
configs.push(config);
|
||||
});
|
||||
|
||||
// Session-level settings (not per-config, applied globally)
|
||||
// These are attached as a special _session_settings key alongside the configs array
|
||||
const sessionSettings = {};
|
||||
|
||||
// Upscaling settings (pipeline-based, filter out inactive pipelines and steps)
|
||||
if (state.upscaling && state.upscaling.enabled && state.upscaling.pipelines) {
|
||||
const activePipelines = state.upscaling.pipelines
|
||||
.filter(p => p.active !== false)
|
||||
.map(p => ({
|
||||
...p,
|
||||
steps: (p.steps || []).filter(s => s.active !== false).map(s => ({ ...s }))
|
||||
}))
|
||||
.filter(p => p.steps.length > 0);
|
||||
if (activePipelines.length > 0) {
|
||||
sessionSettings.upscaling = {
|
||||
enabled: true,
|
||||
save_pre_upscale: state.upscaling.save_pre_upscale || false,
|
||||
run_upscales_at_end: state.upscaling.run_upscales_at_end || false,
|
||||
hires_prompt_adjust: state.upscaling.hires_prompt_adjust || false,
|
||||
hires_prompt_behavior: state.upscaling.hires_prompt_behavior || "append_end",
|
||||
hires_prompt_text: state.upscaling.hires_prompt_text || "",
|
||||
pipelines: activePipelines
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Cooldown settings
|
||||
if (state.cooldown && state.cooldown.enabled) {
|
||||
sessionSettings.cooldown = { ...state.cooldown };
|
||||
}
|
||||
|
||||
// Start At Job # (skip to a specific job number)
|
||||
if (state.start_at_job && parseInt(state.start_at_job) > 0) {
|
||||
sessionSettings.start_at_job = parseInt(state.start_at_job);
|
||||
}
|
||||
|
||||
// Attach session settings to the configs output if any are enabled
|
||||
if (Object.keys(sessionSettings).length > 0) {
|
||||
configs._session_settings = sessionSettings;
|
||||
}
|
||||
|
||||
return configs;
|
||||
}
|
||||
|
||||
export function convertConfigsToConfigArrays(configs) {
|
||||
if (!configs || !Array.isArray(configs)) {
|
||||
|
||||
Reference in New Issue
Block a user