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:
co-authored by
Claude Opus 4.7
parent
a491a88f48
commit
490c5196ef
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user