Files
ai-toolkit/extensions_built_in/inference_engine/engine.py
T
2026-09-10 18:23:23 -06:00

753 lines
31 KiB
Python

"""Engine: request queue + holder lifecycle + generation with streamed frames.
Runs on the process main thread (`run_forever`); the HTTP server hands it
GenerationJobs from its own thread and reads the frames each job emits.
"""
import gc
import glob
import json
import os
import queue
import shutil
import threading
import time
import traceback
import uuid
from typing import Callable, Dict, List, Optional
import torch
from toolkit.basic import flush
from toolkit.config_modules import GenerateImageConfig, ModelConfig
from toolkit.models.registry import OUTPUT_EXT, get_arch_entry
from toolkit.models.v2.pool import ComponentPool
from toolkit.inference_lora import InferenceLoRA, LoRAStack
from toolkit.util.get_model import LEGACY_ARCHS, get_model_class
from .latent_preview import preview_info
from .protocol import encode_frame, tensor_payload
END = None # frames-queue terminator (after the "end" frame)
class GenerationCancelled(Exception):
pass
class GenerationJob:
def __init__(self, model: dict, sample: dict, stream: Optional[dict] = None):
self.request_id = uuid.uuid4().hex[:12]
self.model = model
self.sample = sample
self.stream = {"latents": "raw", "every_n_steps": 1, "max_frames": 4}
self.stream.update(stream or {})
# every frame so far (latents: only the newest kept) so a client can
# attach late / reattach after a reload and see the same stream
self.history: List[bytes] = []
self._latent_index: Optional[int] = None
self._subscribers: List["queue.Queue[Optional[bytes]]"] = []
self._sub_lock = threading.Lock()
self.finished = False
self.cancel = threading.Event()
self.status = "queued"
self.created_at = time.time()
self.started_at: Optional[float] = None
self.finished_at: Optional[float] = None
self.results: List[dict] = []
self.error: Optional[str] = None
self.step = 0
self.total_steps: Optional[int] = None
def emit(self, type_: str, payload: bytes = b"", **fields):
header = {"type": type_, "request_id": self.request_id, **fields}
data = encode_frame(header, payload)
with self._sub_lock:
if type_ == "latent":
if self._latent_index is not None:
self.history[self._latent_index] = data
else:
self._latent_index = len(self.history)
self.history.append(data)
else:
self.history.append(data)
for q in self._subscribers:
q.put(data)
def subscribe(self, replay: bool = True) -> "queue.Queue[Optional[bytes]]":
"""A queue that receives this job's frames (history first when
replay) and END once the job is finished."""
q: "queue.Queue[Optional[bytes]]" = queue.Queue()
with self._sub_lock:
if replay:
for data in self.history:
q.put(data)
if self.finished:
q.put(END)
else:
self._subscribers.append(q)
return q
def finish(self):
self.finished_at = time.time()
self.emit("end", status=self.status)
with self._sub_lock:
self.finished = True
for q in self._subscribers:
q.put(END)
self._subscribers = []
def info(self) -> dict:
return {
"request_id": self.request_id,
"status": self.status,
"arch": self.model.get("arch"),
"prompt": self.sample.get("prompt", ""),
"step": self.step,
"total_steps": self.total_steps,
"created_at": self.created_at,
"started_at": self.started_at,
"finished_at": self.finished_at,
"results": self.results,
"error": self.error,
}
def _model_key(model: dict) -> str:
return json.dumps({k: v for k, v in sorted(model.items()) if v is not None}, sort_keys=True)
# settings that can change on a loaded holder without reloading any weights:
# the memory manager is detached and re-attached at the new fractions
OFFLOAD_KEYS = ("layer_offloading", "layer_offloading_transformer_percent", "layer_offloading_text_encoder_percent")
# LoRAs ride the request's model block but are applied on top of the loaded
# holder (hook mode: dynamic; merge mode: into the weights, which reloads
# the touched components when the merged set changes)
LORA_KEYS = ("loras", "lora_mode")
def _without(model: dict, keys) -> dict:
return {k: v for k, v in model.items() if k not in keys}
class Engine:
def __init__(
self,
device: str,
output_folder: str,
assets_folder: Optional[str] = None,
status_cb: Optional[Callable[[str], None]] = None,
dtype: str = "bf16",
):
self.device = device
self.dtype = dtype
self.output_folder = output_folder
self.assets_folder = assets_folder or os.path.join(os.path.dirname(output_folder), "assets")
os.makedirs(self.output_folder, exist_ok=True)
os.makedirs(self.assets_folder, exist_ok=True)
self.status_cb = status_cb or (lambda s: None)
self.queue: "queue.Queue[GenerationJob]" = queue.Queue()
self.jobs: Dict[str, GenerationJob] = {}
self.current: Optional[GenerationJob] = None
self.stop_event = threading.Event()
self.stop_reason = "stopped"
self.loading = False
self.holder = None
self.holder_key: Optional[str] = None
self.holder_model: Optional[dict] = None
self.holder_is_legacy = False
self.lora_stack = None
self.stats = {"holder_loads": 0, "generations": 0, "errors": 0, "cancelled": 0, "oom_retries": 0}
self._lock = threading.Lock()
# resident components shared across holders (same TE/VAE under two archs)
self.pool = ComponentPool()
# ------------------------------------------------------------------ api
def submit(self, model: dict, sample: dict, stream: Optional[dict] = None) -> GenerationJob:
if "arch" not in model:
raise ValueError("model.arch is required")
job = GenerationJob(model, sample, stream)
with self._lock:
self.jobs[job.request_id] = job
# bounded memory: drop the oldest finished jobs' frame history
finished = [j for j in self.jobs.values() if j.finished]
if len(finished) > 100:
finished.sort(key=lambda j: j.created_at)
for old in finished[: len(finished) - 100]:
self.jobs.pop(old.request_id, None)
self.queue.put(job)
return job
def get_job(self, request_id: str) -> Optional[GenerationJob]:
return self.jobs.get(request_id)
def cancel(self, request_id: str) -> bool:
job = self.jobs.get(request_id)
if job is None:
return False
job.cancel.set()
if job.status == "queued":
job.status = "cancelled"
job.emit("error", message="cancelled", cancelled=True)
job.finish()
return True
def stop(self, reason: str = "stopped"):
self.stop_reason = reason
self.stop_event.set()
if self.current is not None:
self.current.cancel.set()
def queued(self) -> List[dict]:
return [j.info() for j in self.jobs.values() if j.status in ("queued", "running")]
def recent(self, limit: int = 50) -> List[dict]:
jobs = sorted(self.jobs.values(), key=lambda j: j.created_at, reverse=True)
return [j.info() for j in jobs[:limit]]
def health(self) -> dict:
vram = None
if torch.cuda.is_available():
try:
free, total = torch.cuda.mem_get_info(torch.device(self.device))
vram = {"used": total - free, "total": total}
except Exception:
vram = None
return {
"ok": True,
"busy": self.current is not None,
"queue": len([j for j in self.jobs.values() if j.status == "queued"]),
"current": self.current.info() if self.current else None,
"active": {
"model": self.holder_model,
"arch": (self.holder_model or {}).get("arch"),
"loras": [l.summary() for l in self.lora_stack.loras] if self.lora_stack else [],
"lora_mode": self.lora_stack.mode if self.lora_stack else None,
}
if self.holder is not None
else None,
"device": self.device,
"vram": vram,
"stats": dict(self.stats),
"pool": self.pool.stats(),
}
def _drop_holder(self):
"""Drop the holder object; its components stay resident in the pool
until release_unused()/clear() decides."""
if self.lora_stack is not None and self.lora_stack.mode == "hook":
self.lora_stack.remove()
self.lora_stack = None
holder = self.holder
self.holder = None
self.holder_key = None
self.holder_model = None
if holder is not None:
for attr in ("pipeline", "model", "vae", "text_encoder", "image_encoder", "adapter", "network"):
try:
setattr(holder, attr, None)
except Exception:
pass
del holder
gc.collect()
def unload(self):
if self.holder is None and not self.pool.entries:
return
self.status_cb("Unloading model")
self._drop_holder()
self.pool.clear()
flush()
def preload(self, model: dict):
"""Load a model's holder ahead of any request (warm start). Runs on
the caller's thread; meant for the engine main thread before
run_forever."""
job = GenerationJob(dict(model), {}, {"latents": "none"})
job.status = "running"
self.current = job
try:
self._ensure_holder(job)
job.status = "done"
except Exception as e:
job.status = "error"
job.error = f"{type(e).__name__}: {e}"
traceback.print_exc()
finally:
self.current = None
job.finish()
return job
# ------------------------------------------------------------------ loop
def run_forever(self):
while not self.stop_event.is_set():
try:
job = self.queue.get(timeout=0.5)
except queue.Empty:
continue
except KeyboardInterrupt:
self.stop("stopped")
break
if job.status != "queued":
continue
self.current = job
try:
self._run(job)
except KeyboardInterrupt:
job.status = "cancelled"
job.emit("error", message="engine stopping", cancelled=True)
job.finish()
self.stop("stopped")
break
finally:
self.current = None
self._idle_status()
# drain anything left queued
while True:
try:
job = self.queue.get_nowait()
except queue.Empty:
break
self.cancel(job.request_id)
def _idle_status(self):
if self.holder_model is not None:
self.status_cb(f"Idle - {self.holder_model.get('arch')} ({self.holder_model.get('name_or_path')})")
else:
self.status_cb("Idle - no model loaded")
# ------------------------------------------------------------------ holder
def _build_model_config(self, model: dict) -> ModelConfig:
kwargs = dict(model)
arch = kwargs.pop("arch")
entry = get_arch_entry(arch)
for k, v in entry["model"].items():
kwargs.setdefault(k, v)
kwargs.setdefault("dtype", self.dtype)
if kwargs.get("quantize"):
kwargs.setdefault("qtype", "convrot8")
if kwargs.get("quantize_te"):
kwargs.setdefault("qtype_te", "convrot8")
return ModelConfig(arch=arch, **kwargs)
def _ensure_holder(self, job: GenerationJob):
model = dict(job.model)
entry = get_arch_entry(model["arch"])
for k, v in entry["model"].items():
model.setdefault(k, v)
lora_specs = [l for l in (model.get("loras") or []) if l.get("path")]
lora_mode = model.get("lora_mode") or "hook"
model = _without(model, LORA_KEYS)
key = _model_key(model)
if self.holder is not None and key == self.holder_key:
self._apply_loras(job, lora_specs, lora_mode)
return self.holder
if (
self.holder is not None
and _model_key(_without(model, OFFLOAD_KEYS)) == _model_key(_without(self.holder_model, OFFLOAD_KEYS))
):
# same weights, different offloading: re-stage in place
self._apply_offload(job, model)
self.holder_model = model
self.holder_key = key
self._apply_loras(job, lora_specs, lora_mode)
return self.holder
if self.holder is not None:
self._drop_holder()
# merged LoRAs live inside pooled weights: a component merged with a
# different set than this request wants must reload from disk
wanted = LoRAStack(None, lora_mode)
wanted.loras = [InferenceLoRA(l["path"], l.get("strength", 1.0)) for l in lora_specs] if lora_mode == "merge" else []
wanted_key = wanted.key() if lora_mode == "merge" else None
freed = self.pool.evict_where(lambda m: getattr(m, "_aitk_merged_loras", None) not in (None, wanted_key))
if freed:
job.emit("status", message=f"Reloading {freed / 1e9:.1f} GB of components that carried other merged LoRAs")
self.pool.begin_request()
ComponentPool.current = self.pool
self.loading = True
try:
try:
holder, is_legacy = self._build_holder(job, model)
except torch.OutOfMemoryError:
# components the previous model needed but this one does not
# were still resident: free them and try once more
self.stats["oom_retries"] += 1
job.emit("status", message="Out of memory: freeing unused components and retrying")
freed = self.pool.release_unused()
flush()
job.emit("status", message=f"Freed {freed / 1e9:.1f} GB, retrying load")
holder, is_legacy = self._build_holder(job, model)
finally:
ComponentPool.current = None
self.loading = False
freed = self.pool.release_unused()
if freed:
job.emit("status", message=f"Released {freed / 1e9:.1f} GB of components not used by {model['arch']}")
flush()
self.holder = holder
self.holder_key = key
self.holder_model = model
self.holder_is_legacy = is_legacy
self.lora_stack = None
self._apply_loras(job, lora_specs, lora_mode)
return holder
def _apply_loras(self, job: GenerationJob, specs: List[dict], mode: str):
"""Bring the holder's LoRA state to `specs` in `mode`. Hook mode:
strengths update in place, a different set re-attaches. Merge mode:
a different set than what the weights carry evicts and reloads them."""
wanted = LoRAStack(self.holder, mode)
wanted.loras = [InferenceLoRA(l["path"], l.get("strength", 1.0)) for l in specs]
wanted_key = wanted.key()
current = self.lora_stack
if current is not None and current.key() == wanted_key:
return
if current is not None and current.mode == "hook" and mode == "hook" and current.set_strengths(specs):
self.lora_stack = current
return
def status(msg):
job.emit("status", message=msg)
self.status_cb(msg)
if current is not None:
if current.mode == "merge":
# weights carry the old set: reload the merged components
status("Reloading weights to drop merged LoRAs")
merged_key = current.key()
self._drop_holder()
self.pool.evict_where(lambda m: getattr(m, "_aitk_merged_loras", None) == merged_key)
flush()
self.lora_stack = None
self._ensure_holder_for_loras(job, specs, mode)
return
current.remove()
self.lora_stack = None
if not specs:
return
stack = LoRAStack(self.holder, mode).load(specs, status_fn=status)
touched = stack.apply(status_fn=status)
if mode == "merge":
for root in [getattr(self.holder, "model", None)] + (
self.holder.text_encoder if isinstance(getattr(self.holder, "text_encoder", None), list) else [getattr(self.holder, "text_encoder", None)]
):
if isinstance(root, torch.nn.Module) and any(id(m) in touched for m in root.modules()):
root._aitk_merged_loras = stack.key()
self.lora_stack = stack
def _ensure_holder_for_loras(self, job: GenerationJob, specs: List[dict], mode: str):
model = dict(self.holder_model or job.model)
model["loras"] = specs
model["lora_mode"] = mode
job_model = job.model
job.model = model
try:
self._ensure_holder(job)
finally:
job.model = job_model
def _apply_offload(self, job: GenerationJob, model: dict):
"""Detach/re-attach layer offloading on the loaded holder's components
at the requested fractions. No weights are reloaded."""
from toolkit.memory_management import MemoryManager
holder = self.holder
mc = holder.model_config
enabled = bool(model.get("layer_offloading", False))
t_pct = float(model.get("layer_offloading_transformer_percent", 1.0))
te_pct = float(model.get("layer_offloading_text_encoder_percent", 1.0))
mc.layer_offloading = enabled
mc.layer_offloading_transformer_percent = t_pct
mc.layer_offloading_text_encoder_percent = te_pct
device = torch.device(self.device)
targets = []
model_module = getattr(holder, "model", None)
subs = [getattr(model_module, n, None) for n in ("transformer_1", "transformer_2")]
if model_module is not None and all(m is not None for m in subs):
targets += [(m, t_pct) for m in subs]
elif model_module is not None:
targets.append((model_module, t_pct))
tes = getattr(holder, "text_encoder", None)
for te in tes if isinstance(tes, list) else [tes]:
targets.append((te, te_pct))
desc = f"offload {'on' if enabled else 'off'} (transformer {t_pct:.0%}, text encoder {te_pct:.0%})"
job.emit("status", message=f"Applying {desc}")
self.status_cb(f"Applying {desc}")
changed = 0
for module, pct in targets:
if not isinstance(module, torch.nn.Module) or type(module).__name__.startswith("Fake"):
continue
if next(module.parameters(), None) is None:
continue
if hasattr(module, "_memory_manager"):
MemoryManager.detach(module)
if enabled and pct > 0:
get_ignore = getattr(module, "get_offload_ignore_modules", None)
ignore = get_ignore() if callable(get_ignore) else None
MemoryManager.attach(module, device, offload_percent=pct, ignore_modules=list(ignore or []))
else:
module.to(device)
# keep the pool's policy record in sync so a later identical
# request is a no-op rather than a re-attach
policy = getattr(module, "_aitk_policy", None)
if isinstance(policy, dict):
policy["offload"] = pct if enabled else 0.0
changed += 1
flush()
job.emit("status", message=f"Applied {desc} to {changed} components")
def _build_holder(self, job: GenerationJob, model: dict):
model_config = self._build_model_config(model)
ModelClass = get_model_class(model_config)
is_legacy = not hasattr(ModelClass, "get_train_scheduler")
if is_legacy:
from toolkit.sampler import get_sampler
legacy_arch = "sd"
if model_config.is_pixart:
legacy_arch = "pixart"
elif model_config.is_flux:
legacy_arch = "flux"
elif model_config.is_lumina2:
legacy_arch = "lumina2"
sampler = get_sampler(
"ddpm",
{"prediction_type": "v_prediction" if model_config.is_v_pred else "epsilon"},
arch=legacy_arch,
)
else:
sampler = ModelClass.get_train_scheduler()
job.emit("status", message=f"Loading {model['arch']}")
self.status_cb(f"Loading {model['arch']}: {model.get('name_or_path')}")
holder = ModelClass(
device=self.device,
model_config=model_config,
dtype=self.dtype,
noise_scheduler=sampler,
)
def _status_hook(msg):
job.emit("status", message=str(msg))
self.status_cb(f"Loading {model['arch']}: {msg}")
if hasattr(holder, "add_status_update_hook"):
holder.add_status_update_hook(_status_hook)
t0 = time.perf_counter()
holder.load_model()
self.stats["holder_loads"] += 1
p = self.pool.stats()
job.emit(
"status",
message=f"Loaded {model['arch']} in {time.perf_counter() - t0:.1f}s (pool hits {p['hits']}, misses {p['misses']})",
)
return holder, is_legacy
# ------------------------------------------------------------------ run
def _resolve_asset(self, value):
if value is None:
return None
if os.path.isabs(value) and os.path.exists(value):
return value
cand = os.path.join(self.assets_folder, os.path.basename(value))
if os.path.exists(cand):
return cand
if os.path.exists(value):
return os.path.abspath(value)
raise FileNotFoundError(f"control image not found: {value}")
def _run(self, job: GenerationJob):
job.status = "running"
job.started_at = time.time()
arch = job.model.get("arch")
entry = get_arch_entry(arch)
out_dir = os.path.join(self.output_folder, job.request_id)
os.makedirs(out_dir, exist_ok=True)
try:
holder = self._ensure_holder(job)
if job.cancel.is_set():
raise GenerationCancelled()
sample = dict(entry["sample"])
sample.update({k: v for k, v in job.sample.items() if v is not None})
for key in ("ctrl_img", "ctrl_img_1", "ctrl_img_2", "ctrl_img_3"):
if sample.get(key):
sample[key] = self._resolve_asset(sample[key])
if entry["needs_control_image"] and not sample.get("ctrl_img"):
raise ValueError(f"{arch} requires a control image (ctrl_img)")
output_ext = sample.pop("output_ext", None) or OUTPUT_EXT[entry["modality"]]
if entry["modality"] == "video" and sample.get("num_frames", 1) > 1 and output_ext == "mp4":
# BaseModel's video saver writes animated webp; archs with their
# own mp4 writer (ltx2) override output_ext themselves
output_ext = "webp"
prompt = sample.pop("prompt", "")
gen = GenerateImageConfig(
prompt=prompt,
output_folder=out_dir,
output_ext=output_ext,
**sample,
)
job.total_steps = gen.num_inference_steps
job.emit(
"start",
model={**(self.holder_model or {}), "loras": job.model.get("loras") or [], "lora_mode": job.model.get("lora_mode") or "hook"},
sample={
"prompt": gen.prompt,
"negative_prompt": gen.negative_prompt,
"width": gen.width,
"height": gen.height,
"num_inference_steps": gen.num_inference_steps,
"guidance_scale": gen.guidance_scale,
"seed": gen.seed,
"num_frames": gen.num_frames,
"fps": gen.fps,
},
modality=entry["modality"],
preview=preview_info(arch),
)
t_start = time.perf_counter()
every_n = max(1, int(job.stream.get("every_n_steps", 1)))
want_latents = job.stream.get("latents", "raw") == "raw"
max_frames = int(job.stream.get("max_frames", 4))
def step_hook(idx, num_steps, latents):
if job.cancel.is_set():
raise GenerationCancelled()
job.step = idx + 1
if num_steps:
job.total_steps = num_steps
elapsed = time.perf_counter() - t_start
job.emit("progress", step=job.step, total=job.total_steps, elapsed=round(elapsed, 3))
self.status_cb(f"Generating {job.step}/{job.total_steps or '?'}")
if want_latents and (idx % every_n == 0 or (job.total_steps and job.step == job.total_steps)):
self._emit_latent(job, latents, gen, max_frames)
def status_hook(msg):
job.emit("status", message=str(msg))
holder.sample_step_hook = step_hook
if hasattr(holder, "add_status_update_hook"):
holder._status_update_hooks.append(status_hook)
gen_kwargs = {"sampler": "ddpm"} if self.holder_is_legacy else {}
self.status_cb(f"Generating with {arch}")
before = set(_list_outputs(out_dir))
t_gen = time.perf_counter()
try:
holder.generate_images([gen], **gen_kwargs)
finally:
gen_seconds = time.perf_counter() - t_gen
holder.sample_step_hook = None
if hasattr(holder, "_status_update_hooks"):
try:
holder._status_update_hooks.remove(status_hook)
except ValueError:
pass
produced = [p for p in _list_outputs(out_dir) if p not in before]
if not produced:
raise RuntimeError("generation produced no output file")
steps = job.total_steps or gen.num_inference_steps
per_step = gen_seconds / steps if steps else 0
size = f"{gen.width}x{gen.height}" + (f"x{gen.num_frames}f" if gen.num_frames > 1 else "")
timing = f"Generated {arch} {size} in {gen_seconds:.2f}s ({steps} steps, {per_step:.2f}s/step, seed {gen.seed})"
print(f"[AITK] {timing}", flush=True)
job.emit("status", message=timing)
for path in sorted(produced):
ext = os.path.splitext(path)[1].lower().lstrip(".")
result = {
"path": path,
"relpath": os.path.relpath(path, self.output_folder),
"kind": _kind_for_ext(ext),
"ext": ext,
"bytes": os.path.getsize(path),
"seed": gen.seed,
"width": gen.width,
"height": gen.height,
"num_frames": gen.num_frames,
"fps": gen.fps,
"seconds": round(gen_seconds, 3),
"steps": steps,
}
job.results.append(result)
job.emit("result", **result)
job.status = "done"
self.stats["generations"] += 1
except (GenerationCancelled, KeyboardInterrupt) as e:
job.status = "cancelled"
job.error = "cancelled"
self.stats["cancelled"] += 1
job.emit("error", message="cancelled" if isinstance(e, GenerationCancelled) else "engine stopping", cancelled=True)
flush()
if isinstance(e, KeyboardInterrupt):
job.finish()
raise
except Exception as e:
job.status = "error"
job.error = f"{type(e).__name__}: {e}"
self.stats["errors"] += 1
traceback.print_exc()
job.emit("error", message=job.error, cancelled=False, traceback=traceback.format_exc())
flush()
finally:
if job.status != "done":
shutil.rmtree(out_dir, ignore_errors=True)
job.finish()
def _emit_latent(self, job: GenerationJob, latents, gen: GenerateImageConfig, max_frames: int):
try:
t = latents
if hasattr(t, "tensor"):
t = t.tensor
if not isinstance(t, torch.Tensor):
return
t = t.detach()
layout = None
if t.dim() == 3 and t.shape[-1] % 4 == 0:
# packed 2x2 patch tokens (flux family): (B, h/2*w/2, C*4)
h = gen.height // 16
w = gen.width // 16
if t.shape[1] == h * w:
c = t.shape[-1] // 4
t = t.view(t.shape[0], h, w, c, 2, 2).permute(0, 3, 1, 4, 2, 5).reshape(t.shape[0], c, h * 2, w * 2)
if t.dim() == 4:
layout = "BCHW"
elif t.dim() == 5:
layout = "BCFHW"
if max_frames and t.shape[2] > max_frames:
idx = torch.linspace(0, t.shape[2] - 1, max_frames).round().long().to(t.device)
t = t.index_select(2, idx)
elif t.dim() == 3:
layout = "BLC" # sequence latents (audio: batch, length, channels)
else:
layout = "raw"
fields, payload = tensor_payload(t[:1] if t.dim() >= 3 else t)
extra = {}
if not getattr(job, "_preview_sent", False) and layout in ("BCHW", "BCFHW"):
# now that the real latent shape is known, resolve the preview
# table (arch table, else a channel-count guess) once per job
job._preview_sent = True
extra["preview"] = preview_info(job.model.get("arch"), int(t.shape[1]), 2 if layout == "BCHW" else 3)
job.emit("latent", payload, step=job.step, total=job.total_steps, layout=layout, **fields, **extra)
except Exception as e:
job.emit("status", message=f"latent preview skipped: {e}")
def _list_outputs(folder: str) -> List[str]:
return [
p
for p in glob.glob(os.path.join(folder, "*"))
if os.path.isfile(p) and not os.path.basename(p).startswith(".") and not p.endswith(".txt")
]
def _kind_for_ext(ext: str) -> str:
if ext in ("mp4", "webm", "mov", "webp"):
return "video" if ext != "webp" else "image"
if ext in ("wav", "mp3", "flac", "ogg"):
return "audio"
return "image"