Initial inference engine build out
This commit is contained in:
1 parent
f56b5a1d40
commit
449488f6b7
33 files changed
+4253
-4
No files matched your search
+2
-1
@@ -192,4 +192,5 @@ aitk_db.db-shm
|
||||
.claude
|
||||
original_repo
|
||||
.next
|
||||
testing/.model_test_outputs
|
||||
testing/.model_test_outputs
|
||||
testing/.engine_test_outputs
|
||||
@@ -264,6 +264,7 @@ class AceStep15Model(BaseAudioModel):
|
||||
time_sig=time_sig,
|
||||
language=language,
|
||||
guidance_scale=gen_config.guidance_scale,
|
||||
step_callback=lambda i, n, latents: self._emit_sample_step(latents, i, n),
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ class AceStep15Pipeline:
|
||||
time_sig="N/A",
|
||||
language="en",
|
||||
guidance_scale=1.0,
|
||||
step_callback=None, # step_callback(i, num_steps, latents) after each update
|
||||
):
|
||||
t_sched = compute_timesteps(num_inference_steps, 3.0)
|
||||
latent_len = int(duration * self.LATENT_RATE)
|
||||
@@ -153,6 +154,9 @@ class AceStep15Pipeline:
|
||||
else:
|
||||
vt = vt_cond
|
||||
|
||||
if step_callback is not None:
|
||||
# x0 estimate at this step (flow matching: x_t - t * v)
|
||||
step_callback(i, len(t_sched_t), xt - vt * tv)
|
||||
if i == len(t_sched_t) - 1:
|
||||
xt = xt - vt * tv
|
||||
else:
|
||||
|
||||
@@ -357,7 +357,8 @@ class Krea2Pipeline:
|
||||
ref_cache = {"kv": None, "mask": None}
|
||||
|
||||
# Euler integration of the flow ODE (with optional CFG).
|
||||
for tcurr, tprev in zip(ts[:-1], ts[1:]):
|
||||
num_steps = len(ts) - 1
|
||||
for i, (tcurr, tprev) in enumerate(zip(ts[:-1], ts[1:])):
|
||||
t = torch.full((latents.shape[0],), tcurr, dtype=dtype, device=device)
|
||||
v_cond = predict_velocity(
|
||||
transformer,
|
||||
@@ -383,7 +384,12 @@ class Krea2Pipeline:
|
||||
v = v_cond + guidance_scale * (v_cond - v_uncond)
|
||||
else:
|
||||
v = v_cond
|
||||
latents = latents + (tprev - tcurr) * v.to(torch.float32)
|
||||
v = v.to(torch.float32)
|
||||
# inference engine preview: x0 estimate for this step
|
||||
emit = getattr(model, "_emit_sample_step", None)
|
||||
if emit is not None and getattr(model, "sample_step_hook", None) is not None:
|
||||
emit(latents - float(tcurr) * v, i, num_steps)
|
||||
latents = latents + (tprev - tcurr) * v
|
||||
|
||||
images = model.decode_latents(latents, device=device, dtype=dtype)
|
||||
images = images.float().clamp(-1.0, 1.0)
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""The inference engine as an AI Toolkit job: a resident generation server
|
||||
that stays up until the job is stopped.
|
||||
|
||||
job: extension
|
||||
config:
|
||||
name: inference_engine
|
||||
process:
|
||||
- type: InferenceEngine
|
||||
sqlite_db_path: ./aitk_db.db
|
||||
device: cuda
|
||||
engine:
|
||||
job_folder: output/inference_engine # engine.json + outputs/ + assets/
|
||||
default_model: {arch: zimage} # optional warm load
|
||||
|
||||
The server binds 127.0.0.1 on an ephemeral port and publishes it (with a
|
||||
per-launch token) to <job_folder>/engine.json; the UI proxies to it.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import signal
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
|
||||
import torch
|
||||
|
||||
from jobs.process import BaseExtensionProcess
|
||||
from toolkit.paths import TOOLKIT_ROOT
|
||||
from toolkit.ui_job_status import UIJobStatus
|
||||
|
||||
|
||||
class InferenceEngineConfig:
|
||||
def __init__(self, **kwargs):
|
||||
self.job_folder = kwargs.get("job_folder", None)
|
||||
self.output_folder = kwargs.get("output_folder", None)
|
||||
self.default_model = kwargs.get("default_model", None)
|
||||
self.dtype = kwargs.get("dtype", "bf16")
|
||||
self.host = kwargs.get("host", "127.0.0.1")
|
||||
# 0 = ephemeral (recommended); a fixed port is for external clients
|
||||
self.port = int(kwargs.get("port", 0))
|
||||
self.token = kwargs.get("token", None)
|
||||
|
||||
|
||||
class InferenceEngine(BaseExtensionProcess):
|
||||
def __init__(self, process_id: int, job, config: OrderedDict, **kwargs):
|
||||
super().__init__(process_id, job, config, **kwargs)
|
||||
self.ui = UIJobStatus(self.config.get("sqlite_db_path", "./aitk_db.db"))
|
||||
self.engine_config = InferenceEngineConfig(**self.get_conf("engine", {}))
|
||||
self.device = self.get_conf("device", "cuda")
|
||||
job_folder = self.engine_config.job_folder or os.path.join(TOOLKIT_ROOT, "output", self.name)
|
||||
self.job_folder = os.path.abspath(job_folder)
|
||||
self.output_folder = os.path.abspath(
|
||||
self.engine_config.output_folder or os.path.join(self.job_folder, "outputs")
|
||||
)
|
||||
self.endpoint_path = os.path.join(self.job_folder, "engine.json")
|
||||
self.engine = None
|
||||
self.server = None
|
||||
self.server_thread = None
|
||||
self.endpoint = None
|
||||
|
||||
def run(self):
|
||||
super().run()
|
||||
from .engine import Engine
|
||||
from .server import create_app
|
||||
|
||||
self.ui.update_status("running", "Starting server")
|
||||
os.makedirs(self.job_folder, exist_ok=True)
|
||||
self.engine = Engine(
|
||||
device=self.device,
|
||||
output_folder=self.output_folder,
|
||||
assets_folder=os.path.join(self.job_folder, "assets"),
|
||||
status_cb=self.ui.update_info,
|
||||
dtype=self.engine_config.dtype,
|
||||
)
|
||||
token = self.engine_config.token or secrets.token_urlsafe(24)
|
||||
self.start_server(create_app(self.engine, token), token)
|
||||
|
||||
self.ui.start_stop_watcher(lambda reason: self.engine.stop(reason))
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
signal.signal(sig, lambda *_: self.engine.stop("stopped"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.engine_config.default_model:
|
||||
with torch.no_grad():
|
||||
self.engine.preload(dict(self.engine_config.default_model))
|
||||
|
||||
self.engine._idle_status()
|
||||
try:
|
||||
with torch.no_grad():
|
||||
self.engine.run_forever()
|
||||
finally:
|
||||
self.shutdown()
|
||||
|
||||
def start_server(self, app, token: str):
|
||||
from .server import serve_in_thread
|
||||
|
||||
self.server, self.server_thread, host, port = serve_in_thread(
|
||||
app, self.engine_config.host, self.engine_config.port
|
||||
)
|
||||
self.endpoint = {
|
||||
"host": host,
|
||||
"port": port,
|
||||
"token": token,
|
||||
"pid": os.getpid(),
|
||||
"job_id": self.ui.job_id,
|
||||
"device": self.device,
|
||||
"job_folder": self.job_folder,
|
||||
"output_folder": self.output_folder,
|
||||
"started_at": time.time(),
|
||||
}
|
||||
tmp = self.endpoint_path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(self.endpoint, f, indent=2)
|
||||
os.replace(tmp, self.endpoint_path)
|
||||
print(f"[AITK] Inference engine listening on http://{host}:{port} (endpoint file: {self.endpoint_path})")
|
||||
|
||||
def shutdown(self):
|
||||
reason = self.engine.stop_reason if self.engine else "stopped"
|
||||
print(f"[AITK] Inference engine shutting down ({reason})")
|
||||
if self.server is not None:
|
||||
self.server.should_exit = True
|
||||
if self.server_thread is not None:
|
||||
self.server_thread.join(timeout=10)
|
||||
try:
|
||||
os.remove(self.endpoint_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
if self.engine is not None:
|
||||
try:
|
||||
self.engine.unload()
|
||||
except Exception:
|
||||
pass
|
||||
self.ui.update_status("queued" if reason == "queued" else "stopped", "Engine stopped")
|
||||
|
||||
def on_error(self, e: Exception):
|
||||
super().on_error(e)
|
||||
if isinstance(e, KeyboardInterrupt):
|
||||
self.ui.update_status("stopped", "Engine stopped")
|
||||
else:
|
||||
self.ui.update_status("error", str(e))
|
||||
try:
|
||||
os.remove(self.endpoint_path)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,366 @@
|
||||
# Inference Engine — Planning
|
||||
|
||||
## Goal
|
||||
|
||||
A resident generation server that runs as a normal AI Toolkit job (like a
|
||||
captioner job: `job: extension`, `process[0].type: InferenceEngine`), stays up
|
||||
until stopped from the UI, and serves generation requests for every arch the
|
||||
toolkit knows (image, video, audio). Each request names its model config; the
|
||||
engine loads/reloads only the components that differ from what is resident,
|
||||
drops what the request does not need, and streams progress + per-step latents
|
||||
back over the request's connection, ending with the finished file.
|
||||
|
||||
This is Phase 3 of `toolkit/models/v2/PLANNING.md` ("live server with model
|
||||
hot-swap"); everything below builds on the unified v2 load API
|
||||
(`ModelClass.load(...)`, `aitk_post_load`, `component_load_kwargs`) that is
|
||||
already in place for every non-legacy arch.
|
||||
|
||||
## Survey (2026-09-10)
|
||||
|
||||
What exists and is reused as-is:
|
||||
|
||||
- **Job plumbing**: `jobs/ExtensionJob.py` -> `get_all_extensions_process_dict`
|
||||
-> our process class. The UI launches jobs via `ui/cron/actions/startJob.ts`
|
||||
(`python run.py <config>` with `AITK_JOB_ID`, `CUDA_VISIBLE_DEVICES`,
|
||||
detached, stdout to the job log). Stop = `Job.stop` flag in sqlite, which
|
||||
`BaseCaptioner` polls in a daemon thread and turns into a SIGINT. The DB
|
||||
status/step/info writers (`_update_status`, `_update_key`, retry-on-lock,
|
||||
stop watcher) live in `extensions_built_in/captioner/BaseCaptioner.py`.
|
||||
- **Generation path**: `BaseModel.generate_images([GenerateImageConfig])` does
|
||||
prompt encoding, control images, LoRA/network handling, then
|
||||
`generate_single_image` (per arch), then `gen_config.save_image_atomic`
|
||||
(png/jpg/webp image, animated webp video, wav/mp3 audio; `toolkit/audio/
|
||||
make_video.py` has the mp4 writer). `testing/test_model_loading.py` proves
|
||||
this flow for 34 archs and holds realistic per-arch sample settings.
|
||||
- **Per-step hook point**: every arch samples through a diffusers-style
|
||||
scheduler object owned by the generation pipeline (`pipeline.scheduler.step`
|
||||
— diffusers pipelines, the vendored wan22/ltx/minimax/ace pipelines, the
|
||||
legacy monolith). Wrapping that one method yields (step, timestep, latents)
|
||||
for all of them without touching per-arch code.
|
||||
- **Component loading**: `OstrisModelMixin.load` / `load_model` is the single
|
||||
funnel every v2 component goes through (DiTs, TEs, VAEs, vision encoders,
|
||||
connectors, vocoders). Legacy monolith archs adopt their pipeline
|
||||
components into v2 wrappers at one post-load funnel (`adopt_component`).
|
||||
- **UI front**: `ui/cron/fileServer.ts` owns the public port and already
|
||||
spawns Next.js on an ephemeral loopback port and proxies to it (incl.
|
||||
websocket upgrades). Next.js route handlers stream `Response` bodies (the
|
||||
`/api/monitor` SSE route is the pattern). Bearer auth is a middleware on
|
||||
`/api/*`. `/api/files/*` serves job output folders. The job detail page
|
||||
already has a per-job `plugin.html` iframe tab.
|
||||
- **Latent previews**: ComfyUI's `comfy/latent_formats.py` (read-only
|
||||
reference) has per-family linear `latent_rgb_factors` tables for SD1/SDXL/
|
||||
SD3/Flux/Flux2/Wan21/Wan22/LTXV/LTXAV/MiniMaxH3/Cosmos/HunyuanVideo.
|
||||
- **HTTP server libs**: `fastapi` 0.140 and `uvicorn` 0.51 are importable in
|
||||
this env but only as transitive deps of gradio; they are NOT pinned in
|
||||
`requirements.txt`.
|
||||
|
||||
## Decisions (proposed — confirm before build)
|
||||
|
||||
1. **Transport = one chunked HTTP response per generation request** (not
|
||||
WebSocket). A `POST /generate` stays open for the whole generation and
|
||||
streams binary frames; the Next.js proxy route passes the body through as
|
||||
a `ReadableStream`, so it works with or without the fileServer front, in
|
||||
docker, on Windows/macOS, and from `curl`. WebSocket would require the
|
||||
fileServer to be mandatory (Next route handlers cannot upgrade). Cancel is
|
||||
a separate `POST /cancel/{request_id}` (also fired when the client aborts
|
||||
the stream).
|
||||
2. **Frame format**: length-prefixed binary frames
|
||||
`u32 header_len | json header | u64 payload_len | payload`. Header
|
||||
`{type, request_id, ...}`; types: `start` (resolved config, output kind,
|
||||
latent layout + rgb factors), `status` (load/quantize/encode stages,
|
||||
from `print_and_status_update`), `progress` (step/total/eta), `latent`
|
||||
(raw fp16 tensor bytes + shape), `result` (file path/url + media meta),
|
||||
`error`, `end`. Text frames have empty payload.
|
||||
3. **Latents stream raw, projection happens client-side.** The `start` frame
|
||||
carries `latent_layout` (`BCHW` / `BCFHW` / `audio`), the arch's
|
||||
`latent_rgb_factors` (+bias) and any scaling, so the browser projects
|
||||
latents to a canvas with a channels x 3 matmul per pixel and needs no
|
||||
per-arch code. Server options `stream.latents = "raw" | "none"`,
|
||||
`stream.every_n_steps`, and for video `stream.max_frames` /
|
||||
`stream.downsample` to cap frame size (a 30-step video run at full latent
|
||||
res is hundreds of MB; images are ~0.5 MB/step). Archs without known
|
||||
factors still stream latents; the client falls back to a per-channel
|
||||
grayscale/PCA preview. A server-side `preview="jpeg"` mode (full or
|
||||
TAE decode) is a later add, not v1.
|
||||
4. **Endpoint discovery = ephemeral loopback port + a file in the job
|
||||
folder.** Engine binds `127.0.0.1:0`, writes
|
||||
`<training_folder>/<job_name>/engine.json` (`host, port, token, pid,
|
||||
started_at`) and logs it. No fixed port, nothing network-exposed, no
|
||||
collisions, works identically in docker (same container as the UI) and
|
||||
on Windows (uvicorn has no named-pipe support, so a loopback TCP port is
|
||||
the portable stand-in for "no port"). No DB schema change (a new Job
|
||||
column would crash the jobs list for anyone who has not run `update_db`).
|
||||
The UI already resolves `<training_folder>/<job.name>` for `plugin.html`.
|
||||
Requests carry the per-launch `token` (proxy adds it) so other local
|
||||
processes cannot drive the engine.
|
||||
5. **One engine process per GPU set, one generation at a time, FIFO queue**
|
||||
(`GET /queue`, `POST /cancel`). Multiple engine jobs on different GPUs are
|
||||
independent; the UI picks by job.
|
||||
6. **Hot-swap via a component pool hooked into the mixin funnel** (no
|
||||
rewrites of the 35 holder `load_model`s). See "Component pool".
|
||||
7. **LoRA in v1 = part of the transformer's pool identity** (`lora_path`,
|
||||
`assistant_lora_path`, network multiplier apply through the existing
|
||||
holder paths; a different LoRA means the transformer entry reloads; TEs/
|
||||
VAEs stay shared). Hot-swappable unmerged LoRA on a resident transformer
|
||||
is v2.
|
||||
8. **Server = FastAPI + uvicorn**, run on a thread inside the job process
|
||||
(generation stays on the main thread). Requires adding `fastapi` and
|
||||
`uvicorn` to `requirements.txt` (Jaret's call; fallback is a stdlib
|
||||
`ThreadingHTTPServer` with chunked responses — strictly worse, but zero
|
||||
deps).
|
||||
9. **Model registry served by the engine.** `GET /models` returns every
|
||||
registered arch with capabilities + defaults (modality, control-image
|
||||
requirement, size divisibility, native res, default steps/CFG/frames/fps,
|
||||
latent layout, rgb factors). The table lives in python
|
||||
(`toolkit/models/registry.py`, seeded from `MODEL_TESTS` in
|
||||
`testing/test_model_loading.py` + per-holder class attrs) so the UI reads
|
||||
one source of truth at runtime instead of a second copy in `options.tsx`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
extensions_built_in/inference_engine/
|
||||
__init__.py # InferenceEngineExtension (uid "InferenceEngine")
|
||||
InferenceEngine.py # process: UI status/stop plumbing, server lifecycle
|
||||
server.py # FastAPI app: /health /models /generate /cancel /queue /unload
|
||||
engine.py # Engine: request queue, holder cache, pool orchestration
|
||||
protocol.py # frame encoder (python) — mirrored by ui/src/lib/engineStream.ts
|
||||
latent_preview.py # latent layout + rgb factor tables per arch family
|
||||
toolkit/models/v2/pool.py # ComponentPool (identity, residency, eviction)
|
||||
toolkit/models/registry.py # arch capabilities/defaults (shared with tests + UI)
|
||||
toolkit/ui_job_status.py # DB status/stop helpers lifted from BaseCaptioner
|
||||
# (engine uses it; captioner can adopt later, optional)
|
||||
testing/test_inference_engine.py
|
||||
ui/src/app/api/inference/[...path]/route.ts # proxy to the engine (streams)
|
||||
ui/src/app/generate/page.tsx # Generate page
|
||||
ui/src/components/generate/* # model picker, prompt form, preview canvas, gallery
|
||||
ui/src/lib/engineStream.ts # frame parser + latent->canvas projection
|
||||
```
|
||||
|
||||
### Process (`InferenceEngine.py`)
|
||||
|
||||
- Config:
|
||||
```yaml
|
||||
job: extension
|
||||
config:
|
||||
name: inference_engine
|
||||
process:
|
||||
- type: InferenceEngine
|
||||
sqlite_db_path: ./aitk_db.db
|
||||
device: cuda
|
||||
engine:
|
||||
output_folder: <training_folder>/<name>/outputs # results, served by /api/files
|
||||
keep_resident_gb: 0 # 0 = keep only what the last request needs
|
||||
idle_unload_seconds: 0 # 0 = never auto-unload
|
||||
default_model: {arch: zimage, name_or_path: Tongyi-MAI/Z-Image-Turbo} # optional warm load
|
||||
```
|
||||
- `run()`: status "running / Starting server" -> start uvicorn on a thread
|
||||
(port 0) -> write `engine.json` -> optional warm load -> main thread loops
|
||||
on the request queue, executing generations; `info` reflects
|
||||
"Idle — zimage (Tongyi-MAI/Z-Image-Turbo)" / "Generating 12/30" / "Loading
|
||||
qwen_image: Quantizing transformer". Stop flag -> SIGINT (captioner
|
||||
pattern) -> cancel in-flight request, shut the server, delete
|
||||
`engine.json`, free the pool, status "stopped".
|
||||
- Also runnable headless: `python run.py config/inference_engine.yaml`
|
||||
prints the endpoint; no UI needed (useful for tests).
|
||||
|
||||
### Engine (`engine.py`)
|
||||
|
||||
- `GenerateRequest`: `{model: ModelConfig kwargs (arch, name_or_path,
|
||||
extras_name_or_path, quantize, qtype, quantize_te, qtype_te, low_vram,
|
||||
layer_offloading..., lora_path, dtype), sample: GenerateImageConfig kwargs
|
||||
(prompt, negative_prompt, width, height, num_inference_steps,
|
||||
guidance_scale, seed, num_frames, fps, ctrl_img[_1..3] as server path or
|
||||
uploaded asset id, output_ext), stream: {latents, every_n_steps,
|
||||
max_frames}}`. Uploads: `POST /assets` stores control images under the job
|
||||
folder and returns an id/path.
|
||||
- Holder handling: `get_model_class(model_config)`, build the holder with
|
||||
the pool active, `load_model()`, then `pool.release_unused()`. The holder
|
||||
is kept as "active" and reused verbatim for the next request when its
|
||||
ModelConfig is identical (fast path: no rebuild). A new ModelConfig
|
||||
rebuilds the holder; the pool makes the rebuild cost = only the changed
|
||||
components.
|
||||
- Sampling: `sd.generate_images([gen])` with the step hook installed;
|
||||
`add_status_update_hook` feeds `status` frames; the `_after_sample_img`
|
||||
hook plus the saved file feed `result`. Output goes to
|
||||
`engine.output_folder` with a request-id filename; the frame carries the
|
||||
path and the `/api/files` url. Cancel = flag checked in the step hook,
|
||||
raising a `GenerationCancelled` that `generate_images` cleanup tolerates
|
||||
(it already restores device/rng state in its epilogue; verify the
|
||||
exception path leaves the holder usable).
|
||||
|
||||
### Step hook (`BaseModel`)
|
||||
|
||||
- `BaseModel.sample_step_hook: Optional[Callable]` and, inside
|
||||
`generate_images`, a context manager that wraps `pipeline.scheduler.step`
|
||||
to call `hook(step_index, num_steps, timestep, latents)` with the
|
||||
scheduler's returned sample. Zero cost when no hook is set. Custom loops
|
||||
that bypass the scheduler (found by the per-arch streaming test) get an
|
||||
explicit `self._emit_sample_step(...)` call. Legacy monolith: same wrap on
|
||||
its pipeline scheduler.
|
||||
- Latent extraction from the DTO/carrier: video latents `BCFHW`, ltx/minimax
|
||||
AV extras (audio rows) ride the DTO — the `latent` frame sends the main
|
||||
tensor; audio extras optional.
|
||||
|
||||
### Component pool (`toolkit/models/v2/pool.py`)
|
||||
|
||||
- Identity key = `(component class, resolved source [repo/subfolder or file
|
||||
path after comfy resolution], dtype, qtype (incl. ARA suffix), offload
|
||||
fraction, final device, lora identity for transformers)`. Computed inside
|
||||
`OstrisModelMixin.load` from exactly the args it already receives, so a
|
||||
holder's `component_load_kwargs(role)` output determines sharing (a TE
|
||||
loaded quantized convrot8 on cuda:0 is one entry; the same TE bf16 on cpu
|
||||
is another).
|
||||
- Wiring: a process-global `ComponentPool.current` context; `load` checks
|
||||
it first (hit = return resident module, skip source+post_load), registers
|
||||
on miss. Legacy `adopt_component` funnel registers too (consume side stays
|
||||
full reload for legacy archs). Tokenizers/processors pooled by repo.
|
||||
- Residency ops: `mark_used(key)` per request; `release_unused(keep)` =
|
||||
MemoryManager.detach/free, drop refs, `gc.collect()`,
|
||||
`torch.cuda.empty_cache()`; `bytes(key)` for accounting;
|
||||
`keep_resident_gb` LRU keep-warm; eviction before a load when
|
||||
`torch.cuda.mem_get_info` says the incoming component will not fit.
|
||||
- Safety: entries must not be mutated by holders in ways that change their
|
||||
identity. Audit as part of step 3: holders that merge LoRAs into weights,
|
||||
`.to(device)` moves under low_vram (device is in the key — a moved module
|
||||
must be moved back or re-keyed on release), `MemoryManager.attach` (offload
|
||||
is in the key), FakeTextEncoder swaps (`toolkit/unloader.py`), per-arch
|
||||
patches applied in `load_model` after `load` (e.g. vision-tower drops:
|
||||
fine, they are class-level config and thus part of the class identity).
|
||||
- Counters exposed on `/health`: hits/misses/evictions, resident entries with
|
||||
bytes and last-used — the tests assert on these.
|
||||
|
||||
### Server (`server.py`)
|
||||
|
||||
- `GET /health` `{ok, busy, queue, active: {arch, name_or_path, config},
|
||||
pool: [...], vram: {used, total}}`; `GET /models`; `POST /generate`
|
||||
(stream); `POST /cancel/{id}`; `GET /queue`; `POST /unload`
|
||||
(`{all: true}` or component keys); `POST /assets` (multipart upload);
|
||||
`GET /outputs` (list results). Token check on everything except `/health`.
|
||||
|
||||
### UI
|
||||
|
||||
- Proxy: `ui/src/app/api/inference/[...path]/route.ts` — finds the running
|
||||
`job_type = "inference"` job (query param `job` to pick one when several
|
||||
GPUs run engines), reads its `engine.json` (cached a few seconds),
|
||||
forwards method/headers/body with the engine token, returns
|
||||
`new Response(upstream.body)` so chunked frames flow through untouched.
|
||||
`export const dynamic = 'force-dynamic'`, no body size limit issues
|
||||
(control images go through `/assets`, multipart).
|
||||
- `/generate` page (sidebar "Generate"): engine strip (pick/start/stop the
|
||||
engine job on a GPU — creates a job via `/api/jobs` with
|
||||
`job_type: 'inference'` and the config above, then start + startQueue,
|
||||
exactly like `CaptionDatasetModal.saveJob`; shows health), model panel
|
||||
(arch select from `/models`, name_or_path creatable select with the arch's
|
||||
defaults, quantize/qtype/low_vram/LoRA), prompt panel (prompt/negative,
|
||||
size with divisibility snapping, steps/CFG/seed/frames/fps, control image
|
||||
upload), preview panel (canvas fed by the stream, step/eta, cancel),
|
||||
results gallery (files from the engine output folder via `/api/files`,
|
||||
click to open; video/audio players).
|
||||
- Jobs list / action bar: `job_type === 'inference'` rows show "Inference
|
||||
Engine" with a link to `/generate`; stop only (no edit/restart-from-step
|
||||
UI). Dashboard active-job widget same treatment as caption.
|
||||
- `ui/src/lib/engineStream.ts`: async frame reader over `fetch` +
|
||||
`ReadableStream`, event emitter, `projectLatentToImageData(latent, layout,
|
||||
rgbFactors)`; video: shows a chosen frame (scrubber) — audio: heatmap.
|
||||
|
||||
### Tests (`testing/test_inference_engine.py`)
|
||||
|
||||
Mirrors `test_model_loading.py` conventions (`--arch`, `--all` = subprocess
|
||||
per arch, `--allow-download`, HF offline default, SKIP classification,
|
||||
outputs under `testing/.engine_test_outputs/<arch>`), reusing its
|
||||
`MODEL_TESTS` entries for per-arch model/sample settings:
|
||||
|
||||
1. `--arch X`: start the engine in-process on an ephemeral port, POST
|
||||
`/generate`, consume the stream, assert `start` -> >=1 `progress` ->
|
||||
>=1 `latent` (shape/dtype/layout sanity, finite values) -> `result`
|
||||
(file exists, > 1 KB, right extension for the modality) -> `end`;
|
||||
record timings + VRAM like the loading test.
|
||||
2. `--swap A,B[,C]`: sequential requests; assert pool hits for shared
|
||||
components (e.g. zimage -> zimage_l2p shares Qwen3 TE + KLVAE; wan21 ->
|
||||
wan22_5b shares UMT5; qwen_image -> qwen_image_edit shares TE + VAE),
|
||||
assert VRAM after B does not include A's DiT, assert a second A request
|
||||
is a holder fast path (no loads).
|
||||
3. `--cancel`: cancel mid-run, assert the stream ends with `error/cancelled`
|
||||
and the next request on the same holder succeeds.
|
||||
4. `--all`: (1) for every registry arch, then a summary + `report.md`.
|
||||
|
||||
## Steps
|
||||
|
||||
Each step lands runnable + tested before the next.
|
||||
|
||||
- [x] **1. Skeleton job + server** (2026-09-10) — `InferenceEngineExtension`
|
||||
(uid `InferenceEngine`), process with the sqlite status/stop plumbing
|
||||
lifted into `toolkit/ui_job_status.py`, FastAPI + uvicorn on
|
||||
`127.0.0.1:0` (`server.serve_in_thread`), `engine.json` in the job
|
||||
folder, `/health` `/models` `/generate` `/cancel` `/queue` `/unload`
|
||||
`/assets` `/outputs`, headless yaml. `fastapi`/`uvicorn` pinned in
|
||||
requirements_base.txt.
|
||||
- [x] **2. Streaming** — binary frame protocol (`protocol.py`, mirrored by
|
||||
`ui/src/lib/engineStream.ts`), `BaseModel.sample_step_hook` via a
|
||||
scheduler.step wrap in `generate_images` (+ `_emit_sample_step` for
|
||||
hand-rolled loops; ace_step wired), latent layouts BCHW/BCFHW/BLC with
|
||||
flux-style packed-token unpacking and video frame subsampling, per-arch
|
||||
preview tables (`latent_preview.py`, numbers from ComfyUI's
|
||||
latent_formats), cancel (step hook raises; holder verified reusable:
|
||||
the rerun after a cancel reproduced the same file byte-for-byte).
|
||||
Verified: zimage — 8 progress + 8 latent frames (1x16x64x64, Flux
|
||||
preview) + result; `--cancel` PASS.
|
||||
- [x] **3. Component pool + hot-swap** — `toolkit/models/v2/pool.py`;
|
||||
`OstrisModelMixin.load_model` consults/registers the active pool by
|
||||
source key (class, name_or_path, dtype, config, subfolder, comfy flag,
|
||||
kwargs); `aitk_post_load` is idempotent for an identical policy and
|
||||
detaches offloading before re-applying a different one. Engine: drop
|
||||
holder → begin_request → build → release_unused; CUDA OOM during a
|
||||
build frees untouched entries and retries once. Verified:
|
||||
zimage→zimage_l2p→zimage PASS (TE shared on the way back; l2p's
|
||||
transformer/VAE bypass the mixin so they reload — see step 4);
|
||||
wan21→wan22_5b: UMT5 TE shared (pool hit), wan21 DiT+VAE released.
|
||||
- [ ] **4. Coverage** — engine test across the registry. PASS so far
|
||||
(2026-09-10, 5090 with ~16 GB free next to a training job, convrot8):
|
||||
zimage (image, Flux preview), wan21 + wan22_5b (video BCFHW, animated
|
||||
webp), ace_step_15 (audio BLC, mp3), sd1 (legacy monolith: the hook
|
||||
is installed in its own generate_images via toolkit/sample_step_hook),
|
||||
zimage `--cancel`. Known gaps: holders whose component loads
|
||||
bypass `load_model` (zimage_l2p's from_pretrained + pixel conversion,
|
||||
legacy monolith adopt path) do not pool; omnigen2 builds its scheduler
|
||||
inside get_generation_pipeline (hook still applies — wrapping happens
|
||||
after); custom loops without scheduler.step need `_emit_sample_step`
|
||||
as they are found. Full `--all` sweep not run (shared GPU); run it
|
||||
when a free card is available.
|
||||
- [x] **5. UI** — `/api/inference/[...path]` streaming proxy
|
||||
(`ui/src/server/inferenceEngine.ts` resolves the running inference
|
||||
job's engine.json, checks the pid, injects the token), `/generate`
|
||||
page (engine start/stop per GPU, model + prompt forms fed by
|
||||
`/models`, live latent canvas with frame scrubber, results gallery via
|
||||
`/api/files`), sidebar entry, inference rows in the queue table /
|
||||
dashboard widget / job page, `startJob.ts` injects
|
||||
`engine.job_folder`. Type-checked and built in an isolated copy of the
|
||||
UI (the live `next start` on this box was not rebuilt — run
|
||||
`npm run build` to pick it up, the worker change needs it too).
|
||||
Proxy verified end to end against that isolated build with a copy of
|
||||
the db: status → health → models → streamed generate (first frame
|
||||
arrives immediately, progress frames live, result at the end) → stop
|
||||
via the Job.stop flag (engine exits 0, row 'stopped', engine.json
|
||||
removed, status reports no engine).
|
||||
- [ ] **6. Hardening** — done: FIFO queue + `/queue`, OOM-retry eviction,
|
||||
stale engine.json ignored when its pid is dead, `wait=1` JSON mode.
|
||||
Open: idle unload, `keep_resident_gb` LRU keep-warm, pre-load VRAM-fit
|
||||
eviction (today untouched entries are only freed after the build or
|
||||
on OOM, so a tight card pays a reload instead of sharing when the
|
||||
DiT loads first), Windows relay launch check, docker compose smoke,
|
||||
pooling for the custom-path holders.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Chunked HTTP stream vs WebSocket (decision 1).
|
||||
2. Raw latents + client-side projection vs server-rendered previews
|
||||
(decision 3). Both can coexist later; which is v1?
|
||||
3. `engine.json` in the job folder vs a new Job DB column (decision 4).
|
||||
4. OK to add `fastapi` + `uvicorn` to `requirements.txt`?
|
||||
5. LoRA as transformer identity in v1 (decision 7) — acceptable?
|
||||
6. Should the per-arch registry (`toolkit/models/registry.py`) also become
|
||||
the source for `ui/src/app/jobs/new/options.tsx` in the same work, or
|
||||
stay engine-only for now?
|
||||
7. Does the engine also need a non-streamed `POST /generate?wait=1` JSON
|
||||
mode for external scripts (cheap to add alongside)?
|
||||
@@ -0,0 +1,110 @@
|
||||
# Inference Engine
|
||||
|
||||
A resident generation server that runs as an AI Toolkit job and serves every
|
||||
registered model arch (image, video, audio). It hot-swaps models per request:
|
||||
components the new model shares with the previous one (a text encoder, a
|
||||
VAE) stay resident, the rest is freed and loaded. Progress and the raw
|
||||
per-step latents stream back over the request's HTTP connection.
|
||||
|
||||
Design notes and status: `PLANNING.md` in this folder.
|
||||
|
||||
## Running
|
||||
|
||||
From the UI: **Generate** in the sidebar, pick a GPU, **Start engine**. The
|
||||
engine shows up in the Queue as an `inference` job and runs until stopped.
|
||||
|
||||
Headless:
|
||||
|
||||
```yaml
|
||||
# inference_engine.yaml
|
||||
job: extension
|
||||
config:
|
||||
name: inference_engine
|
||||
process:
|
||||
- type: InferenceEngine
|
||||
device: cuda
|
||||
engine:
|
||||
job_folder: output/inference_engine # engine.json + outputs/ + assets/
|
||||
# port: 8866 # fixed port for external clients (default: ephemeral)
|
||||
# token: secret # request token (default: random per launch)
|
||||
# default_model: {arch: zimage} # warm load
|
||||
```
|
||||
|
||||
```
|
||||
python run.py inference_engine.yaml
|
||||
```
|
||||
|
||||
The server binds `127.0.0.1` on an ephemeral port and writes
|
||||
`<job_folder>/engine.json` (`host`, `port`, `token`, `pid`). The UI proxies
|
||||
`/api/inference/*` to it; nothing else is exposed.
|
||||
|
||||
## HTTP API
|
||||
|
||||
All routes except `/health` need the token (`X-Engine-Token: <token>` or
|
||||
`Authorization: Bearer <token>`).
|
||||
|
||||
| route | |
|
||||
|---|---|
|
||||
| `GET /health` | busy/queue, active model, VRAM, pool residents + hit/miss stats |
|
||||
| `GET /models` | every arch with modality, defaults, control-image requirement (`toolkit/models/registry.py`) |
|
||||
| `POST /generate` | body `{model, sample, stream}` → binary frame stream (below). `{"wait": true}` returns JSON when done instead |
|
||||
| `POST /cancel/{request_id}` | cancel a queued/running request |
|
||||
| `GET /queue` | queued + recent requests |
|
||||
| `POST /unload` | drop the holder and every pooled component |
|
||||
| `POST /assets?name=x.png` | raw-body upload; returns `{path}` usable as `sample.ctrl_img` |
|
||||
| `GET /outputs/{relpath}` | fetch a produced file |
|
||||
|
||||
`model` = `ModelConfig` kwargs (`arch` required; `name_or_path`, `quantize`,
|
||||
`qtype`, `quantize_te`, `qtype_te`, `low_vram`, `layer_offloading`,
|
||||
`lora_path`, `extras_name_or_path`, ...). Registry defaults fill in what is
|
||||
omitted. `sample` = `GenerateImageConfig` kwargs (`prompt`, `negative_prompt`,
|
||||
`width`, `height`, `num_inference_steps`, `guidance_scale`, `seed`,
|
||||
`num_frames`, `fps`, `ctrl_img`, `output_ext`). `stream` =
|
||||
`{latents: "raw"|"none", every_n_steps, max_frames}`.
|
||||
|
||||
### Frame stream
|
||||
|
||||
`u32 header_len | json header | u64 payload_len | payload` (little endian).
|
||||
Header always has `type` and `request_id`:
|
||||
|
||||
- `start` — resolved model/sample, `modality`, `preview` (latent→RGB factors)
|
||||
- `status` — holder stage lines (loading, quantizing, ...)
|
||||
- `progress` — `{step, total, elapsed}`
|
||||
- `latent` — raw fp16 tensor payload; `shape`, `dtype`, `layout` (`BCHW`,
|
||||
`BCFHW`, `BLC`). Video latents are subsampled to `max_frames` frames.
|
||||
- `result` — `{path, kind, ext, seed, width, height, ...}` per produced file
|
||||
- `error` — `{message, cancelled}`
|
||||
- `end` — `{status}`
|
||||
|
||||
Python reader: `protocol.FrameReader`; browser reader:
|
||||
`ui/src/lib/engineStream.ts` (also does the latent→RGB projection from the
|
||||
`preview` block, so the client needs no per-arch code).
|
||||
|
||||
### Per-step latents
|
||||
|
||||
`BaseModel.generate_images` wraps `scheduler.step` on the sampling scheduler
|
||||
while `sample_step_hook` is set, so every diffusers-style pipeline reports
|
||||
its latents without per-arch code. Holders with a hand-rolled loop call
|
||||
`self._emit_sample_step(latents, i, n)` (ace_step does).
|
||||
|
||||
## Component pool
|
||||
|
||||
`toolkit/models/v2/pool.py`. While a pool is active, `OstrisModelMixin.
|
||||
load_model` returns the resident module for an identical source request and
|
||||
`aitk_post_load` is a no-op for a module already carrying that policy. After
|
||||
each holder build the engine frees every pooled component the new model did
|
||||
not touch. On CUDA OOM during a load it frees the untouched components and
|
||||
retries once. Components loaded outside the mixin's `load_model` (custom
|
||||
holder paths, legacy monolith archs) are not pooled and reload each time.
|
||||
|
||||
## Tests
|
||||
|
||||
```
|
||||
python testing/test_inference_engine.py --arch zimage --quantize
|
||||
python testing/test_inference_engine.py --arch zimage --cancel
|
||||
python testing/test_inference_engine.py --swap zimage,zimage_l2p,zimage --quantize
|
||||
python testing/test_inference_engine.py --all
|
||||
```
|
||||
|
||||
`--quantize` forces convrot8 on transformer + TE for small GPUs;
|
||||
`--allow-download` permits hub downloads (default offline → SKIP).
|
||||
@@ -0,0 +1,18 @@
|
||||
from toolkit.extension import Extension
|
||||
|
||||
|
||||
class InferenceEngineExtension(Extension):
|
||||
uid = "InferenceEngine"
|
||||
name = "Inference Engine"
|
||||
|
||||
@classmethod
|
||||
def get_process(cls):
|
||||
# import your process class here so it is only loaded when needed and return it
|
||||
from .InferenceEngine import InferenceEngine
|
||||
|
||||
return InferenceEngine
|
||||
|
||||
|
||||
AI_TOOLKIT_EXTENSIONS = [
|
||||
InferenceEngineExtension,
|
||||
]
|
||||
@@ -0,0 +1,546 @@
|
||||
"""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.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 {})
|
||||
self.frames: "queue.Queue[Optional[bytes]]" = queue.Queue()
|
||||
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}
|
||||
self.frames.put(encode_frame(header, payload))
|
||||
|
||||
def finish(self):
|
||||
self.finished_at = time.time()
|
||||
self.emit("end", status=self.status)
|
||||
self.frames.put(END)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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.holder = None
|
||||
self.holder_key: Optional[str] = None
|
||||
self.holder_model: Optional[dict] = None
|
||||
self.holder_is_legacy = False
|
||||
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
|
||||
self.queue.put(job)
|
||||
return job
|
||||
|
||||
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")}
|
||||
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."""
|
||||
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"
|
||||
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:
|
||||
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)
|
||||
key = _model_key(model)
|
||||
if self.holder is not None and key == self.holder_key:
|
||||
return self.holder
|
||||
if self.holder is not None:
|
||||
self._drop_holder()
|
||||
|
||||
self.pool.begin_request()
|
||||
ComponentPool.current = self.pool
|
||||
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
|
||||
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
|
||||
return holder
|
||||
|
||||
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,
|
||||
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:
|
||||
job.status = "cancelled"
|
||||
job.error = "cancelled"
|
||||
self.stats["cancelled"] += 1
|
||||
job.emit("error", message="cancelled", cancelled=True)
|
||||
flush()
|
||||
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"
|
||||
@@ -0,0 +1,543 @@
|
||||
"""Latent -> RGB preview tables.
|
||||
|
||||
Linear per-pixel projections (channels x 3, plus bias) so a client can turn a
|
||||
raw latent into a rough RGB preview without a VAE. Numeric tables come from
|
||||
ComfyUI comfy/latent_formats.py (same VAE families). Layouts: dims 2 = BCHW
|
||||
image latents, dims 3 = BCFHW video latents. "reshape" names a packing the
|
||||
client must undo first (flux2: 32 channels x 2x2 pixel shuffle).
|
||||
"""
|
||||
|
||||
LATENT_FORMATS = {
|
||||
"SD15": {
|
||||
"channels": 4, "dims": 2, "spatial": 8, "temporal": 1,
|
||||
"reshape": None,
|
||||
"bias": None,
|
||||
"factors": [
|
||||
[0.3512, 0.2297, 0.3227],
|
||||
[0.325, 0.4974, 0.235],
|
||||
[-0.2829, 0.1762, 0.2721],
|
||||
[-0.212, -0.2616, -0.7177],
|
||||
],
|
||||
},
|
||||
"SDXL": {
|
||||
"channels": 4, "dims": 2, "spatial": 8, "temporal": 1,
|
||||
"reshape": None,
|
||||
"bias": [0.1084, -0.0175, -0.0011],
|
||||
"factors": [
|
||||
[0.3651, 0.4232, 0.4341],
|
||||
[-0.2533, -0.0042, 0.1068],
|
||||
[0.1076, 0.1111, -0.0362],
|
||||
[-0.3165, -0.2492, -0.2188],
|
||||
],
|
||||
},
|
||||
"SD3": {
|
||||
"channels": 16, "dims": 2, "spatial": 8, "temporal": 1,
|
||||
"reshape": None,
|
||||
"bias": [0.2394, 0.2135, 0.1925],
|
||||
"factors": [
|
||||
[-0.0922, -0.0175, 0.0749],
|
||||
[0.0311, 0.0633, 0.0954],
|
||||
[0.1994, 0.0927, 0.0458],
|
||||
[0.0856, 0.0339, 0.0902],
|
||||
[0.0587, 0.0272, -0.0496],
|
||||
[-0.0006, 0.1104, 0.0309],
|
||||
[0.0978, 0.0306, 0.0427],
|
||||
[-0.0042, 0.1038, 0.1358],
|
||||
[-0.0194, 0.002, 0.0669],
|
||||
[-0.0488, 0.013, -0.0268],
|
||||
[0.0922, 0.0988, 0.0951],
|
||||
[-0.0278, 0.0524, -0.0542],
|
||||
[0.0332, 0.0456, 0.0895],
|
||||
[-0.0069, -0.003, -0.081],
|
||||
[-0.0596, -0.0465, -0.0293],
|
||||
[-0.1448, -0.1463, -0.1189],
|
||||
],
|
||||
},
|
||||
"Flux": {
|
||||
"channels": 16, "dims": 2, "spatial": 8, "temporal": 1,
|
||||
"reshape": None,
|
||||
"bias": [-0.0329, -0.0718, -0.0851],
|
||||
"factors": [
|
||||
[-0.0346, 0.0244, 0.0681],
|
||||
[0.0034, 0.021, 0.0687],
|
||||
[0.0275, -0.0668, -0.0433],
|
||||
[-0.0174, 0.016, 0.0617],
|
||||
[0.0859, 0.0721, 0.0329],
|
||||
[0.0004, 0.0383, 0.0115],
|
||||
[0.0405, 0.0861, 0.0915],
|
||||
[-0.0236, -0.0185, -0.0259],
|
||||
[-0.0245, 0.025, 0.118],
|
||||
[0.1008, 0.0755, -0.0421],
|
||||
[-0.0515, 0.0201, 0.0011],
|
||||
[0.0428, -0.0012, -0.0036],
|
||||
[0.0817, 0.0765, 0.0749],
|
||||
[-0.1264, -0.0522, -0.1103],
|
||||
[-0.028, -0.0881, -0.0499],
|
||||
[-0.1262, -0.0982, -0.0778],
|
||||
],
|
||||
},
|
||||
"Flux2": {
|
||||
"channels": 128, "dims": 2, "spatial": 16, "temporal": 1,
|
||||
"reshape": "flux2_2x2",
|
||||
"bias": [-0.0329, -0.0718, -0.0851],
|
||||
"factors": [
|
||||
[0.0058, 0.0113, 0.0073],
|
||||
[0.0495, 0.0443, 0.0836],
|
||||
[-0.0099, 0.0096, 0.0644],
|
||||
[0.2144, 0.3009, 0.3652],
|
||||
[0.0166, -0.0039, -0.0054],
|
||||
[0.0157, 0.0103, -0.016],
|
||||
[-0.0398, 0.0902, -0.0235],
|
||||
[-0.0052, 0.0095, 0.0109],
|
||||
[-0.3527, -0.2712, -0.1666],
|
||||
[-0.0301, -0.0356, -0.018],
|
||||
[-0.0107, 0.0078, 0.0013],
|
||||
[0.0746, 0.009, -0.0941],
|
||||
[0.0156, 0.0169, 0.007],
|
||||
[-0.0034, -0.004, -0.0114],
|
||||
[0.0032, 0.0181, 0.008],
|
||||
[-0.0939, -0.0008, 0.0186],
|
||||
[0.0018, 0.0043, 0.0104],
|
||||
[0.0284, 0.0056, -0.0127],
|
||||
[-0.0024, -0.0022, -0.003],
|
||||
[0.1207, -0.0026, 0.0065],
|
||||
[0.0128, 0.0101, 0.0142],
|
||||
[0.0137, -0.0072, -0.0007],
|
||||
[0.0095, 0.0092, -0.0059],
|
||||
[0.0, -0.0077, -0.0049],
|
||||
[-0.0465, -0.0204, -0.0312],
|
||||
[0.0095, 0.0012, -0.0066],
|
||||
[0.029, -0.0034, 0.0025],
|
||||
[0.022, 0.0169, -0.0048],
|
||||
[-0.0332, -0.0457, -0.0468],
|
||||
[-0.0085, 0.0389, 0.0609],
|
||||
[-0.0076, 0.0003, -0.0043],
|
||||
[-0.0111, -0.046, -0.0614],
|
||||
],
|
||||
},
|
||||
"Wan21": {
|
||||
"channels": 16, "dims": 3, "spatial": 8, "temporal": 4,
|
||||
"reshape": None,
|
||||
"bias": [-0.1835, -0.0868, -0.336],
|
||||
"factors": [
|
||||
[-0.1299, -0.1692, 0.2932],
|
||||
[0.0671, 0.0406, 0.0442],
|
||||
[0.3568, 0.2548, 0.1747],
|
||||
[0.0372, 0.2344, 0.142],
|
||||
[0.0313, 0.0189, -0.0328],
|
||||
[0.0296, -0.0956, -0.0665],
|
||||
[-0.3477, -0.4059, -0.2925],
|
||||
[0.0166, 0.1902, 0.1975],
|
||||
[-0.0412, 0.0267, -0.1364],
|
||||
[-0.1293, 0.074, 0.1636],
|
||||
[0.068, 0.3019, 0.1128],
|
||||
[0.0032, 0.0581, 0.0639],
|
||||
[-0.1251, 0.0927, 0.1699],
|
||||
[0.006, -0.0633, 0.0005],
|
||||
[0.3477, 0.2275, 0.295],
|
||||
[0.1984, 0.0913, 0.1861],
|
||||
],
|
||||
},
|
||||
"Wan22": {
|
||||
"channels": 48, "dims": 3, "spatial": 16, "temporal": 4,
|
||||
"reshape": None,
|
||||
"bias": [0.0317, -0.0878, -0.1388],
|
||||
"factors": [
|
||||
[0.0119, 0.0103, 0.0046],
|
||||
[-0.1062, -0.0504, 0.0165],
|
||||
[0.014, 0.0409, 0.0491],
|
||||
[-0.0813, -0.0677, 0.0607],
|
||||
[0.0656, 0.0851, 0.0808],
|
||||
[0.0264, 0.0463, 0.0912],
|
||||
[0.0295, 0.0326, 0.059],
|
||||
[-0.0244, -0.027, 0.0025],
|
||||
[0.0443, -0.0102, 0.0288],
|
||||
[-0.0465, -0.009, -0.0205],
|
||||
[0.0359, 0.0236, 0.0082],
|
||||
[-0.0776, 0.0854, 0.1048],
|
||||
[0.0564, 0.0264, 0.0561],
|
||||
[0.0006, 0.0594, 0.0418],
|
||||
[-0.0319, -0.0542, -0.0637],
|
||||
[-0.0268, 0.0024, 0.026],
|
||||
[0.0539, 0.0265, 0.0358],
|
||||
[-0.0359, -0.0312, -0.0287],
|
||||
[-0.0285, -0.1032, -0.1237],
|
||||
[0.1041, 0.0537, 0.0622],
|
||||
[-0.0086, -0.0374, -0.0051],
|
||||
[0.039, 0.067, 0.2863],
|
||||
[0.0069, 0.0144, 0.0082],
|
||||
[0.0006, -0.0167, 0.0079],
|
||||
[0.0313, -0.0574, -0.0232],
|
||||
[-0.1454, -0.0902, -0.0481],
|
||||
[0.0714, 0.0827, 0.0447],
|
||||
[-0.0304, -0.0574, -0.0196],
|
||||
[0.0401, 0.0384, 0.0204],
|
||||
[-0.0758, -0.0297, -0.0014],
|
||||
[0.0568, 0.1307, 0.1372],
|
||||
[-0.0055, -0.031, -0.038],
|
||||
[0.0239, -0.0305, 0.0325],
|
||||
[-0.0663, -0.0673, -0.014],
|
||||
[-0.0416, -0.0047, -0.0023],
|
||||
[0.0166, 0.0112, -0.0093],
|
||||
[-0.0211, 0.0011, 0.0331],
|
||||
[0.1833, 0.1466, 0.225],
|
||||
[-0.0368, 0.037, 0.0295],
|
||||
[-0.3441, -0.3543, -0.2008],
|
||||
[-0.0479, -0.0489, -0.042],
|
||||
[-0.066, -0.0153, 0.08],
|
||||
[-0.0101, 0.0068, 0.0156],
|
||||
[-0.069, -0.0452, -0.0927],
|
||||
[-0.0145, 0.0041, 0.0015],
|
||||
[0.0421, 0.0451, 0.0373],
|
||||
[0.0504, -0.0483, -0.0356],
|
||||
[-0.0837, 0.0168, 0.0055],
|
||||
],
|
||||
},
|
||||
"LTXV": {
|
||||
"channels": 128, "dims": 3, "spatial": 32, "temporal": 8,
|
||||
"reshape": None,
|
||||
"bias": [-0.0571, -0.1657, -0.2512],
|
||||
"factors": [
|
||||
[0.011202, -0.00063815, -0.010021],
|
||||
[0.086031, 0.065813, 0.00095409],
|
||||
[-0.012576, -0.0075734, -0.0040528],
|
||||
[0.0094063, -0.0021688, 0.0026093],
|
||||
[0.0037636, 0.012765, 0.0091548],
|
||||
[0.021024, -0.0052973, 0.0034373],
|
||||
[-0.0088896, -0.019703, -0.018761],
|
||||
[-0.01316, -0.010523, 0.0019709],
|
||||
[-0.0015152, -0.0069891, -0.007581],
|
||||
[-0.0017247, 0.0004656, -0.0033839],
|
||||
[0.013617, 0.0047077, -0.0020045],
|
||||
[0.010256, 0.0077318, 0.013948],
|
||||
[-0.016108, -0.0062151, 0.0011561],
|
||||
[0.0073407, 0.015628, 0.00044865],
|
||||
[0.00095357, -0.0029518, -0.01476],
|
||||
[0.019143, 0.010868, 0.012264],
|
||||
[0.0044575, 3.6682e-05, -0.0068508],
|
||||
[-0.00045681, 0.003257, 0.0077929],
|
||||
[0.033902, 0.033405, 0.037454],
|
||||
[-0.023001, -0.0024877, -0.0031033],
|
||||
[0.050265, 0.038841, 0.033539],
|
||||
[-0.0041018, -0.0011095, 0.0015859],
|
||||
[-0.12689, -0.13107, -0.21005],
|
||||
[0.026276, 0.014189, -0.0035963],
|
||||
[-0.0048679, 0.0088486, 0.0078029],
|
||||
[-0.001661, -0.0048597, -0.005206],
|
||||
[-0.002101, 0.002361, 0.0093796],
|
||||
[-0.022482, -0.021305, -0.015087],
|
||||
[-0.015753, -0.010646, -0.0065083],
|
||||
[-0.0046975, 0.0050288, -0.006739],
|
||||
[0.011951, 0.020712, 0.016191],
|
||||
[-0.0063704, -0.0084827, -0.0095483],
|
||||
[0.007261, -0.0099326, -0.022978],
|
||||
[-0.00091904, 0.0062882, 0.009572],
|
||||
[-0.037178, -0.037123, -0.056713],
|
||||
[-0.13373, -0.1072, -0.053801],
|
||||
[-0.0053702, 0.0081256, 0.0088397],
|
||||
[-0.15247, -0.21437, -0.21843],
|
||||
[0.031441, 0.0070335, -0.0097541],
|
||||
[0.0021528, -0.0089817, -0.021023],
|
||||
[0.0038461, -0.0058957, -0.015014],
|
||||
[-0.004347, -0.01294, -0.015972],
|
||||
[-0.0054781, -0.010842, -0.0030204],
|
||||
[-0.0065347, 0.0030806, -0.010163],
|
||||
[-0.0050414, -0.0071503, -0.00089686],
|
||||
[-0.0085851, -0.0024351, 0.0010674],
|
||||
[-0.0090016, -0.0096493, 0.0015692],
|
||||
[0.0050914, 0.012099, 0.019968],
|
||||
[0.013758, 0.011669, 0.0081958],
|
||||
[-0.010518, -0.011575, -0.0041307],
|
||||
[-0.02841, -0.031266, -0.022149],
|
||||
[0.0029336, 0.036511, 0.018717],
|
||||
[-0.016703, -0.016696, -0.0044529],
|
||||
[0.048818, 0.040063, 0.008741],
|
||||
[-0.015066, -0.00057328, 0.0029785],
|
||||
[-0.017613, -0.0081034, 0.013086],
|
||||
[-0.0092633, 0.010803, -0.0063489],
|
||||
[0.0030851, 0.0004775, 0.012347],
|
||||
[-0.022785, -0.023043, -0.026005],
|
||||
[-0.024787, -0.015389, -0.022104],
|
||||
[-0.023572, 0.0010544, 0.012361],
|
||||
[-0.0078915, -0.0012271, -0.0060968],
|
||||
[-0.011478, -0.0012543, 0.0062679],
|
||||
[-0.054229, 0.026644, 0.0063394],
|
||||
[0.0044216, -0.0073338, -0.010464],
|
||||
[-0.0045013, 0.0016082, 0.01442],
|
||||
[0.013673, 0.0088877, 0.0041253],
|
||||
[-0.010145, 0.0090072, 0.015695],
|
||||
[-0.0056234, 0.0011847, 0.0081261],
|
||||
[-0.0037171, -0.0053538, 0.001259],
|
||||
[0.029476, 0.021424, 0.030424],
|
||||
[-0.034925, -0.02434, -0.025316],
|
||||
[-0.034127, -0.022406, -0.010589],
|
||||
[-0.017342, -0.013249, -0.010719],
|
||||
[-0.0021478, -0.0086051, -0.0029878],
|
||||
[0.0012089, -0.0042391, -0.0068569],
|
||||
[0.00090411, -0.0066886, -6.7547e-05],
|
||||
[0.016048, -0.010057, -0.028929],
|
||||
[0.001229, 0.010163, 0.018861],
|
||||
[0.017264, 0.00027257, 0.013785],
|
||||
[-0.013482, -0.0036427, 0.00067481],
|
||||
[0.0046782, -0.0052423, 0.0024467],
|
||||
[-0.0059113, -0.0062244, -0.0018162],
|
||||
[0.015496, 0.014582, 0.0019514],
|
||||
[0.0074958, 0.0015886, -0.0082305],
|
||||
[0.019086, 0.001636, -0.0039674],
|
||||
[-0.0057021, -0.0027307, -0.0041066],
|
||||
[0.001745, 0.014602, 0.025794],
|
||||
[-0.00082788, 0.0022902, 0.0045161],
|
||||
[0.011632, 0.0089193, -0.0072813],
|
||||
[0.0075721, 0.0026784, 0.011393],
|
||||
[0.0051939, 0.0036903, 0.014049],
|
||||
[-0.018383, -0.022529, -0.024477],
|
||||
[0.00058842, -0.0057874, -0.01477],
|
||||
[-0.016125, -0.0086101, -0.014533],
|
||||
[0.02054, 0.020729, 0.0064338],
|
||||
[0.0033587, -0.011226, -0.016444],
|
||||
[-0.0014742, -0.010489, 0.0017097],
|
||||
[0.02813, 0.023546, 0.032791],
|
||||
[-0.018532, -0.012842, -0.0087756],
|
||||
[-0.0080533, -0.010771, -0.017536],
|
||||
[-0.0039009, 0.01615, 0.033359],
|
||||
[-0.0074554, -0.014154, -0.006191],
|
||||
[0.0034734, -0.01137, -0.010581],
|
||||
[0.011476, 0.0039281, 0.0028231],
|
||||
[0.0071639, -0.0014741, -0.0038066],
|
||||
[0.002225, -0.0087552, -0.0095719],
|
||||
[0.024146, 0.021696, 0.028056],
|
||||
[-0.0054365, -0.024291, -0.017802],
|
||||
[0.0074263, 0.01051, 0.012705],
|
||||
[0.0062669, 0.0062658, 0.019211],
|
||||
[0.016378, 0.0094933, 0.0066971],
|
||||
[0.017173, 0.023601, 0.023296],
|
||||
[-0.014568, -0.0098279, -0.011556],
|
||||
[0.014431, 0.01443, 0.0066362],
|
||||
[-0.006823, 0.018863, 0.014555],
|
||||
[0.0061156, 0.00347, -0.0026662],
|
||||
[-0.0026983, -0.0059402, -0.0092276],
|
||||
[0.010235, 0.0074173, -0.0076243],
|
||||
[-0.013255, 0.019322, -0.00092153],
|
||||
[0.0024222, -0.0048039, -0.015759],
|
||||
[0.026244, 0.025951, 0.020249],
|
||||
[0.015711, 0.018498, 0.0027407],
|
||||
[-0.0021714, 0.0047214, -0.022443],
|
||||
[-0.0074747, 0.0074166, 0.01443],
|
||||
[-0.0083906, -0.0079776, 0.0097927],
|
||||
[0.038321, 0.0096622, -0.019268],
|
||||
[-0.014605, -0.0067032, 0.0039675],
|
||||
],
|
||||
},
|
||||
"LTXAV": {
|
||||
"channels": 128, "dims": 3, "spatial": 32, "temporal": 8,
|
||||
"reshape": None,
|
||||
"bias": [-0.347892, -0.363814, -0.370287],
|
||||
"factors": [
|
||||
[0.001135, -0.010555, -0.004925],
|
||||
[-0.008019, -0.006231, -0.005564],
|
||||
[0.012637, 0.005605, 0.012713],
|
||||
[0.023454, 0.020771, 0.017844],
|
||||
[-0.01194, -0.000932, 0.009292],
|
||||
[0.018602, 0.011018, 0.013969],
|
||||
[-0.036369, -0.046631, -0.057898],
|
||||
[-0.031919, 0.000131, 0.015214],
|
||||
[0.014519, 0.021041, 0.015325],
|
||||
[0.018889, 0.016149, -0.002836],
|
||||
[-0.003784, -0.006057, -0.008195],
|
||||
[0.013262, 0.030259, 0.029775],
|
||||
[0.050465, 0.050366, 0.025255],
|
||||
[0.018628, 0.007691, 0.002893],
|
||||
[-0.015698, -0.008451, -0.000676],
|
||||
[-0.0136, -0.012587, -0.004437],
|
||||
[0.012482, 0.021469, 0.027913],
|
||||
[-0.018241, -0.013488, -0.010975],
|
||||
[0.013828, 0.012568, 0.021984],
|
||||
[0.017911, 0.006552, 0.005567],
|
||||
[0.026769, 0.006803, -0.00936],
|
||||
[-0.006794, -0.008447, -0.013921],
|
||||
[0.029708, 0.018671, 0.022811],
|
||||
[-0.014732, -0.019169, 0.000903],
|
||||
[0.019607, 0.032595, 0.053409],
|
||||
[-0.003721, 0.003976, 0.010364],
|
||||
[-0.020193, -0.026076, -0.036068],
|
||||
[-0.002328, 0.006527, 0.013052],
|
||||
[0.017171, 0.009224, 0.006548],
|
||||
[0.001104, -0.000591, 0.000147],
|
||||
[-0.000217, 0.011834, 0.017945],
|
||||
[-0.015329, -0.012463, -0.006178],
|
||||
[-0.009478, -0.00868, -0.004107],
|
||||
[-0.005565, -0.006006, -0.001493],
|
||||
[0.009451, 0.008794, 0.013207],
|
||||
[-0.009989, -0.008027, -0.009568],
|
||||
[-0.001505, -0.008805, -0.006828],
|
||||
[0.001105, 0.008999, 0.009079],
|
||||
[0.025935, 0.016426, 0.008036],
|
||||
[0.006313, 0.000694, -0.006039],
|
||||
[-0.001893, -0.006951, -0.00956],
|
||||
[-0.007082, -0.002566, -0.007152],
|
||||
[-0.005231, 0.004829, 0.00822],
|
||||
[-0.004333, 0.001251, -0.004852],
|
||||
[-0.017024, -0.01273, -0.007457],
|
||||
[0.024988, 0.032963, 0.036556],
|
||||
[0.013697, 0.012278, 0.009979],
|
||||
[-0.013751, -0.008369, -0.015446],
|
||||
[-0.009348, -0.001047, 0.007622],
|
||||
[-0.003135, -0.00335, -0.003766],
|
||||
[0.007436, 0.004957, 0.01048],
|
||||
[0.018315, 0.022066, 0.021104],
|
||||
[-0.005621, -0.00677, -0.008219],
|
||||
[-0.007427, 0.001911, -0.001231],
|
||||
[-0.007413, 0.000486, -0.006039],
|
||||
[-0.014698, -0.00716, 0.006509],
|
||||
[0.013775, 0.014185, 0.008203],
|
||||
[0.060246, 0.069787, 0.072833],
|
||||
[0.009861, 0.00487, 0.001194],
|
||||
[-0.00366, 0.003251, 0.008015],
|
||||
[0.003696, -0.00368, -0.008851],
|
||||
[0.014924, 0.006196, 0.005282],
|
||||
[-0.00674, -0.004319, -0.006729],
|
||||
[0.020635, 0.015163, 0.012385],
|
||||
[-0.032623, -0.006105, 0.010436],
|
||||
[-0.058988, -0.030162, -0.037961],
|
||||
[-0.035614, -0.021929, -0.011062],
|
||||
[-0.023412, -0.011305, -0.005054],
|
||||
[-0.002716, -0.005184, -0.004084],
|
||||
[0.014591, 0.015294, 0.014045],
|
||||
[0.00831, 0.002466, -0.003225],
|
||||
[0.005176, 0.001119, 0.000695],
|
||||
[-0.021569, -0.030886, -0.044732],
|
||||
[0.007517, 0.003891, 0.000551],
|
||||
[-0.006793, 0.004059, 0.010184],
|
||||
[-0.086481, -0.082033, -0.083414],
|
||||
[0.004192, 0.000762, -0.008658],
|
||||
[0.01097, 0.009002, 0.007384],
|
||||
[0.004042, -0.006732, -0.011031],
|
||||
[0.012164, 0.006401, 0.007483],
|
||||
[0.029252, 0.01399, 0.011128],
|
||||
[0.048452, 0.034648, 0.016269],
|
||||
[0.024104, 0.012647, 0.011754],
|
||||
[-0.013216, -0.020192, -0.019752],
|
||||
[-0.010799, -0.008535, -0.005467],
|
||||
[0.005823, 0.001403, 0.00189],
|
||||
[0.052393, 0.044771, 0.032777],
|
||||
[0.007576, -0.00808, -0.012453],
|
||||
[0.00983, 0.004244, 0.001213],
|
||||
[-0.025867, -0.013169, -0.010636],
|
||||
[0.008494, 0.003135, 0.00079],
|
||||
[0.003969, -0.002625, -0.010204],
|
||||
[0.006509, 0.008272, 0.020819],
|
||||
[-0.004943, -0.013424, -0.015351],
|
||||
[0.005541, 0.009136, -0.003666],
|
||||
[-0.0143, -0.015864, -0.016853],
|
||||
[0.00265, 0.028393, 0.014125],
|
||||
[-0.027661, -0.045422, -0.064995],
|
||||
[0.00922, 0.015522, 0.010574],
|
||||
[-0.002236, 0.002915, 0.004557],
|
||||
[-0.020269, -0.008212, -0.000532],
|
||||
[0.019294, 0.003655, -0.002809],
|
||||
[0.007116, -0.002784, 1.7e-05],
|
||||
[0.057277, 0.07327, 0.074401],
|
||||
[-0.002616, -0.001696, -0.000498],
|
||||
[0.007248, 0.009793, 0.022829],
|
||||
[-0.00259, -0.005601, -0.000436],
|
||||
[-0.007681, 0.003893, -0.004119],
|
||||
[-0.057392, -0.045545, -0.02529],
|
||||
[0.045188, 0.047985, 0.054059],
|
||||
[0.000937, -0.008861, -0.038406],
|
||||
[-0.010192, -0.008036, -0.005385],
|
||||
[-0.030222, -0.027498, -0.030765],
|
||||
[-0.008359, 0.013247, 0.010918],
|
||||
[0.004102, 0.002093, 0.006934],
|
||||
[0.039461, 0.027339, 0.008284],
|
||||
[-0.075747, -0.07634, -0.071625],
|
||||
[0.002692, 0.005096, -0.002247],
|
||||
[-0.002453, -0.002785, -0.010483],
|
||||
[0.012265, 0.005481, 0.001729],
|
||||
[0.017755, 0.008655, 0.003532],
|
||||
[0.05556, 0.049128, 0.044137],
|
||||
[-0.025861, -0.023798, -0.018815],
|
||||
[-0.014876, -0.01077, -0.010713],
|
||||
[-0.017315, -0.012599, -0.008661],
|
||||
[-0.008461, -0.00621, -0.007744],
|
||||
[-0.040175, -0.042255, -0.048119],
|
||||
[-0.019355, -0.021055, -0.021919],
|
||||
],
|
||||
},
|
||||
"MiniMaxH3Video": {
|
||||
"channels": 24, "dims": 3, "spatial": 16, "temporal": 4,
|
||||
"reshape": None,
|
||||
"bias": [0.057426, -0.022078, -0.071449],
|
||||
"factors": [
|
||||
[-0.018555, 0.024344, -0.017536],
|
||||
[0.150164, 0.137244, 0.129221],
|
||||
[0.027367, -0.050369, -0.208606],
|
||||
[-0.000793, -0.164622, -0.323161],
|
||||
[-0.048556, 0.01397, -0.074286],
|
||||
[0.01174, 0.014172, -0.006906],
|
||||
[0.061517, 0.061212, 0.110025],
|
||||
[0.035321, 0.086879, 0.110059],
|
||||
[-0.017426, 0.002997, 0.035356],
|
||||
[0.531539, 0.548819, 0.624404],
|
||||
[-0.024968, -0.040234, -0.034302],
|
||||
[-0.032549, -0.029096, -0.017221],
|
||||
[0.022609, 0.020286, 0.050661],
|
||||
[-0.084001, -0.038131, -0.020805],
|
||||
[-0.01883, 0.010412, 0.06112],
|
||||
[0.020777, 0.011196, -0.030994],
|
||||
[-0.00839, -0.012201, -0.025687],
|
||||
[-0.013281, -0.002924, 0.006331],
|
||||
[0.00026, 0.001833, -0.011038],
|
||||
[0.105471, 0.100482, 0.132106],
|
||||
[0.016529, 0.015213, 0.009999],
|
||||
[-0.014015, -0.017438, -0.019134],
|
||||
[-0.033787, -0.009984, -0.019725],
|
||||
[0.004224, 0.017284, 0.027196],
|
||||
],
|
||||
},
|
||||
"HunyuanVideo": {
|
||||
"channels": 16, "dims": 3, "spatial": 8, "temporal": 4,
|
||||
"reshape": None,
|
||||
"bias": [0.0259, -0.0192, -0.0761],
|
||||
"factors": [
|
||||
[-0.0395, -0.0331, 0.0445],
|
||||
[0.0696, 0.0795, 0.0518],
|
||||
[0.0135, -0.0945, -0.0282],
|
||||
[0.0108, -0.025, -0.0765],
|
||||
[-0.0209, 0.0032, 0.0224],
|
||||
[-0.0804, -0.0254, -0.0639],
|
||||
[-0.0991, 0.0271, -0.0669],
|
||||
[-0.0646, -0.0422, -0.04],
|
||||
[-0.0696, -0.0595, -0.0894],
|
||||
[-0.0799, -0.0208, -0.0375],
|
||||
[0.1166, 0.1627, 0.0962],
|
||||
[0.1165, 0.0432, 0.0407],
|
||||
[-0.2315, -0.192, -0.1355],
|
||||
[-0.027, 0.0401, -0.0821],
|
||||
[-0.0616, -0.0997, -0.0727],
|
||||
[0.0249, -0.0469, -0.1703],
|
||||
],
|
||||
},
|
||||
"Cosmos1CV8x8x8": {
|
||||
"channels": 16, "dims": 3, "spatial": 8, "temporal": 8,
|
||||
"reshape": None,
|
||||
"bias": [-0.1223, -0.1889, -0.1976],
|
||||
"factors": [
|
||||
[0.1817, 0.2284, 0.2423],
|
||||
[-0.0586, -0.0862, -0.3108],
|
||||
[-0.4703, -0.4255, -0.3995],
|
||||
[0.0803, 0.1963, 0.1001],
|
||||
[-0.082, -0.105, 0.04],
|
||||
[0.2511, 0.3098, 0.2787],
|
||||
[-0.183, -0.2117, -0.004],
|
||||
[-0.0621, -0.2187, -0.0939],
|
||||
[0.3619, 0.1082, 0.1455],
|
||||
[0.3164, 0.3922, 0.2575],
|
||||
[0.1152, 0.0231, -0.0462],
|
||||
[-0.1434, -0.3609, -0.3665],
|
||||
[0.0635, 0.1471, 0.168],
|
||||
[-0.3635, -0.1963, -0.3248],
|
||||
[-0.1865, 0.0365, 0.2346],
|
||||
[0.0447, 0.0994, 0.0881],
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
from typing import Optional
|
||||
|
||||
from .latent_format_tables import LATENT_FORMATS
|
||||
|
||||
# arch -> preview table. Archs sharing a VAE family share a table; anything
|
||||
# not listed falls back to a channel-count guess in `guess_format`.
|
||||
ARCH_LATENT_FORMAT = {
|
||||
"sd1": "SD15",
|
||||
"sd2": "SD15",
|
||||
"sdxl": "SDXL",
|
||||
"ssd": "SDXL",
|
||||
"vega": "SDXL",
|
||||
"sd3": "SD3",
|
||||
"flux": "Flux",
|
||||
"flux_kontext": "Flux",
|
||||
"chroma": "Flux",
|
||||
"chroma_radiance": None, # pixel space
|
||||
"zeta_chroma": "Flux",
|
||||
"zimage": "Flux",
|
||||
"zimage_l2p": "Flux",
|
||||
"krea2": "Wan21", # Qwen-Image VAE
|
||||
"hidream": "Flux",
|
||||
"hidream_e1": "Flux",
|
||||
"hidream_o1": "Flux",
|
||||
"omnigen2": "Flux",
|
||||
"f-lite": "Flux",
|
||||
"nucleus_image": "Wan21", # Qwen-Image VAE
|
||||
"qwen_image": "Wan21",
|
||||
"qwen_image_edit": "Wan21",
|
||||
"qwen_image_edit_plus": "Wan21",
|
||||
"boogu_image": "Wan21",
|
||||
"boogu_image_edit": "Wan21",
|
||||
"flux2": "Flux2",
|
||||
"flux2_klein_4b": "Flux2",
|
||||
"flux2_klein_9b": "Flux2",
|
||||
"ideogram4": "Flux2",
|
||||
"mageflow": "Wan21",
|
||||
"mageflow_edit": "Wan21",
|
||||
"wan21": "Wan21",
|
||||
"wan21_i2v": "Wan21",
|
||||
"wan22_5b": "Wan22",
|
||||
"wan22_14b": "Wan21",
|
||||
"wan22_14b_i2v": "Wan21",
|
||||
"ltx2": "LTXAV",
|
||||
"ltx2.3": "LTXAV",
|
||||
"ltx2.5": "LTXAV",
|
||||
"minimax_h3": "MiniMaxH3Video",
|
||||
"minimax_h3_ref2va": "MiniMaxH3Video",
|
||||
"minimax_h3_vsa": "MiniMaxH3Video",
|
||||
"anima": "Wan21",
|
||||
"cogview4": "Flux",
|
||||
"prx_pixel": None, # pixel space
|
||||
}
|
||||
|
||||
|
||||
def guess_format(channels: int, dims: int) -> Optional[str]:
|
||||
if dims == 2:
|
||||
return {4: "SD15", 16: "Flux", 128: "Flux2"}.get(channels)
|
||||
if dims == 3:
|
||||
return {16: "Wan21", 48: "Wan22", 128: "LTXAV", 24: "MiniMaxH3Video"}.get(channels)
|
||||
return None
|
||||
|
||||
|
||||
def preview_info(arch: str, channels: Optional[int] = None, dims: Optional[int] = None) -> Optional[dict]:
|
||||
"""The `start` frame's preview block: layout + factors, or None when the
|
||||
latent cannot be previewed linearly (pixel-space models, audio)."""
|
||||
# UI arch names may carry a variant suffix ("zimage:turbo", "wan21:1b")
|
||||
arch = (arch or "").split(":")[0]
|
||||
name = ARCH_LATENT_FORMAT.get(arch, "__unset__")
|
||||
if name is None:
|
||||
return None
|
||||
if name == "__unset__" or (channels is not None and LATENT_FORMATS[name]["channels"] != channels):
|
||||
name = guess_format(channels, dims) if channels is not None else None
|
||||
if name is None:
|
||||
return None
|
||||
fmt = LATENT_FORMATS[name]
|
||||
return {
|
||||
"format": name,
|
||||
"channels": fmt["channels"],
|
||||
"dims": fmt["dims"],
|
||||
"spatial": fmt["spatial"],
|
||||
"temporal": fmt["temporal"],
|
||||
"reshape": fmt["reshape"],
|
||||
"factors": fmt["factors"],
|
||||
"bias": fmt["bias"],
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Binary stream framing for engine responses.
|
||||
|
||||
One generation request is answered with a chunked HTTP body made of frames:
|
||||
|
||||
u32 header_len (little endian) | json header (utf-8) |
|
||||
u64 payload_len (little endian) | payload bytes
|
||||
|
||||
Every frame header has "type" and "request_id". Text-only frames carry an
|
||||
empty payload. The javascript reader (ui/src/lib/engineStream.ts) mirrors
|
||||
this exactly. Frame types:
|
||||
|
||||
start resolved model/sample config, output kind, latent preview info
|
||||
status a holder stage line (loading / quantizing / encoding ...)
|
||||
progress {step, total, elapsed}
|
||||
latent raw tensor payload; header carries shape, dtype, layout
|
||||
result {path, url, kind, ext, ...} for each produced file
|
||||
error {message, cancelled}
|
||||
end terminal frame
|
||||
"""
|
||||
|
||||
import json
|
||||
import struct
|
||||
from typing import Iterator, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
_HDR = struct.Struct("<I")
|
||||
_LEN = struct.Struct("<Q")
|
||||
|
||||
|
||||
def encode_frame(header: dict, payload: bytes = b"") -> bytes:
|
||||
hdr = json.dumps(header, separators=(",", ":")).encode("utf-8")
|
||||
return _HDR.pack(len(hdr)) + hdr + _LEN.pack(len(payload)) + payload
|
||||
|
||||
|
||||
def tensor_payload(tensor, dtype="float16") -> Tuple[dict, bytes]:
|
||||
"""Serialize a tensor to raw bytes + the header fields that describe it."""
|
||||
import torch
|
||||
|
||||
t = tensor.detach()
|
||||
if t.is_cuda or t.device.type != "cpu":
|
||||
t = t.to("cpu")
|
||||
if dtype == "float16":
|
||||
t = t.to(torch.float16)
|
||||
elif dtype == "float32":
|
||||
t = t.to(torch.float32)
|
||||
arr = t.contiguous().numpy()
|
||||
return {"shape": list(arr.shape), "dtype": str(arr.dtype)}, arr.tobytes()
|
||||
|
||||
|
||||
def payload_to_array(header: dict, payload: bytes) -> np.ndarray:
|
||||
return np.frombuffer(payload, dtype=np.dtype(header["dtype"])).reshape(header["shape"])
|
||||
|
||||
|
||||
class FrameReader:
|
||||
"""Decode frames from an iterator of byte chunks (tests / python clients)."""
|
||||
|
||||
def __init__(self, chunks: Iterator[bytes]):
|
||||
self._chunks = chunks
|
||||
self._buf = bytearray()
|
||||
self._eof = False
|
||||
|
||||
def _fill(self, n: int) -> bool:
|
||||
while len(self._buf) < n and not self._eof:
|
||||
try:
|
||||
chunk = next(self._chunks)
|
||||
except StopIteration:
|
||||
self._eof = True
|
||||
break
|
||||
if chunk:
|
||||
self._buf.extend(chunk)
|
||||
return len(self._buf) >= n
|
||||
|
||||
def _take(self, n: int) -> bytes:
|
||||
out = bytes(self._buf[:n])
|
||||
del self._buf[:n]
|
||||
return out
|
||||
|
||||
def read(self) -> Optional[Tuple[dict, bytes]]:
|
||||
if not self._fill(_HDR.size):
|
||||
return None
|
||||
(hlen,) = _HDR.unpack(self._take(_HDR.size))
|
||||
if not self._fill(hlen + _LEN.size):
|
||||
raise EOFError("truncated frame header")
|
||||
header = json.loads(self._take(hlen).decode("utf-8"))
|
||||
(plen,) = _LEN.unpack(self._take(_LEN.size))
|
||||
if not self._fill(plen):
|
||||
raise EOFError("truncated frame payload")
|
||||
return header, self._take(plen)
|
||||
|
||||
def __iter__(self):
|
||||
while True:
|
||||
frame = self.read()
|
||||
if frame is None:
|
||||
return
|
||||
yield frame
|
||||
@@ -0,0 +1,152 @@
|
||||
"""HTTP surface of the engine (FastAPI). Runs on its own thread; every call
|
||||
hands work to the Engine and returns, except /generate which streams the
|
||||
job's frames for as long as the generation runs."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
|
||||
from toolkit.models.registry import describe_archs
|
||||
|
||||
from .engine import END, Engine
|
||||
|
||||
_SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
|
||||
|
||||
def create_app(engine: Engine, token: Optional[str] = None) -> FastAPI:
|
||||
app = FastAPI(title="AI Toolkit Inference Engine", docs_url=None, redoc_url=None)
|
||||
|
||||
def check_auth(request: Request):
|
||||
if not token:
|
||||
return
|
||||
supplied = request.headers.get("x-engine-token")
|
||||
if supplied is None:
|
||||
auth = request.headers.get("authorization", "")
|
||||
if auth.lower().startswith("bearer "):
|
||||
supplied = auth[7:]
|
||||
if supplied != token:
|
||||
raise HTTPException(status_code=401, detail="bad engine token")
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return engine.health()
|
||||
|
||||
@app.get("/models")
|
||||
async def models(request: Request):
|
||||
check_auth(request)
|
||||
return {"archs": await asyncio.to_thread(describe_archs)}
|
||||
|
||||
@app.get("/queue")
|
||||
async def queue_(request: Request):
|
||||
check_auth(request)
|
||||
return {"queued": engine.queued(), "recent": engine.recent()}
|
||||
|
||||
@app.post("/cancel/{request_id}")
|
||||
async def cancel(request: Request, request_id: str):
|
||||
check_auth(request)
|
||||
return {"ok": engine.cancel(request_id)}
|
||||
|
||||
@app.post("/unload")
|
||||
async def unload(request: Request):
|
||||
check_auth(request)
|
||||
if engine.current is not None:
|
||||
raise HTTPException(status_code=409, detail="busy")
|
||||
await asyncio.to_thread(engine.unload)
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/assets")
|
||||
async def upload_asset(request: Request, name: str = "asset.png"):
|
||||
"""Raw-body upload (no multipart dependency). Returns the stored path,
|
||||
usable as ctrl_img in a generate request."""
|
||||
check_auth(request)
|
||||
body = await request.body()
|
||||
if not body:
|
||||
raise HTTPException(status_code=400, detail="empty body")
|
||||
base = _SAFE_NAME.sub("_", os.path.basename(name)) or "asset"
|
||||
stem, ext = os.path.splitext(base)
|
||||
asset_id = f"{stem}_{uuid.uuid4().hex[:8]}{ext or '.bin'}"
|
||||
path = os.path.join(engine.assets_folder, asset_id)
|
||||
with open(path, "wb") as f:
|
||||
f.write(body)
|
||||
return {"id": asset_id, "path": path, "bytes": len(body)}
|
||||
|
||||
@app.get("/outputs/{relpath:path}")
|
||||
async def get_output(request: Request, relpath: str):
|
||||
check_auth(request)
|
||||
root = os.path.realpath(engine.output_folder)
|
||||
path = os.path.realpath(os.path.join(root, relpath))
|
||||
if not path.startswith(root + os.sep) or not os.path.isfile(path):
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
return FileResponse(path)
|
||||
|
||||
@app.post("/generate")
|
||||
async def generate(request: Request):
|
||||
check_auth(request)
|
||||
body = await request.json()
|
||||
model = body.get("model")
|
||||
sample = body.get("sample") or {}
|
||||
if not isinstance(model, dict) or not model.get("arch"):
|
||||
raise HTTPException(status_code=400, detail="model.arch is required")
|
||||
if body.get("wait"):
|
||||
# plain JSON mode for scripts: block until the job is done, drop frames
|
||||
job = engine.submit(model, sample, {"latents": "none"})
|
||||
while True:
|
||||
frame = await asyncio.to_thread(job.frames.get)
|
||||
if frame is END:
|
||||
break
|
||||
return JSONResponse(job.info(), status_code=200 if job.status == "done" else 500)
|
||||
|
||||
job = engine.submit(model, sample, body.get("stream"))
|
||||
|
||||
async def frames():
|
||||
try:
|
||||
while True:
|
||||
frame = await asyncio.to_thread(job.frames.get)
|
||||
if frame is END:
|
||||
break
|
||||
yield frame
|
||||
finally:
|
||||
if job.status in ("queued", "running"):
|
||||
engine.cancel(job.request_id)
|
||||
|
||||
return StreamingResponse(
|
||||
frames(),
|
||||
media_type="application/octet-stream",
|
||||
headers={"X-Request-Id": job.request_id, "Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def serve_in_thread(app, host: str = "127.0.0.1", port: int = 0, timeout: float = 30.0) -> Tuple[object, threading.Thread, str, int]:
|
||||
"""Bind the socket ourselves (port 0 = ephemeral) and run uvicorn on a
|
||||
daemon thread. Returns (server, thread, host, port); the caller stops it
|
||||
with server.should_exit = True. uvicorn only installs signal handlers on
|
||||
the main thread, so the process keeps its own."""
|
||||
import uvicorn
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind((host, port))
|
||||
sock.listen(64)
|
||||
host, port = sock.getsockname()[:2]
|
||||
config = uvicorn.Config(app, log_level="warning", access_log=False, timeout_keep_alive=600)
|
||||
server = uvicorn.Server(config)
|
||||
thread = threading.Thread(target=server.run, kwargs={"sockets": [sock]}, daemon=True, name="engine-http")
|
||||
thread.start()
|
||||
deadline = time.time() + timeout
|
||||
while not server.started and time.time() < deadline:
|
||||
if not thread.is_alive():
|
||||
raise RuntimeError("engine http server died on startup")
|
||||
time.sleep(0.05)
|
||||
if not server.started:
|
||||
raise RuntimeError("engine http server failed to start")
|
||||
return server, thread, host, port
|
||||
@@ -31,6 +31,9 @@ sentencepiece
|
||||
huggingface_hub==1.23.0
|
||||
peft==0.18.1
|
||||
gradio
|
||||
# inference engine http server (extensions_built_in/inference_engine)
|
||||
fastapi>=0.115
|
||||
uvicorn>=0.30
|
||||
python-slugify
|
||||
opencv-python
|
||||
pytorch-wavelets==1.3.0
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
"""Inference engine end-to-end test (extensions_built_in/inference_engine).
|
||||
|
||||
Starts the engine in-process on an ephemeral loopback port, sends real
|
||||
generate requests over HTTP, decodes the streamed frames and asserts on
|
||||
them. Model/sample settings come from testing/test_model_loading.py's
|
||||
MODEL_TESTS (tiny sizes: this proves the path, not quality).
|
||||
|
||||
Usage:
|
||||
python testing/test_inference_engine.py --arch zimage # one arch
|
||||
python testing/test_inference_engine.py --swap zimage,wan21 # sequential
|
||||
# model switches
|
||||
python testing/test_inference_engine.py --arch zimage --cancel # cancel mid-run
|
||||
python testing/test_inference_engine.py --all # every arch,
|
||||
# subprocess each
|
||||
--allow-download permit hub downloads (default HF_HUB_OFFLINE=1 -> SKIP)
|
||||
--device cuda:0
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
TOOLKIT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, TOOLKIT_ROOT)
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(os.path.join(TOOLKIT_ROOT, ".env"))
|
||||
|
||||
OUTPUT_ROOT = os.path.join(TOOLKIT_ROOT, "testing", ".engine_test_outputs")
|
||||
|
||||
from testing.test_model_loading import MODEL_TESTS, SKIP_MARKERS, classify_error # noqa: E402
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, host, port, token):
|
||||
self.host, self.port, self.token = host, port, token
|
||||
|
||||
def _conn(self):
|
||||
return http.client.HTTPConnection(self.host, self.port, timeout=3600)
|
||||
|
||||
def _headers(self):
|
||||
return {"Content-Type": "application/json", "X-Engine-Token": self.token}
|
||||
|
||||
def get(self, path):
|
||||
c = self._conn()
|
||||
c.request("GET", path, headers=self._headers())
|
||||
r = c.getresponse()
|
||||
return r.status, json.loads(r.read())
|
||||
|
||||
def post_json(self, path, body=None):
|
||||
c = self._conn()
|
||||
c.request("POST", path, body=json.dumps(body or {}), headers=self._headers())
|
||||
r = c.getresponse()
|
||||
return r.status, json.loads(r.read())
|
||||
|
||||
def upload(self, path, name):
|
||||
c = self._conn()
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
c.request("POST", f"/assets?name={name}", body=data, headers={"X-Engine-Token": self.token})
|
||||
r = c.getresponse()
|
||||
return r.status, json.loads(r.read())
|
||||
|
||||
def generate_stream(self, body):
|
||||
"""Yields (header, payload) frames as they arrive."""
|
||||
from extensions_built_in.inference_engine.protocol import FrameReader
|
||||
|
||||
c = self._conn()
|
||||
c.request("POST", "/generate", body=json.dumps(body), headers=self._headers())
|
||||
r = c.getresponse()
|
||||
if r.status != 200:
|
||||
raise RuntimeError(f"/generate -> {r.status}: {r.read()[:400]}")
|
||||
|
||||
def chunks():
|
||||
while True:
|
||||
data = r.read1(65536) # read1 returns the available chunk instead of blocking for 64KB
|
||||
if not data:
|
||||
return
|
||||
yield data
|
||||
|
||||
reader = FrameReader(chunks())
|
||||
for frame in reader:
|
||||
yield frame
|
||||
c.close()
|
||||
|
||||
|
||||
def start_engine(device: str, out_root: str):
|
||||
from extensions_built_in.inference_engine.engine import Engine
|
||||
from extensions_built_in.inference_engine.server import create_app, serve_in_thread
|
||||
|
||||
import threading
|
||||
|
||||
engine = Engine(device=device, output_folder=os.path.join(out_root, "outputs"), assets_folder=os.path.join(out_root, "assets"))
|
||||
token = "test-token"
|
||||
server, thread, host, port = serve_in_thread(create_app(engine, token))
|
||||
loop = threading.Thread(target=engine.run_forever, daemon=True, name="engine-loop")
|
||||
loop.start()
|
||||
return engine, server, Client(host, port, token)
|
||||
|
||||
|
||||
def stop_engine(engine, server):
|
||||
engine.stop()
|
||||
server.should_exit = True
|
||||
engine.unload()
|
||||
|
||||
|
||||
QUANTIZE_ALL = False # --quantize: force convrot8 on transformer + TE (small GPUs)
|
||||
|
||||
|
||||
def request_body(arch: str, entry: dict, client: Client, out_dir: str, tiny=True, stream=None):
|
||||
model = dict(entry["model"], arch=arch)
|
||||
if QUANTIZE_ALL:
|
||||
model["quantize"] = True
|
||||
model["quantize_te"] = True
|
||||
if model.get("quantize"):
|
||||
model.setdefault("qtype", "convrot8")
|
||||
if model.get("quantize_te"):
|
||||
model.setdefault("qtype_te", "convrot8")
|
||||
sample = dict(entry["sample"])
|
||||
sample["prompt"] = "a photo of a cat sitting on a wooden table"
|
||||
if tiny and not entry.get("size_locked"):
|
||||
# 384 divides every bucket size (16/32/64); video runs also keep the
|
||||
# frame count small so the swap tests fit next to a resident TE
|
||||
cap = 384 if tiny == "tiny" else 512
|
||||
sample["width"] = min(sample["width"], cap)
|
||||
sample["height"] = min(sample["height"], cap)
|
||||
if tiny == "tiny" and "num_frames" in sample:
|
||||
sample["num_frames"] = min(sample["num_frames"], 9)
|
||||
if entry.get("needs_control_image"):
|
||||
from PIL import Image
|
||||
|
||||
ctrl_path = os.path.join(out_dir, "ctrl.png")
|
||||
Image.new("RGB", (sample["width"], sample["height"]), (128, 128, 128)).save(ctrl_path)
|
||||
status, asset = client.upload(ctrl_path, "ctrl.png")
|
||||
assert status == 200, asset
|
||||
sample["ctrl_img"] = asset["path"]
|
||||
return {"model": model, "sample": sample, "stream": stream or {"latents": "raw", "every_n_steps": 1, "max_frames": 2}}
|
||||
|
||||
|
||||
def run_request(client: Client, body: dict, cancel_after: int = 0) -> dict:
|
||||
"""Consume one streamed generation; returns a summary of the frames."""
|
||||
import numpy as np
|
||||
|
||||
from extensions_built_in.inference_engine.protocol import payload_to_array
|
||||
|
||||
seen = {"start": 0, "status": 0, "progress": 0, "latent": 0, "result": 0, "error": 0, "end": 0}
|
||||
summary = {"frames": seen, "results": [], "latent_shapes": [], "error": None, "cancelled": False, "request_id": None, "preview": None}
|
||||
for header, payload in client.generate_stream(body):
|
||||
t = header["type"]
|
||||
seen[t] = seen.get(t, 0) + 1
|
||||
summary["request_id"] = header.get("request_id")
|
||||
if t == "start":
|
||||
summary["preview"] = header.get("preview")
|
||||
summary["modality"] = header.get("modality")
|
||||
elif t == "status":
|
||||
print(f" [status] {header.get('message')}")
|
||||
elif t == "progress":
|
||||
print(f" [progress] {header['step']}/{header['total']} {header['elapsed']}s", flush=True)
|
||||
if cancel_after and header["step"] >= cancel_after:
|
||||
st, res = client.post_json(f"/cancel/{summary['request_id']}")
|
||||
assert st == 200 and res["ok"], res
|
||||
cancel_after = 0
|
||||
elif t == "latent":
|
||||
arr = payload_to_array(header, payload)
|
||||
assert np.isfinite(arr.astype(np.float32)).all(), "non-finite latent"
|
||||
summary["latent_shapes"].append((header["layout"], list(arr.shape)))
|
||||
elif t == "result":
|
||||
summary["results"].append(header)
|
||||
print(f" [result] {header['kind']} {header['path']} ({header['bytes']} bytes)")
|
||||
elif t == "error":
|
||||
summary["error"] = header.get("message")
|
||||
summary["cancelled"] = bool(header.get("cancelled"))
|
||||
if header.get("traceback"):
|
||||
print(header["traceback"])
|
||||
elif t == "end":
|
||||
summary["status"] = header.get("status")
|
||||
return summary
|
||||
|
||||
|
||||
def assert_success(summary: dict, modality: str):
|
||||
assert summary["frames"]["start"] == 1, summary
|
||||
assert summary["frames"]["end"] == 1, summary
|
||||
assert summary["error"] is None, summary["error"]
|
||||
assert summary["frames"]["progress"] >= 1, "no progress frames"
|
||||
assert summary["frames"]["latent"] >= 1, "no latent frames"
|
||||
assert summary["results"], "no result frames"
|
||||
for r in summary["results"]:
|
||||
assert os.path.isfile(r["path"]) and r["bytes"] > 1024, r
|
||||
kinds = {r["kind"] for r in summary["results"]}
|
||||
assert modality in kinds or (modality == "video" and "image" in kinds), (modality, kinds)
|
||||
|
||||
|
||||
def run_one(arch: str, device: str, cancel: bool = False) -> dict:
|
||||
from toolkit.models.registry import get_arch_entry
|
||||
|
||||
entry = MODEL_TESTS[arch]
|
||||
out_root = os.path.join(OUTPUT_ROOT, arch.replace("/", "_"))
|
||||
shutil.rmtree(out_root, ignore_errors=True)
|
||||
os.makedirs(out_root, exist_ok=True)
|
||||
engine, server, client = start_engine(device, out_root)
|
||||
try:
|
||||
st, health = client.get("/health")
|
||||
assert st == 200 and health["ok"], health
|
||||
st, models = client.get("/models")
|
||||
assert st == 200 and any(m["arch"] == arch for m in models["archs"]), f"{arch} missing from /models"
|
||||
modality = get_arch_entry(arch)["modality"]
|
||||
body = request_body(arch, entry, client, out_root)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
if cancel:
|
||||
s = run_request(client, body, cancel_after=2)
|
||||
assert s["cancelled"], f"expected a cancelled stream, got {s}"
|
||||
assert s["frames"]["end"] == 1
|
||||
print(" cancel: ok, re-running the same request")
|
||||
s = run_request(client, body)
|
||||
gen_seconds = time.perf_counter() - t0
|
||||
assert_success(s, modality)
|
||||
st, health = client.get("/health")
|
||||
assert health["active"]["arch"] == arch, health
|
||||
return {
|
||||
"arch": arch,
|
||||
"status": "PASS",
|
||||
"seconds": round(gen_seconds, 2),
|
||||
"frames": s["frames"],
|
||||
"latent_shapes": s["latent_shapes"][:2],
|
||||
"results": [r["path"] for r in s["results"]],
|
||||
"preview_format": (s["preview"] or {}).get("format"),
|
||||
"stats": engine.stats,
|
||||
}
|
||||
finally:
|
||||
stop_engine(engine, server)
|
||||
|
||||
|
||||
def run_swap(archs, device: str) -> dict:
|
||||
out_root = os.path.join(OUTPUT_ROOT, "swap_" + "_".join(a.replace("/", "_") for a in archs))
|
||||
shutil.rmtree(out_root, ignore_errors=True)
|
||||
os.makedirs(out_root, exist_ok=True)
|
||||
engine, server, client = start_engine(device, out_root)
|
||||
from toolkit.models.registry import get_arch_entry
|
||||
|
||||
timeline = []
|
||||
try:
|
||||
for i, arch in enumerate(archs):
|
||||
body = request_body(arch, MODEL_TESTS[arch], client, out_root, tiny="tiny")
|
||||
t0 = time.perf_counter()
|
||||
s = run_request(client, body)
|
||||
assert_success(s, get_arch_entry(arch)["modality"])
|
||||
pool = engine.pool.stats()
|
||||
resident = [(e["cls"], round(e["bytes"] / 1e9, 2), e["device"]) for e in pool["resident"]]
|
||||
entry = {
|
||||
"arch": arch,
|
||||
"seconds": round(time.perf_counter() - t0, 2),
|
||||
"holder_loads": engine.stats["holder_loads"],
|
||||
"oom_retries": engine.stats["oom_retries"],
|
||||
"pool_hits": pool["hits"],
|
||||
"pool_misses": pool["misses"],
|
||||
"pool_evictions": pool["evictions"],
|
||||
"resident_gb": round(pool["resident_bytes"] / 1e9, 2),
|
||||
"resident": resident,
|
||||
}
|
||||
timeline.append(entry)
|
||||
print(f" {arch}: {json.dumps(entry)}")
|
||||
# after a request only this holder's components may stay resident
|
||||
assert pool["untouched"] == 0, f"stale components left resident: {pool}"
|
||||
if i > 0 and archs[i - 1] == arch:
|
||||
assert entry["holder_loads"] == timeline[i - 1]["holder_loads"], "same model should reuse the holder"
|
||||
if len(set(archs)) > 1:
|
||||
assert engine.pool.hits > 0, "expected shared components between the swapped archs"
|
||||
return {"arch": ",".join(archs), "status": "PASS", "timeline": timeline}
|
||||
finally:
|
||||
stop_engine(engine, server)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--arch", type=str, default=None)
|
||||
parser.add_argument("--swap", type=str, default=None, help="comma-separated archs to run back to back")
|
||||
parser.add_argument("--cancel", action="store_true")
|
||||
parser.add_argument("--all", action="store_true")
|
||||
parser.add_argument("--list", action="store_true")
|
||||
parser.add_argument("--device", type=str, default="cuda:0")
|
||||
parser.add_argument("--allow-download", action="store_true")
|
||||
parser.add_argument("--json-result", type=str, default=None)
|
||||
parser.add_argument("--quantize", action="store_true", help="force convrot8 quantization of transformer + TE")
|
||||
args = parser.parse_args()
|
||||
global QUANTIZE_ALL
|
||||
QUANTIZE_ALL = args.quantize
|
||||
|
||||
if args.list:
|
||||
for arch in MODEL_TESTS:
|
||||
print(arch)
|
||||
return
|
||||
|
||||
os.environ.setdefault("CUDA_DEVICE_ORDER", "PCI_BUS_ID")
|
||||
if not args.allow_download:
|
||||
os.environ.setdefault("HF_HUB_OFFLINE", "1")
|
||||
|
||||
def _record(result):
|
||||
if args.json_result:
|
||||
with open(args.json_result, "w") as f:
|
||||
json.dump(result, f)
|
||||
print(f"[{result['status']}] {result['arch']}" + (f" - {result.get('error', '')}" if result["status"] != "PASS" else f" {json.dumps({k: v for k, v in result.items() if k not in ('arch', 'status')})}"))
|
||||
if result["status"] == "FAIL":
|
||||
sys.exit(1)
|
||||
|
||||
if args.swap:
|
||||
archs = [a.strip() for a in args.swap.split(",") if a.strip()]
|
||||
try:
|
||||
result = run_swap(archs, args.device)
|
||||
except BaseException as err:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
result = {"arch": args.swap, "status": classify_error(err), "error": f"{type(err).__name__}: {err}"}
|
||||
_record(result)
|
||||
return
|
||||
|
||||
if args.arch:
|
||||
if args.arch not in MODEL_TESTS:
|
||||
raise SystemExit(f"arch {args.arch!r} is not registered; --list shows options")
|
||||
try:
|
||||
result = run_one(args.arch, args.device, cancel=args.cancel)
|
||||
except BaseException as err:
|
||||
status = classify_error(err)
|
||||
if status == "FAIL":
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
result = {"arch": args.arch, "status": status, "error": f"{type(err).__name__}: {err}"}
|
||||
_record(result)
|
||||
return
|
||||
|
||||
if not args.all:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
results = []
|
||||
total = len(MODEL_TESTS)
|
||||
t0 = time.perf_counter()
|
||||
for i, arch in enumerate(MODEL_TESTS, start=1):
|
||||
print(f"\n===== {arch} ({i}/{total}) =====", flush=True)
|
||||
result_path = os.path.join(OUTPUT_ROOT, f".{arch.replace('/', '_')}.result.json")
|
||||
os.makedirs(OUTPUT_ROOT, exist_ok=True)
|
||||
cmd = [sys.executable, os.path.abspath(__file__), "--arch", arch, "--device", args.device, "--json-result", result_path]
|
||||
if args.allow_download:
|
||||
cmd.append("--allow-download")
|
||||
if args.quantize:
|
||||
cmd.append("--quantize")
|
||||
proc = subprocess.run(cmd, cwd=TOOLKIT_ROOT)
|
||||
if os.path.exists(result_path):
|
||||
with open(result_path) as f:
|
||||
results.append(json.load(f))
|
||||
os.remove(result_path)
|
||||
else:
|
||||
results.append({"arch": arch, "status": "FAIL", "error": f"subprocess died (exit {proc.returncode})"})
|
||||
tally = {"PASS": 0, "FAIL": 0, "SKIP": 0}
|
||||
for r in results:
|
||||
tally[r["status"]] = tally.get(r["status"], 0) + 1
|
||||
elapsed = time.perf_counter() - t0
|
||||
print(f">>> progress: {i}/{total} - {tally['PASS']} pass, {tally['FAIL']} fail, {tally['SKIP']} skip - elapsed {elapsed / 60:.0f}m", flush=True)
|
||||
|
||||
print("\n===== summary =====")
|
||||
counts = {"PASS": 0, "FAIL": 0, "SKIP": 0}
|
||||
for r in results:
|
||||
counts[r["status"]] = counts.get(r["status"], 0) + 1
|
||||
line = f"[{r['status']}] {r['arch']}"
|
||||
if r["status"] != "PASS":
|
||||
line += f" - {r.get('error', '')[:160]}"
|
||||
print(line)
|
||||
print(f"\n{counts['PASS']} passed, {counts['FAIL']} failed, {counts['SKIP']} skipped")
|
||||
with open(os.path.join(OUTPUT_ROOT, "report.json"), "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
if counts["FAIL"]:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -167,6 +167,9 @@ class BaseModel:
|
||||
self.invert_assistant_lora = False
|
||||
self._after_sample_img_hooks = []
|
||||
self._status_update_hooks = []
|
||||
# inference engine: called as hook(step_index, num_steps, latents)
|
||||
# after every scheduler step while generating samples
|
||||
self.sample_step_hook = None
|
||||
self.is_transformer = False
|
||||
|
||||
self.sample_prompts_cache = None
|
||||
@@ -401,6 +404,18 @@ class BaseModel:
|
||||
def add_status_update_hook(self, func):
|
||||
self._status_update_hooks.append(func)
|
||||
|
||||
def _emit_sample_step(self, latents, step_index=None, num_steps=None):
|
||||
"""For holders whose sampling loop bypasses scheduler.step: report one
|
||||
denoised latent to sample_step_hook (no-op when unset)."""
|
||||
from toolkit.sample_step_hook import emit_sample_step
|
||||
|
||||
emit_sample_step(self, latents, step_index, num_steps)
|
||||
|
||||
def _install_sample_step_hooks(self, pipeline):
|
||||
from toolkit.sample_step_hook import install_sample_step_hooks
|
||||
|
||||
return install_sample_step_hooks(self, pipeline)
|
||||
|
||||
@torch.no_grad()
|
||||
def generate_images(
|
||||
self,
|
||||
@@ -457,6 +472,8 @@ class BaseModel:
|
||||
except:
|
||||
pass
|
||||
|
||||
unwrap_step_hooks = self._install_sample_step_hooks(pipeline)
|
||||
|
||||
start_multiplier = 1.0
|
||||
if network is not None:
|
||||
start_multiplier = network.multiplier
|
||||
@@ -517,6 +534,7 @@ class BaseModel:
|
||||
|
||||
if network is not None:
|
||||
network.multiplier = gen_config.network_multiplier
|
||||
self._sample_step_index = 0
|
||||
torch.manual_seed(gen_config.seed)
|
||||
torch.cuda.manual_seed(gen_config.seed)
|
||||
|
||||
@@ -725,6 +743,7 @@ class BaseModel:
|
||||
if self.adapter is not None and isinstance(self.adapter, ReferenceAdapter):
|
||||
self.adapter.clear_memory()
|
||||
|
||||
unwrap_step_hooks()
|
||||
# clear pipeline and cache to reduce vram usage
|
||||
del pipeline
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Per-arch capabilities + realistic defaults, one table shared by the
|
||||
inference engine (`GET /models`), its tests, and eventually the UI.
|
||||
|
||||
Every arch registered through get_all_models()/LEGACY_ARCHS is served; an
|
||||
entry here adds defaults and capability flags on top. Archs without an entry
|
||||
get GENERIC. Keep `model` limited to ModelConfig kwargs and `sample` to
|
||||
GenerateImageConfig kwargs.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
IMG = {"width": 1024, "height": 1024, "num_inference_steps": 25, "guidance_scale": 4.0}
|
||||
VID = {"width": 832, "height": 480, "num_inference_steps": 20, "guidance_scale": 5.0, "num_frames": 33, "fps": 16}
|
||||
|
||||
GENERIC = {"modality": "image", "model": {}, "sample": dict(IMG)}
|
||||
|
||||
# modality: image | video | audio
|
||||
# needs_control_image: the arch requires ctrl_img (edit / i2v models)
|
||||
# size_locked: only the default resolution is valid
|
||||
ARCH_REGISTRY: Dict[str, dict] = {
|
||||
"zimage": {"modality": "image", "model": {"name_or_path": "Tongyi-MAI/Z-Image-Turbo"}, "sample": {**IMG, "num_inference_steps": 8, "guidance_scale": 1.0}},
|
||||
"zimage_l2p": {"modality": "image", "model": {"name_or_path": "zhen-nan/L2P/model-1k-merge.safetensors", "extras_name_or_path": "Tongyi-MAI/Z-Image-Turbo", "quantize_te": True}, "sample": {**IMG, "num_inference_steps": 8, "guidance_scale": 1.0}},
|
||||
"qwen_image": {"modality": "image", "model": {"name_or_path": "Qwen/Qwen-Image", "quantize": True, "quantize_te": True}, "sample": {**IMG, "num_inference_steps": 20}},
|
||||
"qwen_image_edit": {"modality": "image", "model": {"name_or_path": "Qwen/Qwen-Image-Edit", "quantize": True, "quantize_te": True}, "sample": {**IMG, "num_inference_steps": 20}, "needs_control_image": True},
|
||||
"qwen_image_edit_plus": {"modality": "image", "model": {"name_or_path": "Qwen/Qwen-Image-Edit-2509", "quantize": True, "quantize_te": True}, "sample": {**IMG, "num_inference_steps": 20}, "needs_control_image": True},
|
||||
"krea2": {"modality": "image", "model": {"name_or_path": "krea/Krea-2-Turbo", "quantize": True, "quantize_te": True}, "sample": {**IMG, "num_inference_steps": 8, "guidance_scale": 1.0}},
|
||||
"boogu_image": {"modality": "image", "model": {"name_or_path": "Boogu/Boogu-Image-0.1-Base", "quantize": True, "quantize_te": True}, "sample": dict(IMG)},
|
||||
"boogu_image_edit": {"modality": "image", "model": {"name_or_path": "Boogu/Boogu-Image-0.1-Edit", "quantize": True, "quantize_te": True}, "sample": dict(IMG), "needs_control_image": True},
|
||||
"ernie_image": {"modality": "image", "model": {"name_or_path": "baidu/ERNIE-Image", "quantize": True, "quantize_te": True}, "sample": dict(IMG)},
|
||||
"mageflow": {"modality": "image", "model": {"name_or_path": "microsoft/Mage-Flow-Base", "quantize": True, "quantize_te": True}, "sample": dict(IMG)},
|
||||
"mageflow_edit": {"modality": "image", "model": {"name_or_path": "microsoft/Mage-Flow-Edit", "quantize": True, "quantize_te": True}, "sample": dict(IMG), "needs_control_image": True},
|
||||
"ideogram4": {"modality": "image", "model": {"name_or_path": "ideogram-ai/ideogram-4-fp8", "quantize": True, "quantize_te": True}, "sample": dict(IMG)},
|
||||
"hidream": {"modality": "image", "model": {"name_or_path": "HiDream-ai/HiDream-I1-Full", "quantize": True, "quantize_te": True}, "sample": {**IMG, "num_inference_steps": 28, "guidance_scale": 5.0}},
|
||||
"hidream_e1": {"modality": "image", "model": {"name_or_path": "HiDream-ai/HiDream-E1-1", "quantize": True, "quantize_te": True}, "sample": {"width": 768, "height": 768, "num_inference_steps": 28, "guidance_scale": 5.0}, "needs_control_image": True, "size_locked": True},
|
||||
"hidream_o1": {"modality": "image", "model": {"name_or_path": "HiDream-ai/HiDream-O1-Image", "quantize": True, "quantize_te": True}, "sample": {**IMG, "num_inference_steps": 28, "guidance_scale": 5.0}},
|
||||
"anima": {"modality": "image", "model": {"name_or_path": "circlestone-labs/Anima-Base-v1.0-Diffusers"}, "sample": {**IMG, "guidance_scale": 4.5}},
|
||||
"nucleus_image": {"modality": "image", "model": {"name_or_path": "NucleusAI/Nucleus-Image", "quantize": True, "quantize_te": True}, "sample": dict(IMG)},
|
||||
"omnigen2": {"modality": "image", "model": {"name_or_path": "OmniGen2/OmniGen2", "quantize": True, "quantize_te": True}, "sample": dict(IMG)},
|
||||
"chroma": {"modality": "image", "model": {"name_or_path": "lodestones/Chroma1-HD", "quantize": True, "quantize_te": True}, "sample": {**IMG, "num_inference_steps": 26}},
|
||||
"chroma_radiance": {"modality": "image", "model": {"name_or_path": "lodestones/Chroma1-Radiance", "quantize": True, "quantize_te": True}, "sample": {**IMG, "num_inference_steps": 26}},
|
||||
"zeta_chroma": {"modality": "image", "model": {"name_or_path": "lodestones/Zeta-Chroma/zeta-chroma-base-x0-pixel-dino-distance.safetensors", "extras_name_or_path": "Tongyi-MAI/Z-Image-Turbo", "quantize": True, "quantize_te": True}, "sample": dict(IMG)},
|
||||
"flux_kontext": {"modality": "image", "model": {"name_or_path": "black-forest-labs/FLUX.1-Kontext-dev", "quantize": True, "quantize_te": True}, "sample": {**IMG, "num_inference_steps": 20, "guidance_scale": 2.5}, "needs_control_image": True},
|
||||
"flux2": {"modality": "image", "model": {"name_or_path": "black-forest-labs/FLUX.2-dev", "quantize": True, "quantize_te": True}, "sample": dict(IMG)},
|
||||
"flux2_klein_4b": {"modality": "image", "model": {"name_or_path": "black-forest-labs/FLUX.2-klein-base-4B", "quantize_te": True}, "sample": dict(IMG)},
|
||||
"flux2_klein_9b": {"modality": "image", "model": {"name_or_path": "black-forest-labs/FLUX.2-klein-base-9B", "quantize": True, "quantize_te": True}, "sample": dict(IMG)},
|
||||
"prx_pixel": {"modality": "image", "model": {"name_or_path": "Photoroom/prxpixel-t2i", "quantize_te": True}, "sample": dict(IMG)},
|
||||
"f-lite": {"modality": "image", "model": {"name_or_path": "Freepik/F-Lite", "quantize": True, "quantize_te": True}, "sample": dict(IMG)},
|
||||
"sd1": {"modality": "image", "model": {"name_or_path": "stable-diffusion-v1-5/stable-diffusion-v1-5"}, "sample": {"width": 512, "height": 512, "num_inference_steps": 20, "guidance_scale": 7.5}},
|
||||
"sdxl": {"modality": "image", "model": {"name_or_path": "stabilityai/stable-diffusion-xl-base-1.0"}, "sample": {**IMG, "guidance_scale": 6.0}},
|
||||
"wan21": {"modality": "video", "model": {"name_or_path": "Wan-AI/Wan2.1-T2V-1.3B-Diffusers"}, "sample": dict(VID)},
|
||||
"wan21_i2v": {"modality": "video", "model": {"name_or_path": "Wan-AI/Wan2.1-I2V-14B-480P-Diffusers", "quantize": True, "quantize_te": True}, "sample": dict(VID), "needs_control_image": True},
|
||||
"wan22_5b": {"modality": "video", "model": {"name_or_path": "Wan-AI/Wan2.2-TI2V-5B-Diffusers", "quantize": True, "quantize_te": True}, "sample": {**VID, "fps": 24}},
|
||||
"wan22_14b": {"modality": "video", "model": {"name_or_path": "ai-toolkit/Wan2.2-T2V-A14B-Diffusers-bf16", "quantize": True, "quantize_te": True, "low_vram": True}, "sample": {**VID, "guidance_scale": 3.5}},
|
||||
"wan22_14b_i2v": {"modality": "video", "model": {"name_or_path": "ai-toolkit/Wan2.2-I2V-A14B-Diffusers-bf16", "quantize": True, "quantize_te": True, "low_vram": True}, "sample": {**VID, "guidance_scale": 3.5}, "needs_control_image": True},
|
||||
"ltx2.3": {"modality": "video", "model": {"name_or_path": "Lightricks/LTX-2.3/ltx-2.3-22b-dev.safetensors", "quantize": True, "quantize_te": True}, "sample": {**VID, "num_inference_steps": 25, "guidance_scale": 3.0, "num_frames": 49, "fps": 24}},
|
||||
"ltx2.5": {"modality": "video", "model": {"name_or_path": "Lightricks/LTX-2.5", "quantize": True, "quantize_te": True}, "sample": {**VID, "num_inference_steps": 25, "guidance_scale": 3.0, "num_frames": 49, "fps": 24}},
|
||||
"ace_step_15": {"modality": "audio", "model": {"name_or_path": "ostris/ace_step_1.5_ComfyUI_files/ace_step_1.5_base_aio.safetensors", "quantize": True, "quantize_te": True}, "sample": {"width": 512, "height": 512, "num_inference_steps": 20, "guidance_scale": 4.0}},
|
||||
}
|
||||
|
||||
OUTPUT_EXT = {"image": "png", "video": "mp4", "audio": "mp3"}
|
||||
|
||||
|
||||
def get_arch_entry(arch: str) -> dict:
|
||||
# variant suffixes ("zimage:turbo") share the base arch's entry
|
||||
entry = ARCH_REGISTRY.get(arch) or ARCH_REGISTRY.get(arch.split(":")[0], GENERIC)
|
||||
return {
|
||||
"arch": arch,
|
||||
"modality": entry.get("modality", "image"),
|
||||
"model": dict(entry.get("model", {})),
|
||||
"sample": dict(entry.get("sample", IMG)),
|
||||
"needs_control_image": bool(entry.get("needs_control_image", False)),
|
||||
"size_locked": bool(entry.get("size_locked", False)),
|
||||
}
|
||||
|
||||
|
||||
def list_archs() -> List[str]:
|
||||
from toolkit.util.get_model import LEGACY_ARCHS, get_all_models
|
||||
|
||||
archs = {m.arch for m in get_all_models() if getattr(m, "arch", None)}
|
||||
archs |= LEGACY_ARCHS
|
||||
return sorted(archs)
|
||||
|
||||
|
||||
def describe_archs(archs: Optional[List[str]] = None) -> List[dict]:
|
||||
return [get_arch_entry(a) for a in (archs or list_archs())]
|
||||
@@ -255,6 +255,31 @@ class OstrisModelMixin:
|
||||
base_model.print_and_status_update if base_model is not None else print_acc
|
||||
)
|
||||
|
||||
# live server: a pooled module already carrying this exact policy is
|
||||
# done; a different policy re-applies on top (requantize / dequantize
|
||||
# paths below handle the weights, offloading is detached first)
|
||||
from toolkit.models.v2.pool import ComponentPool, _stable
|
||||
|
||||
policy = _stable(
|
||||
dict(
|
||||
qtype=qtype,
|
||||
offload=offload,
|
||||
dtype=dtype,
|
||||
device=device,
|
||||
quantize_device=quantize_device,
|
||||
exclude=exclude_quant_modules,
|
||||
)
|
||||
)
|
||||
if ComponentPool.current is not None:
|
||||
if getattr(self, "_aitk_policy", None) == policy:
|
||||
ComponentPool.current.touch_module(self)
|
||||
return self
|
||||
if hasattr(self, "_memory_manager"):
|
||||
from toolkit.memory_management import MemoryManager
|
||||
|
||||
MemoryManager.detach(self)
|
||||
self._aitk_policy = policy
|
||||
|
||||
ara_path = None
|
||||
if qtype is not None and "|" in qtype:
|
||||
qtype, ara_path = qtype.split("|", 1)
|
||||
@@ -428,6 +453,27 @@ class OstrisModelMixin:
|
||||
elif subfolder == "":
|
||||
subfolder = None
|
||||
|
||||
# live server: an identical source request returns the resident module
|
||||
from toolkit.models.v2.pool import ComponentPool
|
||||
|
||||
pool = ComponentPool.current
|
||||
pool_key = None
|
||||
if pool is not None:
|
||||
pool_key = ComponentPool.make_key(
|
||||
cls,
|
||||
name_or_path,
|
||||
dtype=dtype,
|
||||
config_path=config_path,
|
||||
config=config,
|
||||
subfolder=subfolder,
|
||||
use_comfy_weights=use_comfy_weights,
|
||||
qtype=qtype if quantize_on_load else None,
|
||||
kwargs=kwargs,
|
||||
)
|
||||
resident = pool.get(pool_key)
|
||||
if resident is not None:
|
||||
return resident
|
||||
|
||||
if (
|
||||
use_comfy_weights
|
||||
and not name_or_path.endswith(".safetensors")
|
||||
@@ -473,6 +519,8 @@ class OstrisModelMixin:
|
||||
|
||||
if device is not None:
|
||||
model.to(device)
|
||||
if pool is not None and pool_key is not None:
|
||||
pool.put(pool_key, model)
|
||||
return model
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Process-wide component pool for the live inference server.
|
||||
|
||||
While a pool is active (`ComponentPool.current`), `OstrisModelMixin.load_model`
|
||||
returns an already-resident module for an identical source request instead
|
||||
of loading it again, and `aitk_post_load` becomes a no-op when the module
|
||||
already carries the requested policy (qtype / offload / device). Holders
|
||||
keep calling the normal load API; component reuse across model switches
|
||||
(same TE or VAE under two archs) falls out of that.
|
||||
|
||||
Lifecycle per request: `begin_request()` -> holder loads (hits mark entries
|
||||
as touched) -> `release_unused()` frees every entry the request did not
|
||||
touch. `clear()` drops everything.
|
||||
"""
|
||||
|
||||
import gc
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, Optional
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
def _stable(value):
|
||||
"""JSON-safe, deterministic rendering of a key part."""
|
||||
if value is None or isinstance(value, (bool, int, float, str)):
|
||||
return value
|
||||
if isinstance(value, (torch.dtype, torch.device)):
|
||||
return str(value)
|
||||
if isinstance(value, dict):
|
||||
return {str(k): _stable(v) for k, v in sorted(value.items(), key=lambda kv: str(kv[0]))}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_stable(v) for v in value]
|
||||
to_dict = getattr(value, "to_dict", None)
|
||||
if callable(to_dict):
|
||||
try:
|
||||
return _stable(to_dict())
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(value, "__dataclass_fields__"):
|
||||
return _stable({k: getattr(value, k) for k in value.__dataclass_fields__})
|
||||
return repr(value)
|
||||
|
||||
|
||||
def module_bytes(module: torch.nn.Module) -> int:
|
||||
total = 0
|
||||
seen = set()
|
||||
for t in list(module.parameters()) + list(module.buffers()):
|
||||
try:
|
||||
ptr = t.untyped_storage().data_ptr()
|
||||
except Exception:
|
||||
ptr = id(t)
|
||||
if ptr in seen or t.device.type == "meta":
|
||||
continue
|
||||
seen.add(ptr)
|
||||
total += t.numel() * t.element_size()
|
||||
return total
|
||||
|
||||
|
||||
class PoolEntry:
|
||||
def __init__(self, key: str, module: torch.nn.Module, generation: int):
|
||||
self.key = key
|
||||
self.module = module
|
||||
self.cls_name = type(module).__name__
|
||||
self.created_at = time.time()
|
||||
self.last_used = self.created_at
|
||||
self.uses = 0
|
||||
self.touched_gen = generation
|
||||
self._bytes: Optional[int] = None
|
||||
|
||||
@property
|
||||
def bytes(self) -> int:
|
||||
if self._bytes is None:
|
||||
try:
|
||||
self._bytes = module_bytes(self.module)
|
||||
except Exception:
|
||||
self._bytes = 0
|
||||
return self._bytes
|
||||
|
||||
def device(self) -> str:
|
||||
try:
|
||||
p = next(self.module.parameters())
|
||||
return str(p.device)
|
||||
except StopIteration:
|
||||
return "none"
|
||||
|
||||
def info(self) -> dict:
|
||||
try:
|
||||
parsed = json.loads(self.key)
|
||||
except Exception:
|
||||
parsed = {"key": self.key}
|
||||
return {
|
||||
"cls": self.cls_name,
|
||||
"src": parsed.get("src"),
|
||||
"subfolder": parsed.get("subfolder"),
|
||||
"dtype": parsed.get("dtype"),
|
||||
"policy": getattr(self.module, "_aitk_policy", None),
|
||||
"bytes": self.bytes,
|
||||
"device": self.device(),
|
||||
"uses": self.uses,
|
||||
"last_used": self.last_used,
|
||||
}
|
||||
|
||||
|
||||
class ComponentPool:
|
||||
current: Optional["ComponentPool"] = None
|
||||
|
||||
def __init__(self):
|
||||
self.entries: Dict[str, PoolEntry] = {}
|
||||
self.generation = 0
|
||||
self.hits = 0
|
||||
self.misses = 0
|
||||
self.evictions = 0
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# ---- keys ----
|
||||
@staticmethod
|
||||
def make_key(cls, name_or_path: str, **parts) -> str:
|
||||
key = {"cls": f"{cls.__module__}.{cls.__qualname__}", "src": name_or_path}
|
||||
key.update({k: _stable(v) for k, v in parts.items()})
|
||||
return json.dumps(key, sort_keys=True, default=repr)
|
||||
|
||||
# ---- request lifecycle ----
|
||||
def begin_request(self):
|
||||
with self._lock:
|
||||
self.generation += 1
|
||||
|
||||
def get(self, key: str) -> Optional[torch.nn.Module]:
|
||||
with self._lock:
|
||||
entry = self.entries.get(key)
|
||||
if entry is None:
|
||||
self.misses += 1
|
||||
return None
|
||||
self.hits += 1
|
||||
entry.uses += 1
|
||||
entry.last_used = time.time()
|
||||
entry.touched_gen = self.generation
|
||||
return entry.module
|
||||
|
||||
def put(self, key: str, module: torch.nn.Module):
|
||||
with self._lock:
|
||||
entry = PoolEntry(key, module, self.generation)
|
||||
entry.uses = 1
|
||||
self.entries[key] = entry
|
||||
|
||||
def touch_module(self, module: torch.nn.Module):
|
||||
with self._lock:
|
||||
for entry in self.entries.values():
|
||||
if entry.module is module:
|
||||
entry.touched_gen = self.generation
|
||||
entry.last_used = time.time()
|
||||
|
||||
def release_unused(self) -> int:
|
||||
"""Free every entry not touched during the current request."""
|
||||
with self._lock:
|
||||
stale = [e for e in self.entries.values() if e.touched_gen != self.generation]
|
||||
freed = 0
|
||||
for entry in stale:
|
||||
freed += entry.bytes
|
||||
self._free(entry)
|
||||
return freed
|
||||
|
||||
def clear(self):
|
||||
with self._lock:
|
||||
for entry in list(self.entries.values()):
|
||||
self._free(entry)
|
||||
|
||||
def _free(self, entry: PoolEntry):
|
||||
from toolkit.memory_management import MemoryManager
|
||||
|
||||
self.entries.pop(entry.key, None)
|
||||
self.evictions += 1
|
||||
try:
|
||||
MemoryManager.free(entry.module)
|
||||
except Exception:
|
||||
try:
|
||||
torch.nn.Module.to(entry.module, "meta")
|
||||
except Exception:
|
||||
pass
|
||||
entry.module = None
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
def stats(self) -> dict:
|
||||
with self._lock:
|
||||
return {
|
||||
"hits": self.hits,
|
||||
"misses": self.misses,
|
||||
"evictions": self.evictions,
|
||||
"resident": [e.info() for e in self.entries.values()],
|
||||
"resident_bytes": sum(e.bytes for e in self.entries.values()),
|
||||
"untouched": len([e for e in self.entries.values() if e.touched_gen != self.generation]),
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Per-step latent reporting for sample generation (inference engine).
|
||||
|
||||
A holder exposes `sample_step_hook(step_index, num_steps, latents)`; while it
|
||||
is set, the sampling scheduler's `step` is wrapped on the instance so every
|
||||
step reaches the hook without per-pipeline code. The latent handed to the
|
||||
hook is the step's predicted CLEAN sample (x0) — what a preview should show
|
||||
— falling back to the scheduler's next sample when x0 cannot be derived. Shared by
|
||||
BaseModel and the legacy StableDiffusion monolith.
|
||||
"""
|
||||
|
||||
from typing import Callable, List
|
||||
|
||||
|
||||
def install_sample_step_hooks(holder, pipeline) -> Callable[[], None]:
|
||||
"""Wrap scheduler.step on the sampling scheduler(s). Returns an unwrap
|
||||
callable. No-op without a hook. Instance-level wrap: the class method is
|
||||
untouched and a stale wrapper is replaced rather than stacked."""
|
||||
if getattr(holder, "sample_step_hook", None) is None:
|
||||
return lambda: None
|
||||
schedulers: List = []
|
||||
for cand in (
|
||||
getattr(pipeline, "scheduler", None),
|
||||
getattr(getattr(holder, "pipeline", None), "scheduler", None),
|
||||
getattr(holder, "noise_scheduler", None),
|
||||
):
|
||||
if cand is not None and hasattr(cand, "step") and all(cand is not s for s in schedulers):
|
||||
schedulers.append(cand)
|
||||
unwraps = []
|
||||
for scheduler in schedulers:
|
||||
current = scheduler.step
|
||||
orig = getattr(current, "_aitk_orig_step", None) or current
|
||||
|
||||
def make(orig, scheduler):
|
||||
def step(*args, **kwargs):
|
||||
# capture the pre-step state: x0 is derived from (model_output,
|
||||
# timestep, sample) with the sigma/alpha of THIS step, which
|
||||
# the scheduler advances past inside orig()
|
||||
model_output = args[0] if len(args) > 0 else kwargs.get("model_output")
|
||||
timestep = args[1] if len(args) > 1 else kwargs.get("timestep")
|
||||
sample_in = args[2] if len(args) > 2 else kwargs.get("sample")
|
||||
coeffs = _x0_coefficients(scheduler, timestep)
|
||||
out = orig(*args, **kwargs)
|
||||
if isinstance(out, tuple):
|
||||
prev_sample = out[0]
|
||||
pred_x0 = None
|
||||
else:
|
||||
prev_sample = getattr(out, "prev_sample", None)
|
||||
pred_x0 = getattr(out, "pred_original_sample", None)
|
||||
hook = getattr(holder, "sample_step_hook", None)
|
||||
if hook is not None and prev_sample is not None:
|
||||
if pred_x0 is None:
|
||||
pred_x0 = _predict_x0(coeffs, model_output, sample_in)
|
||||
timesteps = getattr(scheduler, "timesteps", None)
|
||||
num_steps = len(timesteps) if timesteps is not None else None
|
||||
idx = getattr(holder, "_sample_step_index", 0)
|
||||
holder._sample_step_index = idx + 1
|
||||
hook(idx, num_steps, pred_x0 if pred_x0 is not None else prev_sample)
|
||||
return out
|
||||
|
||||
step._aitk_orig_step = orig
|
||||
return step
|
||||
|
||||
scheduler.step = make(orig, scheduler)
|
||||
|
||||
def unwrap(scheduler=scheduler):
|
||||
if "step" in scheduler.__dict__:
|
||||
del scheduler.__dict__["step"]
|
||||
|
||||
unwraps.append(unwrap)
|
||||
return lambda: [u() for u in unwraps]
|
||||
|
||||
|
||||
def _x0_coefficients(scheduler, timestep):
|
||||
"""Per-step numbers needed to turn the model output into a clean-sample
|
||||
estimate, read BEFORE scheduler.step advances its index.
|
||||
Returns ("flow", sigma) | ("ddpm", prediction_type, alpha_bar) | None."""
|
||||
try:
|
||||
import torch
|
||||
|
||||
sigmas = getattr(scheduler, "sigmas", None)
|
||||
if sigmas is not None and hasattr(scheduler, "index_for_timestep"):
|
||||
idx = getattr(scheduler, "step_index", None)
|
||||
if idx is None:
|
||||
idx = scheduler.index_for_timestep(timestep)
|
||||
return ("flow", float(sigmas[idx]))
|
||||
alphas_cumprod = getattr(scheduler, "alphas_cumprod", None)
|
||||
if alphas_cumprod is not None:
|
||||
t = timestep
|
||||
if isinstance(t, torch.Tensor):
|
||||
t = int(t.flatten()[0].item())
|
||||
prediction_type = getattr(getattr(scheduler, "config", None), "prediction_type", "epsilon")
|
||||
return ("ddpm", prediction_type, float(alphas_cumprod[int(t)]))
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _predict_x0(coeffs, model_output, sample):
|
||||
"""Clean-sample estimate for the preview: flow matching x0 = x_t - sigma*v;
|
||||
DDPM-style eps / v / sample predictions via alpha_bar. None when unknown."""
|
||||
if coeffs is None or model_output is None or sample is None:
|
||||
return None
|
||||
try:
|
||||
x = sample.tensor if hasattr(sample, "tensor") else sample
|
||||
v = model_output.tensor if hasattr(model_output, "tensor") else model_output
|
||||
if v.shape != x.shape:
|
||||
return None
|
||||
v = v.to(x.dtype)
|
||||
if coeffs[0] == "flow":
|
||||
sigma = coeffs[1]
|
||||
return x - sigma * v
|
||||
_, prediction_type, alpha_bar = coeffs
|
||||
a = alpha_bar**0.5
|
||||
b = (1.0 - alpha_bar) ** 0.5
|
||||
if prediction_type == "epsilon":
|
||||
return (x - b * v) / a
|
||||
if prediction_type == "v_prediction":
|
||||
return a * x - b * v
|
||||
if prediction_type == "sample":
|
||||
return v
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def emit_sample_step(holder, latents, step_index=None, num_steps=None):
|
||||
"""For sampling loops that bypass scheduler.step: report one denoised
|
||||
latent to the holder's hook (no-op when unset)."""
|
||||
hook = getattr(holder, "sample_step_hook", None)
|
||||
if hook is None:
|
||||
return
|
||||
idx = getattr(holder, "_sample_step_index", 0) if step_index is None else step_index
|
||||
holder._sample_step_index = idx + 1
|
||||
hook(idx, num_steps, latents)
|
||||
@@ -191,6 +191,8 @@ class StableDiffusion:
|
||||
self.te_torch_dtype = get_torch_dtype(model_config.te_dtype)
|
||||
|
||||
self.model_config = model_config
|
||||
# inference engine: hook(step_index, num_steps, latents) per scheduler step
|
||||
self.sample_step_hook = None
|
||||
self.prediction_type = "v_prediction" if self.model_config.is_v_pred else "epsilon"
|
||||
self.arch = model_config.arch
|
||||
|
||||
@@ -1417,6 +1419,10 @@ class StableDiffusion:
|
||||
# disable progress bar
|
||||
pipeline.set_progress_bar_config(disable=True)
|
||||
|
||||
from toolkit.sample_step_hook import install_sample_step_hooks
|
||||
|
||||
unwrap_step_hooks = install_sample_step_hooks(self, pipeline)
|
||||
|
||||
refiner_pipeline = None
|
||||
if self.refiner_unet:
|
||||
# build refiner pipeline
|
||||
@@ -1495,6 +1501,7 @@ class StableDiffusion:
|
||||
|
||||
if network is not None:
|
||||
network.multiplier = gen_config.network_multiplier
|
||||
self._sample_step_index = 0
|
||||
torch.manual_seed(gen_config.seed)
|
||||
torch.cuda.manual_seed(gen_config.seed)
|
||||
|
||||
@@ -1792,6 +1799,7 @@ class StableDiffusion:
|
||||
if self.adapter is not None and isinstance(self.adapter, ReferenceAdapter):
|
||||
self.adapter.clear_memory()
|
||||
|
||||
unwrap_step_hooks()
|
||||
# clear pipeline and cache to reduce vram usage
|
||||
del pipeline
|
||||
if refiner_pipeline is not None:
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""UI job bookkeeping for long-running extension processes.
|
||||
|
||||
When a process is launched by the UI it gets AITK_JOB_ID and a sqlite db
|
||||
path; status / step / info are written to the Job row and the UI's stop
|
||||
button sets Job.stop. This mirrors what BaseCaptioner does inline, as a
|
||||
standalone helper so other UI-driven processes (the inference engine) can
|
||||
share it. Outside the UI (no job id or no db file) every call is a no-op.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Literal, Optional
|
||||
|
||||
AITK_Status = Literal["running", "stopped", "error", "completed", "queued"]
|
||||
|
||||
|
||||
class UIJobStatus:
|
||||
def __init__(self, sqlite_db_path: str = "./aitk_db.db"):
|
||||
self.sqlite_db_path = sqlite_db_path
|
||||
job_id = os.environ.get("AITK_JOB_ID", None)
|
||||
self.job_id = job_id.strip() if job_id else None
|
||||
self.is_ui_job = self.job_id is not None and os.path.exists(sqlite_db_path)
|
||||
if self.is_ui_job:
|
||||
print(f"Using SQLite database at {sqlite_db_path}")
|
||||
print(f'Job ID: "{self.job_id}"')
|
||||
self._lock = threading.Lock()
|
||||
self._watcher = None
|
||||
# collapse bursts of identical writes (progress ticks)
|
||||
self._last_info = None
|
||||
|
||||
# ---- db plumbing ----
|
||||
def _connect(self):
|
||||
conn = sqlite3.connect(self.sqlite_db_path, timeout=30.0)
|
||||
conn.isolation_level = None
|
||||
return conn
|
||||
|
||||
def _retry(self, fn, max_retries=3, base_delay=2.0):
|
||||
last = None
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
return fn()
|
||||
except sqlite3.OperationalError as e:
|
||||
if "database is locked" not in str(e):
|
||||
raise
|
||||
last = e
|
||||
if attempt < max_retries:
|
||||
time.sleep(base_delay * (2**attempt))
|
||||
raise last
|
||||
|
||||
def _write(self, query: str, params: tuple):
|
||||
if not self.is_ui_job:
|
||||
return
|
||||
|
||||
def _do():
|
||||
with self._lock, self._connect() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
cur.execute(query, params)
|
||||
finally:
|
||||
cur.execute("COMMIT")
|
||||
|
||||
try:
|
||||
self._retry(_do)
|
||||
except Exception as e:
|
||||
print(f"[AITK] Warning: db write failed: {e}")
|
||||
|
||||
def _read_flag(self, column: str) -> bool:
|
||||
if not self.is_ui_job:
|
||||
return False
|
||||
|
||||
def _do():
|
||||
with self._lock, self._connect() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"SELECT {column} FROM Job WHERE id = ?", (self.job_id,))
|
||||
row = cur.fetchone()
|
||||
return False if row is None else row[0] == 1
|
||||
|
||||
return self._retry(_do)
|
||||
|
||||
# ---- public api ----
|
||||
def update_status(self, status: AITK_Status, info: Optional[str] = None):
|
||||
if info is None:
|
||||
self._write("UPDATE Job SET status = ? WHERE id = ?", (status, self.job_id))
|
||||
else:
|
||||
self._last_info = info
|
||||
self._write(
|
||||
"UPDATE Job SET status = ?, info = ? WHERE id = ?",
|
||||
(status, info, self.job_id),
|
||||
)
|
||||
|
||||
def update_info(self, info: str):
|
||||
if info == self._last_info:
|
||||
return
|
||||
self._last_info = info
|
||||
self._write("UPDATE Job SET info = ? WHERE id = ?", (info, self.job_id))
|
||||
|
||||
def update_key(self, key: str, value):
|
||||
# key is a column name from our own code, never user input
|
||||
self._write(f"UPDATE Job SET {key} = ? WHERE id = ?", (str(value), self.job_id))
|
||||
|
||||
def should_stop(self) -> bool:
|
||||
return self._read_flag("stop")
|
||||
|
||||
def should_return_to_queue(self) -> bool:
|
||||
return self._read_flag("return_to_queue")
|
||||
|
||||
def start_stop_watcher(self, on_stop: Callable[[str], None], interval_sec: float = 2.0):
|
||||
"""Poll the stop / return_to_queue flags on a daemon thread and call
|
||||
on_stop("stopped" | "queued") once when one is set."""
|
||||
if not self.is_ui_job or self._watcher is not None:
|
||||
return
|
||||
|
||||
def _loop():
|
||||
while True:
|
||||
try:
|
||||
if self.should_stop():
|
||||
on_stop("stopped")
|
||||
return
|
||||
if self.should_return_to_queue():
|
||||
on_stop("queued")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(interval_sec)
|
||||
|
||||
self._watcher = threading.Thread(target=_loop, daemon=True)
|
||||
self._watcher.start()
|
||||
@@ -217,6 +217,14 @@ const startAndWatchJob = (job: Job) => {
|
||||
// update the config dataset path
|
||||
const jobConfig = JSON.parse(job.job_config);
|
||||
jobConfig.config.process[0].sqlite_db_path = path.join(TOOLKIT_ROOT, 'aitk_db.db');
|
||||
if (job.job_type === 'inference') {
|
||||
// the engine publishes engine.json (endpoint + token) and writes its
|
||||
// outputs under the job folder, which the UI proxy/file routes know
|
||||
jobConfig.config.process[0].engine = {
|
||||
...(jobConfig.config.process[0].engine || {}),
|
||||
job_folder: trainingFolder,
|
||||
};
|
||||
}
|
||||
|
||||
// write the config file
|
||||
fs.writeFileSync(configPath, JSON.stringify(jobConfig, null, 2));
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import prisma from '@/server/prisma';
|
||||
import { getTrainingFolder } from '@/server/settings';
|
||||
import { listEngines, resolveEngine } from '@/server/inferenceEngine';
|
||||
|
||||
// Reverse proxy to the resident inference engine (a python job that binds an
|
||||
// ephemeral loopback port and publishes it in its job folder). Everything
|
||||
// under /api/inference/* is forwarded verbatim; a streaming upstream body is
|
||||
// passed through untouched, so /generate frames reach the browser as they
|
||||
// are produced. `?job=<id>` picks an engine when several run.
|
||||
//
|
||||
// /api/inference/status is answered here: it never touches the engine.
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'transfer-encoding', 'host', 'content-length', 'authorization']);
|
||||
|
||||
async function proxy(request: NextRequest, segments: string[]) {
|
||||
const upstreamPath = segments.join('/');
|
||||
const jobId = request.nextUrl.searchParams.get('job');
|
||||
|
||||
if (upstreamPath === 'status') {
|
||||
const engines = await listEngines(true);
|
||||
const active = jobId ? engines.find(e => e.jobId === jobId) || null : engines.find(e => e.endpoint) || engines[0] || null;
|
||||
return NextResponse.json({
|
||||
running: !!active?.endpoint,
|
||||
engine: active
|
||||
? { jobId: active.jobId, jobName: active.jobName, gpuIds: active.gpuIds, status: active.status, ready: !!active.endpoint }
|
||||
: null,
|
||||
engines: engines.map(e => ({ jobId: e.jobId, jobName: e.jobName, gpuIds: e.gpuIds, status: e.status, ready: !!e.endpoint })),
|
||||
});
|
||||
}
|
||||
|
||||
if (upstreamPath === 'outputs/delete' && request.method === 'POST') {
|
||||
// delete generated files from disk. Handled here (not by the engine) so
|
||||
// history can be cleaned up while no engine is running. Only files under
|
||||
// an inference job's outputs folder are touched.
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const paths: string[] = Array.isArray(body?.paths) ? body.paths : [];
|
||||
const trainingFolder = path.resolve(await getTrainingFolder());
|
||||
const jobs = await prisma.job.findMany({ where: { job_type: 'inference' }, select: { name: true } });
|
||||
const roots = jobs.map(j => path.join(trainingFolder, j.name, 'outputs'));
|
||||
const deleted: string[] = [];
|
||||
const refused: string[] = [];
|
||||
for (const p of paths) {
|
||||
const resolved = path.resolve(String(p));
|
||||
const root = roots.find(r => resolved.startsWith(r + path.sep));
|
||||
if (!root) {
|
||||
refused.push(p);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await fs.promises.rm(resolved, { force: true });
|
||||
deleted.push(p);
|
||||
// request folders hold one generation each; drop the folder once empty
|
||||
const dir = path.dirname(resolved);
|
||||
if (dir !== root && (await fs.promises.readdir(dir)).length === 0) {
|
||||
await fs.promises.rmdir(dir);
|
||||
}
|
||||
} catch (e: any) {
|
||||
refused.push(`${p}: ${e?.message || e}`);
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ deleted, refused });
|
||||
}
|
||||
|
||||
const engine = await resolveEngine(jobId);
|
||||
if (!engine || !engine.endpoint) {
|
||||
return NextResponse.json({ error: 'no inference engine is running', engine: engine ? { status: engine.status } : null }, { status: 503 });
|
||||
}
|
||||
|
||||
const search = new URLSearchParams(request.nextUrl.searchParams);
|
||||
search.delete('job');
|
||||
const qs = search.toString();
|
||||
const url = `http://${engine.endpoint.host}:${engine.endpoint.port}/${upstreamPath}${qs ? `?${qs}` : ''}`;
|
||||
|
||||
const headers = new Headers();
|
||||
request.headers.forEach((value, key) => {
|
||||
if (!HOP_BY_HOP.has(key.toLowerCase())) headers.set(key, value);
|
||||
});
|
||||
headers.set('x-engine-token', engine.endpoint.token);
|
||||
|
||||
const method = request.method.toUpperCase();
|
||||
const init: RequestInit & { duplex?: 'half' } = { method, headers, redirect: 'manual' };
|
||||
if (method !== 'GET' && method !== 'HEAD') {
|
||||
// buffer the request body (JSON or a control-image upload); the response
|
||||
// side is what needs to stream
|
||||
init.body = Buffer.from(await request.arrayBuffer());
|
||||
}
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(url, init);
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ error: `engine unreachable: ${e?.message || e}` }, { status: 502 });
|
||||
}
|
||||
|
||||
const responseHeaders = new Headers();
|
||||
upstream.headers.forEach((value, key) => {
|
||||
if (!HOP_BY_HOP.has(key.toLowerCase())) responseHeaders.set(key, value);
|
||||
});
|
||||
responseHeaders.set('Cache-Control', 'no-store');
|
||||
// keep intermediaries from buffering the frame stream
|
||||
responseHeaders.set('X-Accel-Buffering', 'no');
|
||||
return new Response(upstream.body, { status: upstream.status, headers: responseHeaders });
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> | { path: string[] } };
|
||||
|
||||
export async function GET(request: NextRequest, ctx: Ctx) {
|
||||
const { path } = await ctx.params;
|
||||
return proxy(request, path);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, ctx: Ctx) {
|
||||
const { path } = await ctx.params;
|
||||
return proxy(request, path);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, ctx: Ctx) {
|
||||
const { path } = await ctx.params;
|
||||
return proxy(request, path);
|
||||
}
|
||||
@@ -0,0 +1,735 @@
|
||||
'use client';
|
||||
|
||||
import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Button } from '@headlessui/react';
|
||||
import { ChevronDown, ChevronLeft, ChevronRight, Loader2, Play, Square, Sparkles, Trash2, X } from 'lucide-react';
|
||||
import { openConfirm } from '@/components/ConfirmModal';
|
||||
import { TopBar, MainContent } from '@/components/layout';
|
||||
import { Checkbox, CreatableSelectInput, NumberInput, SelectInput, TextAreaInput, TextInput } from '@/components/formInputs';
|
||||
import { apiClient } from '@/utils/api';
|
||||
import { startJob, stopJob } from '@/utils/jobs';
|
||||
import { startQueue } from '@/utils/queue';
|
||||
import useGPUInfo from '@/hooks/useGPUInfo';
|
||||
import usePollLoop from '@/hooks/usePollLoop';
|
||||
import { defaultInferenceJobConfig } from '@/helpers/inferenceJobConfig';
|
||||
import { encodeFilePathForUrl } from '@/utils/basic';
|
||||
import { latentToImage, payloadToFloat32, readEngineFrames, PreviewInfo } from '@/lib/engineStream';
|
||||
import { isMac } from '@/helpers/basic';
|
||||
import { modelArchs, getGenerateDefaults, GenerateDefaults } from '@/app/jobs/new/options';
|
||||
import GenerateFooter from '@/components/generate/GenerateFooter';
|
||||
|
||||
|
||||
interface EngineStatus {
|
||||
running: boolean;
|
||||
engine: { jobId: string; jobName: string; gpuIds: string; status: string; ready: boolean } | null;
|
||||
engines: { jobId: string; jobName: string; gpuIds: string; status: string; ready: boolean }[];
|
||||
}
|
||||
|
||||
interface ResultItem {
|
||||
request_id: string;
|
||||
path: string;
|
||||
seconds?: number;
|
||||
steps?: number;
|
||||
kind: string;
|
||||
ext: string;
|
||||
seed: number;
|
||||
width: number;
|
||||
height: number;
|
||||
prompt: string;
|
||||
arch: string;
|
||||
}
|
||||
|
||||
const qtypeOptions = [
|
||||
{ value: 'convrot8', label: 'ConvRot 8-bit' },
|
||||
{ value: 'float8', label: 'Float8' },
|
||||
{ value: 'qfloat8', label: 'Quanto float8' },
|
||||
{ value: 'nvfp4', label: 'NVFP4' },
|
||||
];
|
||||
|
||||
// everything the user set on the page survives a reload
|
||||
const STORAGE_KEY = 'aitk_generate_page';
|
||||
interface PersistedState {
|
||||
arch?: string;
|
||||
model?: { [key: string]: any };
|
||||
sample?: { [key: string]: any };
|
||||
gpuId?: string;
|
||||
results?: ResultItem[];
|
||||
sidebarOpen?: boolean;
|
||||
cards?: { [key: string]: boolean };
|
||||
}
|
||||
const loadPersisted = (): PersistedState => {
|
||||
if (typeof window === 'undefined') return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? (JSON.parse(raw) as PersistedState) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const authHeaders = (): Record<string, string> => {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('AI_TOOLKIT_AUTH') : null;
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
};
|
||||
|
||||
function Card({ title, open, onToggle, children }: { title: string; open: boolean; onToggle: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800">
|
||||
<button type="button" onClick={onToggle} className="w-full flex items-center justify-between px-4 py-2.5 text-sm text-gray-200 font-semibold">
|
||||
<span>{title}</span>
|
||||
{open ? <ChevronDown className="w-4 h-4 text-gray-400" /> : <ChevronLeft className="w-4 h-4 text-gray-400" />}
|
||||
</button>
|
||||
<div className={open ? 'px-4 pb-4' : 'hidden'}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneratePageInner() {
|
||||
const searchParams = useSearchParams();
|
||||
const preferredJob = searchParams.get('job');
|
||||
const { gpuList } = useGPUInfo();
|
||||
|
||||
// ---- engine lifecycle ----
|
||||
const [engineStatus, setEngineStatus] = useState<EngineStatus | null>(null);
|
||||
const [health, setHealth] = useState<any>(null);
|
||||
const persisted = useMemo(loadPersisted, []);
|
||||
const [gpuId, setGpuId] = useState<string>(persisted.gpuId || '0');
|
||||
const [engineBusy, setEngineBusy] = useState(false);
|
||||
// job ids we are waiting on: the button stays disabled from the click until
|
||||
// the engine is actually up (or the job row reports a failure)
|
||||
const [startingJobId, setStartingJobId] = useState<string | null>(null);
|
||||
const [stoppingJobId, setStoppingJobId] = useState<string | null>(null);
|
||||
const engineJobId = preferredJob || engineStatus?.engine?.jobId || null;
|
||||
const proxy = useCallback((path: string) => `/api/inference/${path}${engineJobId ? `${path.includes('?') ? '&' : '?'}job=${engineJobId}` : ''}`, [engineJobId]);
|
||||
|
||||
usePollLoop(
|
||||
() =>
|
||||
apiClient
|
||||
.get(`/api/inference/status${preferredJob ? `?job=${preferredJob}` : ''}`)
|
||||
.then(res => res.data)
|
||||
.then(async (data: EngineStatus) => {
|
||||
setEngineStatus(data);
|
||||
if (startingJobId) {
|
||||
const mine = data.engines.find(e => e.jobId === startingJobId);
|
||||
if (mine?.ready) {
|
||||
setStartingJobId(null);
|
||||
} else if (!mine) {
|
||||
// no longer queued/running: the launch failed (or was stopped)
|
||||
const job = await apiClient.get('/api/jobs', { params: { id: startingJobId } }).then(r => r.data).catch(() => null);
|
||||
if (!job || ['error', 'stopped', 'completed'].includes(job.status)) {
|
||||
setStartingJobId(null);
|
||||
alert(`The engine did not start${job?.info ? `: ${job.info}` : ''}. Check the job log in the Queue.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stoppingJobId && !data.engines.some(e => e.jobId === stoppingJobId)) {
|
||||
setStoppingJobId(null);
|
||||
}
|
||||
if (data.running) {
|
||||
return apiClient
|
||||
.get(proxy('health'))
|
||||
.then(r => setHealth(r.data))
|
||||
.catch(() => setHealth(null));
|
||||
}
|
||||
setHealth(null);
|
||||
})
|
||||
.catch(() => {}),
|
||||
2000,
|
||||
[preferredJob, proxy, startingJobId, stoppingJobId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (gpuList.length && !gpuList.some(g => `${g.index}` === gpuId)) setGpuId(`${gpuList[0].index}`);
|
||||
}, [gpuList, gpuId]);
|
||||
|
||||
const startEngine = async () => {
|
||||
setEngineBusy(true);
|
||||
try {
|
||||
const gpu = isMac() ? 'mps' : gpuId;
|
||||
const name = `inference_engine_gpu${gpu}`;
|
||||
const existing = await apiClient.get('/api/jobs', { params: { job_type: 'inference' } }).then(r => r.data.jobs || []);
|
||||
const match = existing.find((j: any) => j.name === name);
|
||||
const res = await apiClient.post('/api/jobs', {
|
||||
id: match ? match.id : null,
|
||||
name,
|
||||
gpu_ids: gpu,
|
||||
job_config: { ...defaultInferenceJobConfig, config: { ...defaultInferenceJobConfig.config, name } },
|
||||
job_type: 'inference',
|
||||
});
|
||||
await startJob(res.data.id);
|
||||
await startQueue(gpu);
|
||||
setStartingJobId(res.data.id);
|
||||
} catch (e: any) {
|
||||
alert(`Failed to start the engine: ${e?.response?.data?.error || e?.message || e}`);
|
||||
} finally {
|
||||
setEngineBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stopEngine = async () => {
|
||||
if (!engineStatus?.engine) return;
|
||||
setEngineBusy(true);
|
||||
try {
|
||||
await stopJob(engineStatus.engine.jobId);
|
||||
setStoppingJobId(engineStatus.engine.jobId);
|
||||
} catch (e: any) {
|
||||
alert(`Failed to stop the engine: ${e?.response?.data?.error || e?.message || e}`);
|
||||
} finally {
|
||||
setEngineBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isStarting = engineBusy || !!startingJobId || (!!engineStatus?.engine && !engineStatus.running && !stoppingJobId);
|
||||
const isStopping = !!stoppingJobId;
|
||||
|
||||
// ---- models: the same arch list the training UI uses (jobs/new/options.tsx) ----
|
||||
const archs: GenerateDefaults[] = useMemo(() => modelArchs.map(getGenerateDefaults), []);
|
||||
const ready = !!engineStatus?.running;
|
||||
|
||||
const [arch, setArch] = useState<string>(persisted.arch || 'zimage');
|
||||
const [model, setModel] = useState<{ [key: string]: any }>(persisted.model || {});
|
||||
const [sample, setSample] = useState<{ [key: string]: any }>(persisted.sample || { prompt: '', negative_prompt: '', seed: -1 });
|
||||
const entry = useMemo(() => archs.find(a => a.arch === arch) || null, [archs, arch]);
|
||||
|
||||
const applyArch = (name: string) => {
|
||||
setArch(name);
|
||||
const e = archs.find(a => a.arch === name);
|
||||
if (!e) return;
|
||||
setModel({ ...e.model });
|
||||
setSample(s => ({ ...s, ...e.sample, prompt: s.prompt, negative_prompt: s.negative_prompt, seed: s.seed ?? -1, ctrl_img: undefined }));
|
||||
};
|
||||
useEffect(() => {
|
||||
if (archs.length && !Object.keys(model).length) applyArch(archs.some(a => a.arch === arch) ? arch : archs[0].arch);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [archs]);
|
||||
|
||||
const archOptions = useMemo(() => {
|
||||
const groups: { [k: string]: { value: string; label: string }[] } = {};
|
||||
for (const a of archs) {
|
||||
(groups[a.group] ||= []).push({ value: a.arch, label: a.label });
|
||||
}
|
||||
return Object.entries(groups).map(([label, options]) => ({ label, options }));
|
||||
}, [archs]);
|
||||
|
||||
// ---- generation ----
|
||||
const [running, setRunning] = useState(false);
|
||||
const [progress, setProgress] = useState<{ step: number; total: number | null; elapsed: number } | null>(null);
|
||||
const [statusLine, setStatusLine] = useState<string>('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [results, setResults] = useState<ResultItem[]>(persisted.results || []);
|
||||
const [selected, setSelected] = useState<ResultItem | null>(persisted.results?.[0] || null);
|
||||
// the stage shows the live latent canvas while generating; a finished (or
|
||||
// clicked) result replaces it
|
||||
const [showPreview, setShowPreview] = useState<boolean>(!persisted.results?.length);
|
||||
const [sidebarOpen, setSidebarOpen] = useState<boolean>(persisted.sidebarOpen ?? true);
|
||||
const [cards, setCards] = useState<{ [key: string]: boolean }>(persisted.cards || {});
|
||||
const cardOpen = (key: string) => cards[key] ?? true;
|
||||
const toggleCard = (key: string) => setCards(c => ({ ...c, [key]: !(c[key] ?? true) }));
|
||||
const resultUrl = (r: ResultItem) => `/api/files/${encodeFilePathForUrl(r.path)}`;
|
||||
|
||||
const deleteResults = async (items: ResultItem[]) => {
|
||||
const paths = items.map(i => i.path);
|
||||
try {
|
||||
await apiClient.post('/api/inference/outputs/delete', { paths });
|
||||
} catch (e: any) {
|
||||
alert(`Failed to delete: ${e?.response?.data?.error || e?.message || e}`);
|
||||
return;
|
||||
}
|
||||
setResults(prev => {
|
||||
const next = prev.filter(r => !paths.includes(r.path));
|
||||
if (selected && paths.includes(selected.path)) {
|
||||
const replacement = next[0] || null;
|
||||
setSelected(replacement);
|
||||
if (!replacement) setShowPreview(true);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const deleteAll = () => {
|
||||
if (!results.length) return;
|
||||
openConfirm({
|
||||
title: 'Delete all results',
|
||||
message: `Delete all ${results.length} generated files from disk? This cannot be undone.`,
|
||||
type: 'warning',
|
||||
confirmText: 'Delete all',
|
||||
onConfirm: () => deleteResults(results),
|
||||
});
|
||||
};
|
||||
|
||||
// persist on every change (small payload; results capped)
|
||||
useEffect(() => {
|
||||
try {
|
||||
const state: PersistedState = { arch, model, sample, gpuId, results: results.slice(0, 60), sidebarOpen, cards };
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
// storage full or unavailable: nothing to do
|
||||
}
|
||||
}, [arch, model, sample, gpuId, results, sidebarOpen, cards]);
|
||||
const [frameIdx, setFrameIdx] = useState(0);
|
||||
const [frameCount, setFrameCount] = useState(1);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const framesRef = useRef<ImageData[]>([]);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const requestIdRef = useRef<string | null>(null);
|
||||
|
||||
// the canvas is sized to the requested image dimensions (from the engine's
|
||||
// start frame) so the preview occupies exactly the space the image will
|
||||
const previewSizeRef = useRef<{ width: number; height: number } | null>(null);
|
||||
const scratchRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const drawFrame = (idx: number) => {
|
||||
const canvas = canvasRef.current;
|
||||
const frames = framesRef.current;
|
||||
if (!canvas || !frames.length) return;
|
||||
const img = frames[Math.min(idx, frames.length - 1)];
|
||||
const target = previewSizeRef.current;
|
||||
const w = target?.width || img.width;
|
||||
const h = target?.height || img.height;
|
||||
if (canvas.width !== w || canvas.height !== h) {
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
}
|
||||
if (!scratchRef.current) scratchRef.current = document.createElement('canvas');
|
||||
const scratch = scratchRef.current;
|
||||
if (scratch.width !== img.width || scratch.height !== img.height) {
|
||||
scratch.width = img.width;
|
||||
scratch.height = img.height;
|
||||
}
|
||||
scratch.getContext('2d')?.putImageData(img, 0, 0);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(scratch, 0, 0, w, h);
|
||||
};
|
||||
useEffect(() => drawFrame(frameIdx), [frameIdx]);
|
||||
|
||||
const generate = async () => {
|
||||
if (!ready || running) return;
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
setStatusLine('Submitting');
|
||||
framesRef.current = [];
|
||||
setFrameCount(1);
|
||||
setFrameIdx(0);
|
||||
// keep whatever is on the stage until the first latent of this run
|
||||
// arrives; wipe the stale bitmap so an empty canvas never shows old data
|
||||
const cv = canvasRef.current;
|
||||
if (cv) cv.getContext('2d')?.clearRect(0, 0, cv.width, cv.height);
|
||||
previewSizeRef.current = sample.width && sample.height ? { width: sample.width, height: sample.height } : null;
|
||||
const abort = new AbortController();
|
||||
abortRef.current = abort;
|
||||
let preview: PreviewInfo | null = null;
|
||||
const body: { model: any; sample: any; stream: any } = {
|
||||
model: { ...model, arch },
|
||||
sample: { ...sample, seed: sample.seed === '' ? -1 : sample.seed },
|
||||
stream: { latents: 'raw', every_n_steps: 1, max_frames: 8 },
|
||||
};
|
||||
try {
|
||||
const res = await fetch(proxy('generate'), {
|
||||
method: 'POST',
|
||||
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: abort.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.text()) || `HTTP ${res.status}`);
|
||||
await readEngineFrames(
|
||||
res,
|
||||
({ header, payload }) => {
|
||||
requestIdRef.current = header.request_id;
|
||||
switch (header.type) {
|
||||
case 'start':
|
||||
preview = header.preview;
|
||||
if (header.sample?.width && header.sample?.height) {
|
||||
previewSizeRef.current = { width: header.sample.width, height: header.sample.height };
|
||||
}
|
||||
setStatusLine(`Generating with ${header.model?.arch}`);
|
||||
break;
|
||||
case 'status':
|
||||
setStatusLine(header.message);
|
||||
break;
|
||||
case 'progress':
|
||||
setProgress({ step: header.step, total: header.total, elapsed: header.elapsed });
|
||||
break;
|
||||
case 'latent': {
|
||||
try {
|
||||
if (header.preview) preview = header.preview;
|
||||
const data = payloadToFloat32(header, payload);
|
||||
const img = latentToImage(header, data, preview);
|
||||
if (img) {
|
||||
framesRef.current = img.frameData.map(px => new ImageData(px, img.width, img.height));
|
||||
setFrameCount(img.frames);
|
||||
drawFrame(frameIdx);
|
||||
setShowPreview(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('latent preview failed', e);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'result': {
|
||||
const item: ResultItem = {
|
||||
request_id: header.request_id,
|
||||
path: header.path,
|
||||
kind: header.kind,
|
||||
ext: header.ext,
|
||||
seed: header.seed,
|
||||
width: header.width,
|
||||
height: header.height,
|
||||
seconds: header.seconds,
|
||||
steps: header.steps,
|
||||
prompt: body.sample.prompt,
|
||||
arch,
|
||||
};
|
||||
setResults(r => [item, ...r]);
|
||||
setSelected(item);
|
||||
setShowPreview(false);
|
||||
break;
|
||||
}
|
||||
case 'error':
|
||||
setError(header.cancelled ? 'Cancelled' : header.message);
|
||||
break;
|
||||
case 'end':
|
||||
setStatusLine(header.status === 'done' ? 'Done' : header.status);
|
||||
break;
|
||||
}
|
||||
},
|
||||
abort.signal,
|
||||
);
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') setError(e?.message || String(e));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
abortRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// ctrl/cmd+enter anywhere on the page generates (the prompt textarea included)
|
||||
const generateRef = useRef(generate);
|
||||
generateRef.current = generate;
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !e.repeat) {
|
||||
e.preventDefault();
|
||||
generateRef.current();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
const cancel = async () => {
|
||||
const id = requestIdRef.current;
|
||||
if (id) {
|
||||
try {
|
||||
await apiClient.post(proxy(`cancel/${id}`));
|
||||
} catch {
|
||||
// engine gone; abort below still tears the stream down
|
||||
}
|
||||
}
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
|
||||
const modality = entry?.modality || 'image';
|
||||
const footerStatus = running
|
||||
? statusLine || 'Generating'
|
||||
: error
|
||||
? error
|
||||
: ready
|
||||
? health?.current
|
||||
? `Generating ${health.current.arch}`
|
||||
: health?.active?.arch
|
||||
? `Idle - ${health.active.arch} (${health.active.model?.name_or_path || ''})`
|
||||
: 'Idle - no model loaded'
|
||||
: engineStatus?.engine
|
||||
? `Engine ${engineStatus.engine.status}`
|
||||
: 'Engine off';
|
||||
const vram = health?.vram ? `${(health.vram.used / 1e9).toFixed(1)} / ${(health.vram.total / 1e9).toFixed(0)} GB` : null;
|
||||
const pool = health?.pool;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-blue-400" />
|
||||
<h1 className="text-base sm:text-lg">Generate</h1>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
{isStopping ? (
|
||||
<Button disabled className="px-2 py-1 rounded-md bg-red-900 text-white/70 flex items-center gap-1 cursor-wait">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Stopping engine…
|
||||
</Button>
|
||||
) : ready ? (
|
||||
<>
|
||||
<span className="text-green-400">Engine running</span>
|
||||
{health?.active?.arch && <span className="text-gray-400 hidden sm:inline">· {health.active.arch}</span>}
|
||||
{vram && <span className="text-gray-400 hidden md:inline">· VRAM {vram}</span>}
|
||||
<Button onClick={stopEngine} disabled={engineBusy} className="ml-2 px-2 py-1 rounded-md bg-red-700 hover:bg-red-600 disabled:opacity-60 text-white flex items-center gap-1">
|
||||
{engineBusy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Square className="w-4 h-4" />} Stop
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</TopBar>
|
||||
<MainContent belowTopBar className="overflow-hidden">
|
||||
{!ready && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center p-4">
|
||||
<div className="bg-gray-900/95 backdrop-blur border border-gray-700 rounded-2xl shadow-2xl px-8 py-8 max-w-md w-full text-center">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-blue-500/10 flex items-center justify-center mb-4">
|
||||
<Sparkles className="w-8 h-8 text-blue-400" />
|
||||
</div>
|
||||
<h2 className="text-lg text-gray-100 font-semibold mb-2">Inference Engine</h2>
|
||||
<p className="text-sm text-gray-400 mb-6">
|
||||
{isStarting
|
||||
? 'The engine is starting. Model options appear once its server is up.'
|
||||
: 'Start the inference engine on a GPU to generate images, video, and audio with any model.'}
|
||||
</p>
|
||||
{isStarting ? (
|
||||
<Button disabled className="w-full px-3 py-2 rounded-md bg-blue-900 text-white/70 flex items-center justify-center gap-2 cursor-wait">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{engineStatus?.engine?.status === 'running' ? 'Engine loading…' : engineStatus?.engine ? `Engine ${engineStatus.engine.status}…` : 'Starting engine…'}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{!isMac() && (
|
||||
<select value={gpuId} onChange={e => setGpuId(e.target.value)} className="w-full bg-gray-800 text-gray-200 rounded-md px-3 py-2 border border-gray-700">
|
||||
{gpuList.map(g => (
|
||||
<option key={g.index} value={`${g.index}`}>
|
||||
GPU #{g.index} {g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button onClick={startEngine} className="w-full px-3 py-2 rounded-md bg-blue-700 hover:bg-blue-600 text-white flex items-center justify-center gap-2">
|
||||
<Play className="w-4 h-4" /> Start engine
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={`absolute inset-0 pb-9 flex ${ready ? '' : 'opacity-30 pointer-events-none select-none'}`}>
|
||||
{/* ---- center: stage + history strip ---- */}
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
<div className="flex-1 min-h-0 relative bg-black/40 m-2 mb-0 rounded-xl border border-gray-800 overflow-hidden">
|
||||
{/* status strip over the stage */}
|
||||
<div className="absolute top-0 left-0 right-0 z-10 px-3 py-1.5 flex items-center gap-2 text-xs text-gray-300 bg-gradient-to-b from-gray-950/80 to-transparent">
|
||||
{running && <Loader2 className="w-3.5 h-3.5 animate-spin text-blue-400" />}
|
||||
<span className="truncate">
|
||||
{running
|
||||
? statusLine
|
||||
: selected
|
||||
? `${selected.arch} · seed ${selected.seed}${selected.seconds ? ` · ${selected.seconds.toFixed(1)}s${selected.steps ? ` / ${selected.steps} steps` : ''}` : ''}`
|
||||
: ''}
|
||||
</span>
|
||||
{progress ? (
|
||||
<span className="text-gray-400 shrink-0">
|
||||
step {progress.step}
|
||||
{progress.total ? `/${progress.total}` : ''} · {progress.elapsed.toFixed(1)}s
|
||||
</span>
|
||||
) : null}
|
||||
<div className="flex-1" />
|
||||
{running && (
|
||||
<Button onClick={cancel} className="px-2 py-0.5 rounded bg-gray-800/80 hover:bg-gray-700 text-gray-200 text-xs">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{progress?.total ? (
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gray-800 z-10">
|
||||
<div className="h-0.5 bg-blue-500 transition-all" style={{ width: `${Math.min(100, (progress.step / progress.total) * 100)}%` }} />
|
||||
</div>
|
||||
) : null}
|
||||
{error && <div className="absolute bottom-3 left-3 right-3 z-10 text-rose-300 text-sm bg-rose-950/70 rounded px-3 py-2 whitespace-pre-wrap">{error}</div>}
|
||||
|
||||
{/* live latent preview: shown while generating (or when nothing is selected) */}
|
||||
<div className={`absolute inset-3 flex items-center justify-center ${showPreview ? '' : 'hidden'}`}>
|
||||
<canvas ref={canvasRef} className="max-w-full max-h-full rounded" style={{ imageRendering: 'auto' }} />
|
||||
{!running && framesRef.current.length === 0 && (
|
||||
<div className="absolute text-gray-600 text-sm">Your generation will appear here</div>
|
||||
)}
|
||||
</div>
|
||||
{frameCount > 1 && showPreview && (
|
||||
<input type="range" min={0} max={frameCount - 1} value={frameIdx} onChange={e => setFrameIdx(parseInt(e.target.value))} className="absolute bottom-3 left-1/2 -translate-x-1/2 w-1/2 z-10" />
|
||||
)}
|
||||
|
||||
{/* selected result replaces the preview */}
|
||||
{!showPreview && selected && (
|
||||
<div className="absolute inset-3 flex items-center justify-center">
|
||||
{selected.kind === 'video' ? (
|
||||
<video key={selected.path} src={resultUrl(selected)} controls autoPlay loop className="max-w-full max-h-full rounded" />
|
||||
) : selected.kind === 'audio' ? (
|
||||
<div className="w-full max-w-xl bg-gray-900 rounded-xl p-6 text-center">
|
||||
<div className="text-gray-300 text-sm mb-4 truncate" title={selected.prompt}>
|
||||
{selected.prompt}
|
||||
</div>
|
||||
<audio key={selected.path} src={resultUrl(selected)} controls autoPlay className="w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<a href={resultUrl(selected)} target="_blank" rel="noreferrer" className="w-full h-full flex items-center justify-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img key={selected.path} src={resultUrl(selected)} alt={selected.prompt} className="max-w-full max-h-full rounded object-contain" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* history strip */}
|
||||
<div className="h-20 shrink-0 m-2 flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0 h-full flex items-center gap-2 overflow-x-auto overflow-y-hidden">
|
||||
{results.length === 0 ? (
|
||||
<div className="text-[11px] text-gray-600 px-1">History</div>
|
||||
) : (
|
||||
results.map(r => {
|
||||
const isSel = selected?.path === r.path && !showPreview;
|
||||
return (
|
||||
<div key={`${r.request_id}-${r.path}`} className="relative shrink-0 group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelected(r);
|
||||
setShowPreview(false);
|
||||
}}
|
||||
title={`${r.arch} · seed ${r.seed} · ${r.prompt}`}
|
||||
className={`h-16 w-16 rounded-md overflow-hidden border-2 ${isSel ? 'border-blue-500' : 'border-transparent hover:border-gray-600'} bg-gray-800 block`}
|
||||
>
|
||||
{r.kind === 'audio' ? (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400 text-[10px]">audio</div>
|
||||
) : r.kind === 'video' ? (
|
||||
<video src={resultUrl(r)} muted className="w-full h-full object-cover" />
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={resultUrl(r)} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
deleteResults([r]);
|
||||
}}
|
||||
title="Delete"
|
||||
className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-gray-900 border border-gray-600 text-gray-300 hover:bg-red-700 hover:text-white items-center justify-center hidden group-hover:flex"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{results.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={deleteAll}
|
||||
title="Delete all results"
|
||||
className="shrink-0 h-16 w-10 rounded-md border border-gray-800 text-gray-500 hover:text-red-400 hover:border-red-900 flex items-center justify-center"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- right: collapsible model + prompt sidebar ---- */}
|
||||
<div className={`relative shrink-0 transition-[width] duration-200 ${sidebarOpen ? 'w-[380px]' : 'w-0'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen(o => !o)}
|
||||
className="absolute top-1/2 -translate-y-1/2 -left-4 z-20 w-4 h-16 rounded-l-md bg-gray-800 border border-r-0 border-gray-700 text-gray-300 hover:bg-gray-700 flex items-center justify-center"
|
||||
title={sidebarOpen ? 'Collapse settings' : 'Expand settings'}
|
||||
>
|
||||
{sidebarOpen ? <ChevronRight className="w-3.5 h-3.5" /> : <ChevronLeft className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<div className={`h-full flex flex-col border-l border-gray-800 bg-gray-900/60 ${sidebarOpen ? '' : 'hidden'}`}>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-3">
|
||||
|
||||
<Card title="Model" open={cardOpen('model')} onToggle={() => toggleCard('model')}>
|
||||
<SelectInput label="Architecture" value={arch} onChange={v => applyArch(v as string)} options={archOptions} disabled={!ready} />
|
||||
<CreatableSelectInput
|
||||
label="Name or Path"
|
||||
value={model.name_or_path || ''}
|
||||
onChange={(v: string | null) => setModel(m => ({ ...m, name_or_path: v || '' }))}
|
||||
options={entry?.model?.name_or_path ? [{ value: entry.model.name_or_path, label: entry.model.name_or_path }] : []}
|
||||
placeholder="hub repo, local folder, or .safetensors"
|
||||
/>
|
||||
{'extras_name_or_path' in (entry?.model || {}) && (
|
||||
<TextInput label="Extras Name or Path" value={model.extras_name_or_path || ''} onChange={v => setModel(m => ({ ...m, extras_name_or_path: v }))} />
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<SelectInput
|
||||
label="Quantize Transformer"
|
||||
value={model.quantize ? model.qtype || 'convrot8' : ''}
|
||||
onChange={v => setModel(m => ({ ...m, quantize: !!v, qtype: v || undefined }))}
|
||||
options={[{ value: '', label: 'None (bf16)' }, ...qtypeOptions]}
|
||||
/>
|
||||
<SelectInput
|
||||
label="Quantize Text Encoder"
|
||||
value={model.quantize_te ? model.qtype_te || 'convrot8' : ''}
|
||||
onChange={v => setModel(m => ({ ...m, quantize_te: !!v, qtype_te: v || undefined }))}
|
||||
options={[{ value: '', label: 'None (bf16)' }, ...qtypeOptions]}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 mt-3">
|
||||
<Checkbox label="Low VRAM" checked={!!model.low_vram} onChange={v => setModel(m => ({ ...m, low_vram: v }))} />
|
||||
<Checkbox label="Layer offloading" checked={!!model.layer_offloading} onChange={v => setModel(m => ({ ...m, layer_offloading: v }))} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Prompt" open={cardOpen('prompt')} onToggle={() => toggleCard('prompt')}>
|
||||
<TextAreaInput label="Prompt" value={sample.prompt || ''} onChange={v => setSample(s => ({ ...s, prompt: v }))} placeholder={modality === 'audio' ? 'song description, lyrics, bpm…' : 'describe what to generate'} />
|
||||
{(sample.guidance_scale ?? 4) > 1 && (
|
||||
<TextAreaInput label="Negative prompt" value={sample.negative_prompt || ''} onChange={v => setSample(s => ({ ...s, negative_prompt: v }))} />
|
||||
)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<NumberInput label="Width" value={sample.width ?? 1024} onChange={v => setSample(s => ({ ...s, width: v }))} min={64} max={4096} />
|
||||
<NumberInput label="Height" value={sample.height ?? 1024} onChange={v => setSample(s => ({ ...s, height: v }))} min={64} max={4096} />
|
||||
<NumberInput label="Steps" value={sample.num_inference_steps ?? 25} onChange={v => setSample(s => ({ ...s, num_inference_steps: v }))} min={1} max={200} />
|
||||
<NumberInput label="Guidance" value={sample.guidance_scale ?? 4} onChange={v => setSample(s => ({ ...s, guidance_scale: v }))} min={1} max={30} />
|
||||
<NumberInput label="Seed (-1 random)" value={sample.seed ?? -1} onChange={v => setSample(s => ({ ...s, seed: v }))} min={-1} max={4294967295} />
|
||||
{modality === 'video' && (
|
||||
<>
|
||||
<NumberInput label="Frames" value={sample.num_frames ?? 33} onChange={v => setSample(s => ({ ...s, num_frames: v }))} min={1} max={1000} />
|
||||
<NumberInput label="FPS" value={sample.fps ?? 16} onChange={v => setSample(s => ({ ...s, fps: v }))} min={1} max={60} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
{/* always-visible action bar */}
|
||||
<div className="shrink-0 p-3 border-t border-gray-800 bg-gray-900 flex gap-2">
|
||||
<Button
|
||||
onClick={generate}
|
||||
disabled={!ready || running || !model.name_or_path}
|
||||
className="flex-1 px-3 py-2 rounded-md bg-blue-700 hover:bg-blue-600 disabled:opacity-40 text-white flex items-center justify-center gap-2"
|
||||
title="Ctrl/Cmd + Enter"
|
||||
>
|
||||
{running ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />} Generate
|
||||
</Button>
|
||||
{running && (
|
||||
<Button onClick={cancel} className="px-3 py-2 rounded-md bg-gray-700 hover:bg-gray-600 text-white">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainContent>
|
||||
<GenerateFooter jobId={engineJobId} status={footerStatus} busy={running || isStarting} progress={running ? progress : null} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GeneratePage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<GeneratePageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -97,6 +97,9 @@ export default function JobPage({ params }: { params: { jobID: string } }) {
|
||||
if (jobType === 'caption') {
|
||||
title = `Captioning: ${job?.job_ref || 'Loading...'}`;
|
||||
}
|
||||
if (jobType === 'inference') {
|
||||
title = `Inference Engine: ${job?.name || 'Loading...'}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -58,10 +58,38 @@ export interface SampleTags {
|
||||
[key: string]: SampleTag;
|
||||
}
|
||||
|
||||
export type GenerateModality = 'image' | 'video' | 'audio';
|
||||
|
||||
// Per-arch overrides for the Generate page. Everything it needs is derived
|
||||
// from the training entry (name_or_path / quantize defaults, video/audio
|
||||
// group, ctrl_img section); set these only where the derivation is wrong.
|
||||
export interface GenerateOptions {
|
||||
modality?: GenerateModality;
|
||||
model?: { [key: string]: any }; // extra ModelConfig kwargs
|
||||
sample?: { [key: string]: any }; // GenerateImageConfig kwargs
|
||||
needsControlImage?: boolean;
|
||||
sizeLocked?: boolean;
|
||||
}
|
||||
|
||||
export interface GenerateDefaults {
|
||||
arch: string;
|
||||
label: string;
|
||||
group: ModelGroup;
|
||||
modality: GenerateModality;
|
||||
model: { [key: string]: any };
|
||||
sample: { [key: string]: any };
|
||||
needsControlImage: boolean;
|
||||
sizeLocked: boolean;
|
||||
}
|
||||
|
||||
export interface ModelArch {
|
||||
name: string;
|
||||
label: string;
|
||||
/** label shown by the Generate page instead of `label` (training-specific
|
||||
* wording like "w/ Training Adapter" does not apply to inference) */
|
||||
generateNameOverride?: string;
|
||||
group: ModelGroup;
|
||||
generate?: GenerateOptions;
|
||||
controls?: Control[];
|
||||
isVideoModel?: boolean;
|
||||
hasMultiLinePrompts?: boolean;
|
||||
@@ -658,6 +686,7 @@ export const modelArchs: ModelArch[] = [
|
||||
{
|
||||
name: 'zimage:turbo',
|
||||
label: 'Z-Image Turbo (w/ Training Adapter)',
|
||||
generateNameOverride: 'Z-Image Turbo',
|
||||
group: 'image',
|
||||
defaults: {
|
||||
// default updates when [selected, unselected] in the UI
|
||||
@@ -1519,6 +1548,7 @@ export const modelArchs: ModelArch[] = [
|
||||
{
|
||||
name: 'krea2:turbo',
|
||||
label: 'Krea 2 Turbo (w/ Training Adapter)',
|
||||
generateNameOverride: 'Krea 2 Turbo',
|
||||
group: 'image',
|
||||
gateUrl: 'https://huggingface.co/krea/Krea-2-Turbo',
|
||||
defaults: {
|
||||
@@ -1575,6 +1605,7 @@ export const modelArchs: ModelArch[] = [
|
||||
{
|
||||
name: 'krea2:o_edit_turbo',
|
||||
label: 'Krea 2 Turbo (w/ Training Adapter) [Edit Training]',
|
||||
generateNameOverride: 'Krea 2 Turbo (Edit)',
|
||||
gateUrl: 'https://huggingface.co/krea/Krea-2-Turbo',
|
||||
group: 'experimental',
|
||||
defaults: {
|
||||
@@ -1770,3 +1801,65 @@ export const jobTypeOptions: JobTypeOption[] = [
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
const MODEL_PREFIX = 'config.process[0].model.';
|
||||
const SAMPLE_PREFIX = 'config.process[0].sample';
|
||||
// training-only model settings: the training adapter (e.g. Z-Image Turbo's
|
||||
// de-distill LoRA) and the unconditional LoRA must not load for inference
|
||||
const TRAINING_ONLY_MODEL_KEYS = new Set(['assistant_lora_path', 'unconditional_lora_path', 'inference_lora_path']);
|
||||
|
||||
/** What the Generate page sends the inference engine for an arch: ModelConfig
|
||||
* kwargs + GenerateImageConfig kwargs, derived from the training defaults. */
|
||||
export const getGenerateDefaults = (arch: ModelArch): GenerateDefaults => {
|
||||
const defaults = arch.defaults || {};
|
||||
const model: { [key: string]: any } = {};
|
||||
const sample: { [key: string]: any } = { width: 1024, height: 1024, num_inference_steps: 25, guidance_scale: 4 };
|
||||
for (const [key, pair] of Object.entries(defaults)) {
|
||||
const value = Array.isArray(pair) ? pair[0] : pair;
|
||||
if (key.startsWith(MODEL_PREFIX)) {
|
||||
const field = key.slice(MODEL_PREFIX.length);
|
||||
if (value === '' || value === undefined || value === null) continue;
|
||||
if (field.includes('.')) continue; // nested model_kwargs etc.
|
||||
if (TRAINING_ONLY_MODEL_KEYS.has(field)) continue;
|
||||
model[field] = value;
|
||||
} else if (key === SAMPLE_PREFIX && value && typeof value === 'object') {
|
||||
// whole SampleConfig object (audio models)
|
||||
const sc = value as any;
|
||||
if (sc.width) sample.width = sc.width;
|
||||
if (sc.height) sample.height = sc.height;
|
||||
if (sc.sample_steps) sample.num_inference_steps = sc.sample_steps;
|
||||
if (sc.guidance_scale !== undefined) sample.guidance_scale = sc.guidance_scale;
|
||||
if (sc.num_frames) sample.num_frames = sc.num_frames;
|
||||
if (sc.fps) sample.fps = sc.fps;
|
||||
if (sc.neg) sample.negative_prompt = sc.neg;
|
||||
} else if (key.startsWith(SAMPLE_PREFIX + '.')) {
|
||||
const field = key.slice(SAMPLE_PREFIX.length + 1);
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
if (field === 'sample_steps') sample.num_inference_steps = value;
|
||||
else if (field === 'neg') sample.negative_prompt = value;
|
||||
else if (['width', 'height', 'guidance_scale', 'num_frames', 'fps'].includes(field)) sample[field] = value;
|
||||
}
|
||||
}
|
||||
// the engine defaults to convrot8; the training default qtype is the
|
||||
// quanto one, which is the slower inference choice
|
||||
if (model.quantize && (!model.qtype || model.qtype === 'qfloat8')) model.qtype = 'convrot8';
|
||||
if (model.quantize_te && (!model.qtype_te || model.qtype_te === 'qfloat8')) model.qtype_te = 'convrot8';
|
||||
const sections = arch.additionalSections || [];
|
||||
const gen = arch.generate || {};
|
||||
const modality: GenerateModality = gen.modality || (arch.group === 'audio' ? 'audio' : arch.isVideoModel ? 'video' : 'image');
|
||||
if (modality !== 'video') {
|
||||
delete sample.num_frames;
|
||||
delete sample.fps;
|
||||
}
|
||||
return {
|
||||
arch: arch.name,
|
||||
label: arch.generateNameOverride || arch.label,
|
||||
group: arch.group,
|
||||
modality,
|
||||
model: { ...model, ...(gen.model || {}) },
|
||||
sample: { ...sample, ...(gen.sample || {}) },
|
||||
needsControlImage: gen.needsControlImage ?? (sections.includes('sample.ctrl_img') || sections.includes('sample.multi_ctrl_imgs')),
|
||||
sizeLocked: gen.sizeLocked ?? false,
|
||||
};
|
||||
};
|
||||
@@ -26,6 +26,10 @@ export default function ActiveJobWidget() {
|
||||
|
||||
let label = job.name;
|
||||
let href = `/jobs/${job.id}`;
|
||||
if (job.job_type === 'inference') {
|
||||
label = 'Inference Engine';
|
||||
href = `/generate?job=${job.id}`;
|
||||
}
|
||||
if (job.job_type === 'caption') {
|
||||
const splits = (job.job_ref ?? '').split(/[/\\]/);
|
||||
label = splits[splits.length - 1] || job.name;
|
||||
|
||||
@@ -128,6 +128,14 @@ export default function JobsTable({ onlyActive = false, job_type = null }: JobsT
|
||||
let title = row.name;
|
||||
let href = `/jobs/${row.id}`;
|
||||
// if (row.job_type === 'train') title = `Train: ${title}`;
|
||||
if (row.job_type === 'inference') {
|
||||
href = `/generate?job=${row.id}`;
|
||||
title = (
|
||||
<>
|
||||
<small className="opacity-50">INFERENCE ENGINE: </small> {row.name}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (row.job_type === 'caption') {
|
||||
let splits = row.job_ref.split(/[/\\]/);
|
||||
const datasetPath = `${splits[splits.length - 1]}`;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Home, Settings, BrainCircuit, Images, Plus, X } from 'lucide-react';
|
||||
import { Home, Settings, BrainCircuit, Images, Plus, X, Sparkles } from 'lucide-react';
|
||||
import { FaXTwitter, FaDiscord, FaYoutube } from 'react-icons/fa6';
|
||||
import { createGlobalState } from 'react-global-hooks';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
@@ -36,6 +36,7 @@ const Sidebar = () => {
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: Home },
|
||||
{ name: 'Generate', href: '/generate', icon: Sparkles, badge: 'alpha' },
|
||||
{ name: 'New Job', href: '/jobs/new', icon: Plus },
|
||||
{ name: 'Queue', href: '/jobs', icon: BrainCircuit },
|
||||
{ name: 'Datasets', href: '/datasets', icon: Images },
|
||||
@@ -73,6 +74,11 @@ const Sidebar = () => {
|
||||
>
|
||||
<item.icon className="w-5 h-5 mr-3" />
|
||||
{item.name}
|
||||
{'badge' in item && item.badge && (
|
||||
<span className="ml-2 px-1.5 py-0.5 text-[10px] leading-none uppercase tracking-wide rounded bg-blue-600/20 text-blue-400 border border-blue-500/40">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ChevronUp, ChevronDown, Loader2 } from 'lucide-react';
|
||||
import useJobLog from '@/hooks/useJobLog';
|
||||
|
||||
interface Props {
|
||||
/** engine job id: the log panel follows its log.txt */
|
||||
jobId: string | null;
|
||||
/** one-line status shown in the collapsed bar */
|
||||
status: string;
|
||||
busy?: boolean;
|
||||
progress?: { step: number; total: number | null } | null;
|
||||
}
|
||||
|
||||
export default function GenerateFooter({ jobId, status, busy, progress }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { log, status: logStatus } = useJobLog(jobId ?? '', jobId && open ? 1500 : null);
|
||||
const logRef = useRef<HTMLDivElement | null>(null);
|
||||
const [follow, setFollow] = useState(true);
|
||||
|
||||
const lines = useMemo(() => {
|
||||
const splits = log.split('\n');
|
||||
return splits.length > 1500 ? splits.slice(splits.length - 1500) : splits;
|
||||
}, [log]);
|
||||
|
||||
const handleScroll = () => {
|
||||
const el = logRef.current;
|
||||
if (!el) return;
|
||||
setFollow(el.scrollHeight - el.scrollTop - el.clientHeight < 10);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open && follow && logRef.current) {
|
||||
logRef.current.scrollTop = logRef.current.scrollHeight;
|
||||
}
|
||||
}, [log, open, follow]);
|
||||
|
||||
const pct = progress?.total ? Math.min(100, (progress.step / progress.total) * 100) : null;
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 right-0 bottom-0 z-30 border-t border-gray-800 bg-gray-900 shadow-[0_-4px_16px_rgba(0,0,0,0.4)]">
|
||||
{/* progress hairline */}
|
||||
{pct !== null && (
|
||||
<div className="h-0.5 bg-gray-800">
|
||||
<div className="h-0.5 bg-blue-500 transition-all" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="w-full flex items-center gap-2 px-3 h-9 text-xs text-gray-300 hover:bg-gray-800/60"
|
||||
title={open ? 'Hide log' : 'Show log'}
|
||||
>
|
||||
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin text-blue-400 shrink-0" /> : <span className="w-2 h-2 rounded-full bg-green-500 shrink-0" />}
|
||||
<span className="truncate flex-1 text-left font-mono">{status}</span>
|
||||
{progress?.total ? (
|
||||
<span className="text-gray-500 shrink-0">
|
||||
{progress.step}/{progress.total}
|
||||
</span>
|
||||
) : null}
|
||||
{open ? <ChevronDown className="w-4 h-4 shrink-0" /> : <ChevronUp className="w-4 h-4 shrink-0" />}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="h-[40vh] bg-gray-950 border-t border-gray-800 relative">
|
||||
<div ref={logRef} onScroll={handleScroll} className="absolute inset-0 overflow-y-auto p-3 text-[11px] leading-4 text-gray-300 font-mono">
|
||||
{!jobId && <div className="text-gray-500">No engine job.</div>}
|
||||
{jobId && logStatus === 'loading' && !log && <div className="text-gray-500">Loading log…</div>}
|
||||
{jobId && logStatus === 'error' && <div className="text-rose-400">Error loading log</div>}
|
||||
{lines.map((line, i) => (
|
||||
<pre key={i} className="whitespace-pre-wrap break-all">
|
||||
{line}
|
||||
</pre>
|
||||
))}
|
||||
</div>
|
||||
{!follow && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFollow(true);
|
||||
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
|
||||
}}
|
||||
className="absolute bottom-3 right-4 px-2 py-1 rounded bg-gray-800 border border-gray-700 text-[11px] text-gray-300 hover:bg-gray-700"
|
||||
>
|
||||
Follow
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Job config for the resident inference engine (extensions_built_in/inference_engine).
|
||||
// The worker fills engine.job_folder in when it launches the job.
|
||||
export const defaultInferenceJobConfig = {
|
||||
job: 'extension',
|
||||
config: {
|
||||
name: 'inference_engine',
|
||||
process: [
|
||||
{
|
||||
type: 'InferenceEngine',
|
||||
sqlite_db_path: './aitk_db.db',
|
||||
device: 'cuda',
|
||||
engine: {
|
||||
dtype: 'bf16',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import prisma from '@/server/prisma';
|
||||
import { getTrainingFolder } from '@/server/settings';
|
||||
|
||||
// What the engine job writes to <job folder>/engine.json when its server is up.
|
||||
export interface EngineEndpoint {
|
||||
host: string;
|
||||
port: number;
|
||||
token: string;
|
||||
pid: number;
|
||||
job_id?: string | null;
|
||||
device?: string;
|
||||
job_folder: string;
|
||||
output_folder: string;
|
||||
started_at: number;
|
||||
}
|
||||
|
||||
export interface EngineHandle {
|
||||
jobId: string;
|
||||
jobName: string;
|
||||
gpuIds: string;
|
||||
status: string;
|
||||
endpoint: EngineEndpoint | null;
|
||||
}
|
||||
|
||||
const isProcessAlive = (pid: number): boolean => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
return e?.code === 'EPERM';
|
||||
}
|
||||
};
|
||||
|
||||
const readEndpoint = (jobFolder: string): EngineEndpoint | null => {
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(jobFolder, 'engine.json'), 'utf8');
|
||||
const ep = JSON.parse(raw) as EngineEndpoint;
|
||||
if (!ep.host || !ep.port) return null;
|
||||
// a stale file from a crashed engine must not be proxied to
|
||||
if (ep.pid && !isProcessAlive(ep.pid)) return null;
|
||||
return ep;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
let cache: { ts: number; handles: EngineHandle[] } | null = null;
|
||||
const CACHE_MS = 2000;
|
||||
|
||||
/** Every inference-engine job that is running (or starting), with its endpoint when published. */
|
||||
export async function listEngines(forceFresh = false): Promise<EngineHandle[]> {
|
||||
if (!forceFresh && cache && Date.now() - cache.ts < CACHE_MS) return cache.handles;
|
||||
const jobs = await prisma.job.findMany({
|
||||
where: { job_type: 'inference', status: { in: ['running', 'queued', 'stopping'] } },
|
||||
orderBy: { updated_at: 'desc' },
|
||||
});
|
||||
const trainingFolder = await getTrainingFolder();
|
||||
const handles: EngineHandle[] = jobs.map(job => ({
|
||||
jobId: job.id,
|
||||
jobName: job.name,
|
||||
gpuIds: job.gpu_ids,
|
||||
status: job.status,
|
||||
endpoint: job.status === 'running' ? readEndpoint(path.join(trainingFolder, job.name)) : null,
|
||||
}));
|
||||
cache = { ts: Date.now(), handles };
|
||||
return handles;
|
||||
}
|
||||
|
||||
/** The engine to talk to: the requested job, else the first running one with an endpoint. */
|
||||
export async function resolveEngine(jobId?: string | null): Promise<EngineHandle | null> {
|
||||
const handles = await listEngines();
|
||||
if (jobId) return handles.find(h => h.jobId === jobId) || null;
|
||||
return handles.find(h => h.endpoint) || handles[0] || null;
|
||||
}
|
||||
Reference in new issue
Block a user