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:
Jason Hoku
2026-05-07 09:59:10 -07:00
co-authored by Claude Opus 4.7
parent 9ff76dd8ab
commit b6e2344f9b
2 changed files with 108 additions and 316 deletions
@@ -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
-282
View File
@@ -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)) {