From 490c5196ef9ec5a0df461a9ad67c9f47784b3f2d Mon Sep 17 00:00:00 2001 From: Jason Hoku Date: Thu, 7 May 2026 13:05:10 -0700 Subject: [PATCH] feat: configurable image save format (webp/png/jpg) Adds a dropdown to the Builder UI's Run Settings section letting users pick the saved image file format. Default remains webp (current behavior). State plumbed through state_to_configs_json -> _session_settings -> orchestrator -> save_image_to_disk. PNG users will see lossless saves; JPG users smaller files at quality 95. Co-Authored-By: Claude Opus 4.7 (1M context) --- config_builder_node.py | 4 ++ generation_orchestrator.py | 34 ++++++++++------- image_generation.py | 38 ++++++++++++------- .../conf-builder-config-management.js | 28 ++++++++++++++ web/conf_builder/conf-builder-main.js | 25 +++++++++++- 5 files changed, 102 insertions(+), 27 deletions(-) diff --git a/config_builder_node.py b/config_builder_node.py index f99b716..1050944 100644 --- a/config_builder_node.py +++ b/config_builder_node.py @@ -824,6 +824,10 @@ class UltimateConfigBuilder: start_at_job = state.get("start_at_job", 0) if start_at_job and int(start_at_job) > 0: session_settings["start_at_job"] = int(start_at_job) + # Image save format (only emit when non-default to keep configs_json clean) + image_format = state.get("image_format", "webp") + if image_format and image_format != "webp": + session_settings["image_format"] = image_format if session_settings: output_obj["_session_settings"] = session_settings diff --git a/generation_orchestrator.py b/generation_orchestrator.py index 17c9521..113e34d 100644 --- a/generation_orchestrator.py +++ b/generation_orchestrator.py @@ -103,7 +103,7 @@ def extract_remote_vae_url(vae_string): def _flush_pending_batch(pending_batch, current_vae_is_remote, current_remote_vae_url, per_config_remote_workers, use_remote_vae, remote_vae_worker, loaded_vae, paths, existing_data, session_name, manifest_path, unique_id, - config_overrides_vae=False): + config_overrides_vae=False, image_format="webp"): """Flush pending batch using the appropriate VAE decode method. Handles three-way dispatch: @@ -123,11 +123,11 @@ def _flush_pending_batch(pending_batch, current_vae_is_remote, current_remote_va flush_batch_with_remote_vae(pending_batch, worker, existing_data, session_name) else: print(f"[GridTester] ⚠️ No remote worker for {current_remote_vae_url}, falling back to local VAE") - flush_batch_with_vae(pending_batch, loaded_vae, paths["images"], existing_data, session_name, manifest_path, unique_id) + flush_batch_with_vae(pending_batch, loaded_vae, paths["images"], existing_data, session_name, manifest_path, unique_id, image_format=image_format) elif not config_overrides_vae and use_remote_vae and remote_vae_worker: flush_batch_with_remote_vae(pending_batch, remote_vae_worker, existing_data, session_name) else: - flush_batch_with_vae(pending_batch, loaded_vae, paths["images"], existing_data, session_name, manifest_path, unique_id) + flush_batch_with_vae(pending_batch, loaded_vae, paths["images"], existing_data, session_name, manifest_path, unique_id, image_format=image_format) def _cleanup_per_config_remote_workers(per_config_remote_workers): @@ -971,6 +971,9 @@ def run_generation_loop( start_at_job = int(session_settings.get("start_at_job", 0)) if session_settings else 0 if start_at_job > 0: print(f"[GridTester] ⏭️ Start At Job #{start_at_job} — skipping earlier jobs") + image_format = session_settings.get("image_format", "webp") if session_settings else "webp" + if image_format != "webp": + print(f"[GridTester] 🖼️ Image save format: {image_format}") job_durations = [] deferred_upscale_queue = [] eta_start_time = time.time() @@ -1240,7 +1243,7 @@ def run_generation_loop( _flush_pending_batch(pending_batch, current_vae_is_remote, current_remote_vae_url, per_config_remote_workers, use_remote_vae, remote_vae_worker, loaded_vae, paths, existing_data, session_name, paths["manifest"], unique_id, - config_overrides_vae=config_overrides_vae) + config_overrides_vae=config_overrides_vae, image_format=image_format) pending_batch = [] _cleanup_per_config_remote_workers(per_config_remote_workers) @@ -1431,7 +1434,7 @@ def run_generation_loop( _flush_pending_batch(pending_batch, current_vae_is_remote, current_remote_vae_url, per_config_remote_workers, use_remote_vae, remote_vae_worker, loaded_vae, paths, existing_data, session_name, paths["manifest"], unique_id, - config_overrides_vae=config_overrides_vae) + config_overrides_vae=config_overrides_vae, image_format=image_format) pending_batch = [] if target_vae == "Default": @@ -1558,7 +1561,7 @@ def run_generation_loop( _flush_pending_batch(pending_batch, current_vae_is_remote, current_remote_vae_url, per_config_remote_workers, use_remote_vae, remote_vae_worker, loaded_vae, paths, existing_data, session_name, paths["manifest"], unique_id, - config_overrides_vae=config_overrides_vae) + config_overrides_vae=config_overrides_vae, image_format=image_format) pending_batch = [] _cleanup_per_config_remote_workers(per_config_remote_workers) @@ -2178,7 +2181,7 @@ def run_generation_loop( _flush_pending_batch(pending_batch, current_vae_is_remote, current_remote_vae_url, per_config_remote_workers, use_remote_vae, remote_vae_worker, loaded_vae, paths, existing_data, session_name, paths["manifest"], unique_id, - config_overrides_vae=config_overrides_vae) + config_overrides_vae=config_overrides_vae, image_format=image_format) pending_batch = [] _cleanup_per_config_remote_workers(per_config_remote_workers) @@ -2228,7 +2231,7 @@ def run_generation_loop( _flush_pending_batch(pending_batch, current_vae_is_remote, current_remote_vae_url, per_config_remote_workers, use_remote_vae, remote_vae_worker, loaded_vae, paths, existing_data, session_name, paths["manifest"], unique_id, - config_overrides_vae=config_overrides_vae) + config_overrides_vae=config_overrides_vae, image_format=image_format) pending_batch = [] # ==== FINALIZATION ==== @@ -2236,7 +2239,7 @@ def run_generation_loop( _flush_pending_batch(pending_batch, current_vae_is_remote, current_remote_vae_url, per_config_remote_workers, use_remote_vae, remote_vae_worker, loaded_vae, paths, existing_data, session_name, paths["manifest"], unique_id, - config_overrides_vae=config_overrides_vae) + config_overrides_vae=config_overrides_vae, image_format=image_format) # ==== DEFERRED UPSCALE PHASE (must run before cleanup so VAE/model/CLIP are still loaded) ==== if deferred_upscale_queue and session_settings and session_settings.get("upscaling", {}).get("run_upscales_at_end", False): @@ -2646,6 +2649,11 @@ def _run_distributed_generation( if session_settings: manager._session_settings = session_settings + # Extract image format for local master saves + image_format = session_settings.get("image_format", "webp") if session_settings else "webp" + if image_format != "webp": + print(f"[GridTester] 🖼️ Image save format (distributed master): {image_format}") + manager.populate_jobs( expanded, input_jobs, existing_data, overwrite_existing, has_optional_inputs, lora_triggerwords_mode @@ -2843,7 +2851,7 @@ def _run_distributed_generation( per_config_remote_workers, use_remote_vae, remote_vae_worker, loaded_vae, paths, existing_data, session_name, paths["manifest"], unique_id, - config_overrides_vae=config_overrides_vae + config_overrides_vae=config_overrides_vae, image_format=image_format ) for jid in pending_job_ids: manager.complete_job(jid) @@ -2979,7 +2987,7 @@ def _run_distributed_generation( per_config_remote_workers, use_remote_vae, remote_vae_worker, loaded_vae, paths, existing_data, session_name, paths["manifest"], unique_id, - config_overrides_vae=config_overrides_vae + config_overrides_vae=config_overrides_vae, image_format=image_format ) for jid in pending_job_ids: manager.complete_job(jid) @@ -3000,7 +3008,7 @@ def _run_distributed_generation( per_config_remote_workers, use_remote_vae, remote_vae_worker, loaded_vae, paths, existing_data, session_name, paths["manifest"], unique_id, - config_overrides_vae=config_overrides_vae + config_overrides_vae=config_overrides_vae, image_format=image_format ) for jid in pending_job_ids: manager.complete_job(jid) @@ -3016,7 +3024,7 @@ def _run_distributed_generation( per_config_remote_workers, use_remote_vae, remote_vae_worker, loaded_vae, paths, existing_data, session_name, paths["manifest"], unique_id, - config_overrides_vae=config_overrides_vae + config_overrides_vae=config_overrides_vae, image_format=image_format ) for jid in pending_job_ids: manager.complete_job(jid) diff --git a/image_generation.py b/image_generation.py index c48b710..b7270e0 100644 --- a/image_generation.py +++ b/image_generation.py @@ -797,10 +797,10 @@ def print_generation_progress(current_job, total_jobs, config, width, height, du print(f"{'='*80}") -def flush_batch_with_vae(pending_batch, vae, img_dir, existing_data, session_name, manifest_path=None, unique_id=None): +def flush_batch_with_vae(pending_batch, vae, img_dir, existing_data, session_name, manifest_path=None, unique_id=None, image_format="webp"): """ Flush a batch of latents by decoding them with VAE and saving. - + Args: pending_batch: List of (latent_samples, metadata) tuples vae: VAE model for decoding @@ -809,31 +809,43 @@ def flush_batch_with_vae(pending_batch, vae, img_dir, existing_data, session_nam session_name: Session name for filenames manifest_path: Path to manifest.json file (optional, enables disk syncing) unique_id: Node unique ID for dashboard updates (optional, enables dashboard updates) - + image_format: Image file format — "webp" (default), "png", or "jpg" + Returns: int: Number of images saved """ if not pending_batch: return 0 - + import random - + saved_count = 0 - + + # Normalise format string + fmt = (image_format or "webp").lower().strip() + if fmt not in ("webp", "png", "jpg", "jpeg"): + fmt = "webp" + ext = "jpg" if fmt in ("jpg", "jpeg") else fmt + for latent_samples, meta in pending_batch: # Decode latent to image image = decode_latent_with_vae(vae, latent_samples) - + # Generate ID using same format as remote_vae ts = int(time.time() * 100000) + random.randint(0, 1000) meta["id"] = ts - - # Generate filename using webp format (like remote_vae) - filename = f"img_{meta['id']}.webp" - - # Save image as webp + + # Generate filename with the chosen extension + filename = f"img_{meta['id']}.{ext}" + + # Save image in chosen format filepath = os.path.join(img_dir, filename) - image.save(filepath, quality=80) + if fmt == "png": + image.save(filepath, format="PNG") + elif fmt in ("jpg", "jpeg"): + image.save(filepath, format="JPEG", quality=95) + else: + image.save(filepath, quality=80) # Normalize denoise to int if it's 1.0 (like remote_vae) if meta.get("denoise") == 1.0: diff --git a/web/conf_builder/conf-builder-config-management.js b/web/conf_builder/conf-builder-config-management.js index 6c01415..6b13d1e 100644 --- a/web/conf_builder/conf-builder-config-management.js +++ b/web/conf_builder/conf-builder-config-management.js @@ -6343,6 +6343,7 @@ export function renderCooldownSection(node, container) { // ============================================================================ export function renderRunSettingsSection(node, container) { if (node.state.start_at_job === undefined) node.state.start_at_job = 0; + if (node.state.image_format === undefined) node.state.image_format = "webp"; const section = document.createElement("div"); section.className = "cb-section full-width"; @@ -6386,6 +6387,33 @@ export function renderRunSettingsSection(node, container) { content.appendChild(jobDiv); content.appendChild(jobDesc); + // Image Format dropdown + const fmtDiv = document.createElement("div"); + fmtDiv.style.cssText = "margin-top: 10px; display: flex; align-items: center; gap: 8px;"; + const fmtLabel = document.createElement("label"); + fmtLabel.textContent = "Image Save Format:"; + fmtLabel.style.cssText = "font-size: 12px; color: #ccc; white-space: nowrap;"; + const fmtSelect = document.createElement("select"); + fmtSelect.style.cssText = "background: #1a1a1a; color: #ccc; border: 1px solid #444; border-radius: 4px; padding: 4px 6px; font-size: 12px;"; + [["webp", "WebP (default, smallest)"], ["png", "PNG (lossless)"], ["jpg", "JPG (small, lossy)"]].forEach(([val, label]) => { + const opt = document.createElement("option"); + opt.value = val; + opt.textContent = label; + if (val === (node.state.image_format || "webp")) opt.selected = true; + fmtSelect.appendChild(opt); + }); + fmtSelect.onchange = () => { + node.state.image_format = fmtSelect.value; + node.saveState(); + }; + const fmtDesc = document.createElement("div"); + fmtDesc.style.cssText = "font-size: 9px; color: #666; margin-top: 2px;"; + fmtDesc.textContent = "File format for saved images. PNG is lossless. JPG saves at quality 95."; + fmtDiv.appendChild(fmtLabel); + fmtDiv.appendChild(fmtSelect); + content.appendChild(fmtDiv); + content.appendChild(fmtDesc); + section.appendChild(header); section.appendChild(content); container.appendChild(section); diff --git a/web/conf_builder/conf-builder-main.js b/web/conf_builder/conf-builder-main.js index db4c059..8164a37 100644 --- a/web/conf_builder/conf-builder-main.js +++ b/web/conf_builder/conf-builder-main.js @@ -55,10 +55,32 @@ app.registerExtension({ if (!configWidget) return result; // 1. Synchronous Setup (CRITICAL: Must happen immediately) + // The "converted-widget" trick collapses the LiteGraph slot, but on the + // modern Vue node renderer a per-widget DOM element may still mount and + // peek out under the HTML overlay. As a belt-and-suspenders, also hide + // the actual DOM element when it appears (poll briefly until it does). this.widgets.forEach(w => { w.type = "converted-widget"; w.computeSize = () => [0, -4]; }); + // Capture references to the python-side widgets we want to hide + // BEFORE we add the DOM widget below (otherwise we'd hide the UI). + const _widgetsToHide = this.widgets.slice(); + const _hideWidgetEls = () => { + _widgetsToHide.forEach(w => { + const el = w.element || w.inputEl; + if (el && el.style && el.style.display !== "none") { + el.style.display = "none"; + } + }); + }; + _hideWidgetEls(); + let _hideAttempts = 0; + const _hideRetry = () => { + _hideWidgetEls(); + if (++_hideAttempts < 40) setTimeout(_hideRetry, 50); + }; + setTimeout(_hideRetry, 50); // Assign the widget reference immediately so onConfigure can find it this.configWidget = configWidget; @@ -166,7 +188,8 @@ app.registerExtension({ seconds: 5, every_n: 1, clear_vram: false - } + }, + image_format: "webp" }; // Create HTML container immediately