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) <noreply@anthropic.com>
This commit is contained in:
Jason Hoku
2026-05-07 13:05:10 -07:00
co-authored by Claude Opus 4.7
parent a491a88f48
commit 490c5196ef
5 changed files with 102 additions and 27 deletions
+4
View File
@@ -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
+21 -13
View File
@@ -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)
+25 -13
View File
@@ -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:
@@ -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);
+24 -1
View File
@@ -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