Files
zandClaude Opus 4.8 20d2f809af fix(studio/create): RED findings — dedup double-bill, dim type-confusion, alg over-match, sub-caption XSS (v0.18.2)
- F1 (MED): _dedup_key now keys on stable identity (org|kind|prompt|refs|uploads),
  not the random-tagged output_prefix, so two identical creates collapse to one
  dispatch (the anti-double-bill guard actually fires now).
- F2 (LOW): _image_graph/_video_graph raise DispatchError on non-numeric dims (clean 400, not 500).
- F3 (LOW): alg allowlist tightened to the exact JWS set in gateway_auth + documents.
- F4 (INFO): esc() the gallery sub-caption (crafted-upload filename_prefix XSS).
Red verified /v1/library/image cross-org/traversal/secret-exfil fail closed. +8 tests, 65 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 09:31:48 -07:00

2957 lines
152 KiB
Python

"""Studio Home — the content-forward default view (flow-style), with the node
editor demoted to "Advanced mode".
The normal user works per DESIGN, not per node graph: see the assets a pipeline
produced, judge them (status chips make blotchy rejects vs approved picks
obvious), re-run or chat-fix a given output, and step into the graph editor only
when they mean to. This module adds that surface without inventing any new
storage or lifecycle:
GET /studio the home page (auth-gated like everything else)
GET /v1/library org's library.json + per-asset workflow provenance
POST /v1/library/status {path, status} draft|approved|queued|published
POST /v1/library/rerun {path, count?} re-queue the EXACT embedded graph, new seed
POST /v1/library/fix {path, instruction} chat-fix: Qwen edit pass on that output
POST /v1/library/compose {paths[], uploads[]?, instruction} multi-reference render
(library assets and/or images uploaded via /upload/image)
POST /v1/library/dedup soft-delete exact-duplicate images (recoverable)
POST /v1/library/purge {paths[] | all} permanently remove ARCHIVED assets —
unlink file + drop record, irreversible, archive-only
One source of truth per concern, all pre-existing:
* assets + curation state -> orgs/<org>/output/library.json (library_manifest.py)
* what made an image -> the API graph ComfyUI embeds in the PNG itself
* running work -> the ONE /prompt path (self-POST, so gpu_dispatch,
billing and content_publish all apply unchanged)
* identity -> the caller's own session; headers are forwarded
verbatim on the self-POST, no token is stored
* workflow editing -> the 0.15.1 deep-link opener (?workflow=&run=1)
Env:
STUDIO_HOME_DEFAULT "1"/"true": browser GET / redirects to /studio
(the SPA stays reachable at /?advanced=1 and all
non-root paths). Off by default so local dev and
worker pods are untouched.
"""
from __future__ import annotations
import contextlib
import hashlib
import json
import logging
import os
import random
import re
import tempfile
import time
import uuid
from pathlib import Path
import aiohttp
from aiohttp import web
import folder_paths
from middleware import quality_gate, stacks_store, worklog
log = logging.getLogger("studio.home")
_HTML = Path(__file__).with_name("studio_home.html")
_ASSET_DIR = Path(__file__).parent
_STATUSES = ("draft", "approved", "queued", "published", "deleted", "flagged")
_PROV_CACHE: dict[str, tuple[float, dict]] = {} # abspath -> (mtime, provenance)
_NEG = ("ghosting, double exposure, duplicated body, transparent fabric, sheer "
"fabric, illustration, painterly, blotchy skin, mottled skin, jpeg "
"artifacts, pixelation, noise, deformed hands, extra limbs, text, watermark")
# The Qwen-Image-Edit-2511 model set, by the EXACT filenames present on the render
# worker (diffusion_models / text_encoders / vae — verified on spark). The graph
# names ONLY these: a graph that referenced a missing checkpoint fails ComfyUI
# validation instantly and lands a degenerate, un-sampled result.
_QWEN_UNET = "qwen-image-edit-2511.safetensors"
_QWEN_CLIP = "qwen2.5-vl-7b.safetensors"
_QWEN_VAE = "qwen-image-vae.safetensors"
# The Wan 2.2 TI2V-5B model set, by the EXACT filenames present on the render worker
# (spark) — the same locality rule as the Qwen set: a graph naming a missing checkpoint
# fails ComfyUI validation instantly. _WAN_NEG is the proven Chinese negative from the
# spark video proof (== zen-video DEFAULT_NEGATIVE): the overbright / static / blurry /
# caption / worst-quality / deformed-limb corruption classes for Wan.
_WAN_UNET = "wan2.2_ti2v_5B_fp16.safetensors"
_WAN_CLIP = "umt5_xxl_fp8_e4m3fn_scaled.safetensors"
_WAN_VAE = "wan2.2_vae.safetensors"
_WAN_NEG = "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走"
_VIDEO_EXT = (".mp4", ".webm")
_MODEL3D_EXT = (".glb",) # Hunyuan3D SaveGLB output
_HY3D_CKPT = "hunyuan_3d_v2.1.safetensors" # bundled model+CLIP-vision+VAE (Comfy-Org repackaged)
# The Z-Image-Turbo text-to-image model set, by the EXACT filenames the shipped
# "Text to Image (Z-Image-Turbo)" blueprint names (unet / lumina2 text encoder / vae) —
# the same locality rule as the Qwen/Wan sets. Turbo → 4 steps, cfg 1.0. This is the
# composer's default "What should we create?" lane: words in, an image out.
_ZIMAGE_UNET = "z_image_turbo_bf16.safetensors"
_ZIMAGE_CLIP = "qwen_3_4b.safetensors"
_ZIMAGE_VAE = "ae.safetensors"
_SAVE_EXT = (".png",) + _VIDEO_EXT + _MODEL3D_EXT # every extension a saver writes
try:
from middleware.gpu_dispatch import SAVE_NODES as _SAVERS # the ONE output-node list
except ImportError: # alternate package anchor (same fallback the lazy gpu_dispatch imports use)
from .middleware.gpu_dispatch import SAVE_NODES as _SAVERS
def _home_default() -> bool:
return os.environ.get("STUDIO_HOME_DEFAULT", "").lower() in ("1", "true", "yes")
def _org_of(request: web.Request) -> str:
user = request.get("iam_user") or {}
return user.get("org_id") or os.environ.get("STUDIO_ORG_ID") or "default"
def _owner_of(request: web.Request) -> str:
"""The acting user (IAM subject) — a Stack's owner_id / added_by. Best-effort;
Stacks are org-scoped, so this is provenance, not an access boundary."""
return (request.get("iam_user") or {}).get("sub") or ""
def _workspace_of(request: web.Request) -> str | None:
"""The active project = a Stack's workspace_id, or None when unscoped (the
same ``studio_active_project`` cookie the session/workflow scope reads)."""
return request.cookies.get("studio_active_project") or None
def _library_root(org: str) -> Path | None:
"""The org's asset tree. Pod layout first (orgs/<org>/output via
folder_paths), then the flat-dev staging layout (output/orgs/<org>/output)."""
for cand in (
Path(folder_paths.get_org_output_directory(org)),
Path(folder_paths.get_output_directory()) / "orgs" / org / "output",
):
if (cand / "library.json").is_file():
return cand
return None
def _view_params(abs_path: Path) -> dict:
"""Query params for the existing org-scoped /view endpoint."""
out = Path(folder_paths.get_output_directory())
try:
rel = abs_path.relative_to(out)
except ValueError:
# org-scoped dir outside the flat output dir (pod layout): /view resolves
# the org dir itself in multi-tenant mode, so make the path org-relative.
for org_base in abs_path.parents:
if org_base.name == "output" and org_base.parent.parent.name == "orgs":
rel = abs_path.relative_to(org_base)
break
else:
rel = Path(abs_path.name)
return {"filename": rel.name, "subfolder": str(rel.parent) if str(rel.parent) != "." else "", "type": "output"}
def _embedded(path: Path) -> dict:
"""The metadata a saver embedded in an output, for BOTH media, as a {key: raw-json}
mapping: a PNG carries it in PIL image ``info`` (SaveImage), an mp4/webm carries it in
the CONTAINER metadata (SaveVideo/SaveWEBM write the SAME JSON strings PIL returns).
Returns {} when absent/unreadable. The ONE reader — provenance, Open-in-Editor and
re-run all parse the ``prompt`` (API graph) out of it (Open-in-Editor also reads the UI
``workflow``), so video never silently loses its Versions / Re-run / lessons surface."""
suf = path.suffix.lower()
try:
if suf == ".png":
from PIL import Image
return dict(Image.open(path).info)
if suf in _VIDEO_EXT:
import av
with av.open(str(path)) as c:
return dict(c.metadata)
except Exception: # non-Comfy media, truncated file — metadata is optional
pass
return {}
def _poster(p: Path):
"""The first video frame as a PIL image — the card poster. PyAV in-process (the studio
image ships no ffmpeg binary, and SaveVideo already encodes with av, so av is present).
Raises on any decode failure; the caller turns that into a 404, never the whole file."""
import av
with av.open(str(p)) as c:
frame = next(c.decode(video=0))
return frame.to_image()
def _provenance(abs_path: Path) -> dict:
"""What made this output, from the graph the saver embeds in it — PNG or video."""
try:
mtime = abs_path.stat().st_mtime
except OSError:
return {}
key = str(abs_path)
hit = _PROV_CACHE.get(key)
if hit and hit[0] == mtime:
return hit[1]
prov: dict = {}
try:
graph = json.loads(_embedded(abs_path).get("prompt", "null"))
if isinstance(graph, dict) and graph:
for node in graph.values():
if not isinstance(node, dict):
continue
ct = node.get("class_type", "")
ins = node.get("inputs", {})
if ct in _SAVERS:
prov["workflow"] = ins.get("filename_prefix", "")
elif "KSampler" in ct:
prov.update(steps=ins.get("steps"), cfg=ins.get("cfg"),
seed=ins.get("seed", ins.get("noise_seed")))
elif ct == "Wan22ImageToVideoLatent":
prov["length"] = ins.get("length")
elif ct == "CreateVideo":
prov["fps"] = ins.get("fps")
prov["nodes"] = len(graph)
except Exception: # non-Comfy output, truncated file — provenance is optional
prov = {}
_PROV_CACHE[key] = (mtime, prov)
return prov
def _norm_vote(v) -> int:
"""Any vote input → the canonical preference label: 1 (👍), -1 (👎), 0 (cleared)."""
if isinstance(v, str):
return {"up": 1, "down": -1, "1": 1, "-1": -1}.get(v.strip().lower(), 0)
if isinstance(v, (int, float)):
return 1 if v > 0 else -1 if v < 0 else 0
return 0
def _append_feedback(request, root: Path, org: str, key: str, entry: dict) -> None:
"""Append ONE immutable training-feedback event to orgs/<org>/output/feedback.jsonl —
the flywheel dataset the model trainer consumes. ratings.json is the CURRENT-state
store the UI reads; THIS is the append-only log of every judgement (vote + stars +
text) paired with the output it judged, so preference / reward / instruction-tuning
signal is built downstream (the output PNG carries its own prompt + refs in its
embedded graph, so the record only needs to anchor to it). Best-effort — never
blocks or fails the rating write."""
try:
path = entry.get("path") or (key if key.lower().endswith(_REF_EXT) else "")
rec = {"ts": int(time.time()), "user": _owner_of(request), "org": org, "key": key,
"output": path, "vote": entry.get("vote"), "stars": entry.get("stars"),
"text": entry.get("notes") or ""}
# The output's curation state rides along: a flagged/deleted output is
# negative-only signal downstream — never a positive training exemplar.
if path and root is not None:
rec["status"] = _asset_status(root, path)
if path:
p = _safe_asset(root, path)
if p is not None:
rec["prov"] = _provenance(p)
with open(root / "feedback.jsonl", "a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
except Exception:
pass
def _rate_write(root: Path, key: str, patch: dict) -> dict:
"""The ONE atomic writer for the current-state rating store (ratings.json) — the
append-only training log is _append_feedback. Merges ``patch`` into the entry and
replaces the file via a UNIQUE temp, so two concurrent writers (a manual rate and an
archive hook) can never clobber each other on a shared temp name — the hazard already
fixed for library.json."""
rf = root / "ratings.json"
try:
data = json.loads(rf.read_text())
except Exception:
data = {}
entry = {**data.get(key, {}), **patch, "updatedAt": int(time.time())}
data[key] = entry
fd, tmp = tempfile.mkstemp(dir=str(root), prefix=".ratings.", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=1)
os.replace(tmp, rf)
except Exception:
with contextlib.suppress(OSError):
os.unlink(tmp)
raise
return entry
def _train_on_status(request, root: Path, org: str, rel: str, status: str) -> None:
"""Curation IS training signal: archiving or flagging an output records it as a
NEGATIVE example — the user threw it away, so it is a bad response — and restoring it
clears that auto-negative. The vote is NOTE-LESS on purpose: it trains the
reward/preference dataset (feedback.jsonl, where the deleted/flagged status rides
along as negative-only signal) WITHOUT injecting a generic 'lesson' into future
prompts (_mistakes gates on note text, so a bare downvote never pollutes a prompt).
A judgement the user typed themselves (a manual vote/note) is respected — never
overwritten by, or cleared with, the automatic archive-negative. Automatic dedup does
NOT pass through here, so byte-identical twins are never mislabelled bad."""
try:
try:
cur = json.loads((root / "ratings.json").read_text()).get(rel, {})
except Exception:
cur = {}
user_owned = bool(cur.get("notes")) or ("vote" in cur and not cur.get("archived"))
if status in _CORRUPT: # archived / flagged → negative exemplar
if not user_owned:
cur = _rate_write(root, rel, {"vote": -1, "archived": True, "path": rel})
_append_feedback(request, root, org, rel, {**cur, "path": rel})
elif cur.get("archived"): # restored → clear ONLY our auto-negative
cur = _rate_write(root, rel, {"vote": 0, "archived": False, "path": rel})
_append_feedback(request, root, org, rel, {**cur, "path": rel})
except Exception:
log.debug("train-on-status skipped for %s", rel, exc_info=True)
try:
import fcntl as _fcntl
except ImportError: # non-POSIX; never the studio image
_fcntl = None
def _load_library(root: Path) -> dict:
try:
return json.loads((root / "library.json").read_text())
except Exception:
return {"assets": []}
def _save_library(root: Path, lib: dict) -> None:
"""Write library.json atomically AND serialized against the build-manifest
sidecar. Two failure modes this closes: (1) a fixed shared tmp let two writers
interleave into one inode -> torn JSON -> reader gets {} -> every asset resets to
draft (the curation wipe) — mkstemp gives each writer a private tmp; (2) the
sidecar's rebuild-then-replace and this write could interleave renames — a shared
flock (.library.lock, the same file scripts/library_manifest.py takes across its
build+write) serializes them so neither clobbers the other's file operation."""
lock_path = root / ".library.lock"
lh = open(lock_path, "w") if _fcntl is not None else None
try:
if lh is not None:
_fcntl.flock(lh, _fcntl.LOCK_EX)
fd, tmp = tempfile.mkstemp(prefix="library.json.", suffix=".tmp", dir=str(root))
try:
with os.fdopen(fd, "w") as fh:
json.dump(lib, fh, indent=1, sort_keys=False)
os.replace(tmp, str(root / "library.json"))
except BaseException:
with contextlib.suppress(OSError):
os.unlink(tmp)
raise
finally:
if lh is not None:
with contextlib.suppress(OSError):
_fcntl.flock(lh, _fcntl.LOCK_UN)
lh.close()
def _soft_delete_assets(org: str, asset_ids: list[str]) -> None:
"""Trash a set of library assets (status=deleted) — the SAME soft-delete the
gallery's 🗑 uses. Files are never unlinked; Deleted stays recoverable. This is
what "Delete Stack and Images" maps to (Stacks own no lifecycle of their own)."""
root = _library_root(org)
if root is None:
return
ids = set(asset_ids)
lib = _load_library(root)
changed = False
for a in lib.get("assets", []):
if a.get("path") in ids and a.get("status") != "deleted":
a["status"] = "deleted"
a["updatedAt"] = int(time.time())
changed = True
if changed:
_save_library(root, lib)
def _safe_asset(root: Path, rel: str) -> Path | None:
if any(part.startswith(".") for part in Path(rel).parts):
# Hidden segments are never assets — AppleDouble forks AND cache
# trees (.thumbs/) in one rule, refused everywhere at once.
return None
p = (root / rel).resolve()
return p if p.is_file() and str(p).startswith(str(root.resolve())) else None
def _drop_thumb(root: Path, rel: str) -> None:
"""Remove an asset's cached grid thumbnail, if one exists. Mirrors the key scheme
get_asset_file writes (sanitized resolved path → root/.thumbs/<key>.jpg) so a
permanent delete leaves no orphaned thumbnail behind. Best-effort."""
try:
p = (root / rel).resolve()
key = re.sub(r"[^a-zA-Z0-9]", "_", str(p))[-120:]
for ext in (".webp", ".jpg"): # current WebP thumb + any legacy JPEG
(root / ".thumbs" / f"{key}{ext}").unlink(missing_ok=True)
except OSError:
pass
_REF_EXT = (".png", ".jpg", ".jpeg", ".webp")
def _resolve_uploads(org: str, uploads: list | None) -> list[str]:
"""Validate reference images already uploaded via ``POST /upload/image`` — the
core engine route lands them in this org's input dir (the SAME place fix/compose
stage their library-sourced references), so the compose graph's LoadImage nodes
can name them directly and the BYO-GPU collector ships them with the job. Return
the safe basenames, in order, de-duped. A traversal/absent/non-image entry is
dropped, never fatal — the caller enforces the reference count."""
in_dir = Path(folder_paths.get_org_input_directory(org))
root = str(in_dir.resolve())
names: list[str] = []
for u in uploads or []:
if not isinstance(u, str):
continue
name = os.path.basename(u.strip())
if not name or name in names or os.path.splitext(name)[1].lower() not in _REF_EXT:
continue
p = (in_dir / name).resolve()
if p.is_file() and str(p).startswith(root):
names.append(name)
return names
def _reseed(graph: dict) -> dict:
for node in graph.values():
ins = node.get("inputs", {})
for k in ("seed", "noise_seed"):
if isinstance(ins.get(k), (int, float)):
ins[k] = random.randrange(2**31)
return graph
def _retag(graph: dict, prefix: str) -> dict:
"""Point every saver (SaveImage / SaveVideo / SaveWEBM) in a re-run / template graph
at a UNIQUE output prefix. Without this a re-run replays the source graph's own saver
prefix, so its file lands in the SOURCE's namespace and N re-runs share one prefix —
the library then attributes the newest sibling to every row (a job shows a photo it
did not make). fix/compose/amend already tag per dispatch; this gives re-run and
template-render the same job-identity guarantee, for image AND video outputs."""
for node in graph.values():
if isinstance(node, dict) and node.get("class_type") in _SAVERS:
node.setdefault("inputs", {})["filename_prefix"] = prefix
return graph
def _mistakes(ratings: dict, family) -> list[str]:
"""The correction texts inside ``ratings`` for any asset in ``family`` — a note
counts as a MISTAKE when its judgement is negative (👎 or ≤2 stars) or carries no
judgement at all (a bare note points something out); praise notes are not
avoid-clauses. Deduped, sanitized, capped for prompt injection."""
out: list[str] = []
for path in family:
r = ratings.get(path) if path else None
if not isinstance(r, dict):
continue
note = " ".join((r.get("notes") or "").split()).strip()[:140]
if not note:
continue
vote, stars = r.get("vote"), r.get("stars")
if vote == -1 or (isinstance(stars, int) and stars <= 2) or (vote in (None, 0) and stars is None):
if note not in out:
out.append(note)
return out[:4]
def _lessons(org: str, root: Path | None, rels: list[str]) -> list[str]:
"""Every mistake the user has pointed out on these assets or their lineage —
read at DISPATCH so the very next generation is conditioned against repeating
it. The same judgements also accumulate in feedback.jsonl for weight training."""
rels = [r for r in rels if r]
if root is None or not rels:
return []
try:
ratings = json.loads((root / "ratings.json").read_text())
except Exception:
return []
if not ratings:
return []
rows = worklog.load(org)
_resolve_outputs(org, root, rows, int(time.time()))
family = set(rels)
for rel in rels:
lin = worklog.build_lineage(rows, rel)
family |= {a.get("path") for a in lin.get("ancestors") or []}
family |= {d.get("path") for d in lin.get("descendants") or []}
return _mistakes(ratings, family)
def _job_tag() -> str:
"""Six hex chars appended to every saver prefix so each dispatch owns a
UNIQUE output namespace — a re-fix of the same source can never prefix-match an
earlier run's (possibly rejected) file as its own result."""
return uuid.uuid4().hex[:6]
def _stage_source(in_dir: Path, p: Path, kind: str, *, max_px: int | None = None) -> str:
"""Stage a library asset into the org input dir under a CONTENT-ADDRESSED name — the
ONE way an Edit/Compose/Video/3D source reaches the worker's LoadImage. The name is
the sha256 of the source bytes, which makes the source binding self-verifying and
concurrency-safe:
* the name IS a checksum of the exact selected image, so the bytes the worker loads
can never silently drift to another asset (a mismatch would be a different name);
* two jobs editing DIFFERENT sources can never collide on a shared worker input dir
(different bytes -> different name, cryptographically) — the cross-contamination
that a process-salted hash could, rarely, produce for concurrent edits;
* two jobs on the SAME source share one identical file (idempotent, dedup-friendly).
``max_px`` downsizes the STAGED copy (Hunyuan3D encodes small; keeps the enqueue under
the inline cap) — the content-address still keys on the ORIGINAL asset, so distinct
sources stay distinct regardless of the resample."""
data = p.read_bytes()
ref_name = f"{kind}_src_{hashlib.sha256(data).hexdigest()[:16]}.png"
dst = in_dir / ref_name
if max_px:
try:
from PIL import Image
im = Image.open(p)
if max(im.size) > max_px:
im.thumbnail((max_px, max_px))
im.save(dst)
return ref_name
except Exception:
pass
dst.write_bytes(data)
return ref_name
def _trace(stage: str, **fields) -> None:
"""One structured render-lifecycle line, correlated by job id, at each hop
(build -> queue -> worker-publish) so a wrong asset/prompt is pinpointed to the
FIRST stage it appears. NEVER logs signed URLs, tokens, cookies or image bytes —
only ids, content-address checksums, counts and lane/node labels."""
kv = " ".join(f"{k}={v}" for k, v in fields.items() if v is not None)
log.info("studio.trace %s %s", stage, kv)
_TAG_RE = re.compile(r"_[0-9a-f]{6}$")
def _stamp(prefix: str) -> str:
"""Give an output prefix a FRESH job identity: strip a trailing job tag from its LAST
path segment if present (so a re-run of a re-run, or a re-render of a saved template,
does not accumulate tags), then append a new one. Every dispatch of a STORED graph —
template render, re-run — thus owns a unique output namespace (the house job rule)."""
head, sep, tail = prefix.rpartition("/")
tail = _TAG_RE.sub("", tail) + f"_{_job_tag()}"
return f"{head}/{tail}" if sep else tail
# View/pose-change intent: these instructions need the empty-latent reference
# family — the anchored fix latent (VAEEncode of the base) preserves composition
# by design, which is exactly wrong when the user asks for a different view.
_REPOSE = re.compile(
r"\b(from the (side|back|front|left|right)|side view|back view|front view|profile|"
r"three.?quarter|turn(ed|s|ing)? (around|left|right)|facing (left|right|away)|"
r"different (pose|angle|view)|change (the )?(pose|angle|view)|from behind|"
r"standing|sitting|kneeling|walking|lying|looking (left|right|away|back|over))\b", re.I)
def _repose_size(p: Path) -> tuple[int, int]:
"""EmptySD3LatentImage dims for a view/pose change: the reference's aspect at
~1MP, both sides multiples of 16 (latent stride)."""
try:
from PIL import Image
w, h = Image.open(p).size
except Exception:
return 1024, 1536
scale = (1_048_576 / max(1, w * h)) ** 0.5
def to16(v: float) -> int:
return max(256, min(2048, int(round(v * scale / 16)) * 16))
return to16(w), to16(h)
# Edit-strength → denoise. The Edit action is a LOW-denoise img2img over the SOURCE
# latent — never EmptyLatentImage, never denoise 1.0 (that is Regenerate). 0.25 default
# preserves composition/palette/lighting/subject and applies only the requested change;
# the slider lets a bigger edit dial up. Clamped to the edit band [0.05, 0.95] — a true
# 1.0 is the separate Regenerate action, so an Edit can NEVER silently become a regen.
EDIT_DENOISE_DEFAULT = 0.25
EDIT_DENOISE_MIN, EDIT_DENOISE_MAX = 0.05, 0.95
def _edit_denoise(strength) -> float:
"""Map an edit-strength input to a KSampler denoise in the edit band. Accepts a
fraction (0.25) or a percent (25). None/invalid → the 0.25 default."""
try:
v = float(strength)
except (TypeError, ValueError):
return EDIT_DENOISE_DEFAULT
if v > 1.0: # a percent (25) → fraction
v = v / 100.0
return max(EDIT_DENOISE_MIN, min(EDIT_DENOISE_MAX, v))
def _fix_graph(ref_name: str, instruction: str, prefix: str, *, denoise: float = EDIT_DENOISE_DEFAULT,
seed: int | None = None, lessons: list[str] | None = None) -> dict:
"""LOW-DENOISE Qwen-Image-Edit-2511 instruction edit — the EDIT action.
The source is LoadImage'd, scaled ONCE (FluxKontextImageScale), and fed to BOTH the
edit conditioning (TextEncodeQwenImageEditPlus) AND the sampling latent (VAEEncode) —
so KSampler starts from the SOURCE latent, NEVER an empty one. ``denoise`` (default
0.25, from the edit-strength slider) is how much may change: at 0.25 the source
latent dominates → composition, palette, lighting, framing and subject are preserved
and only the requested change is applied. ``seed`` reuses the source's original seed
by default (stable iteration; a locked seed + identical input + settings is
deterministic); a caller may vary it and STILL edit the source (the latent is the
KSampler input regardless of seed). AuraFlow shift + CFGNorm are the Qwen recipe.
INVARIANTS (see _assert_edit_graph): latent_image is the VAEEncode of the source (no
EmptyLatentImage), and denoise is in the edit band (never 1.0). A pose/view change or
a fresh interpretation is REGENERATE (a separate action), not this."""
prompt = (f"{instruction.strip()}. Photorealistic; keep the same subject, "
f"composition, pose, framing, proportions, lighting, materials, colors "
f"and background — apply ONLY the requested change, leave everything else "
f"unchanged.")
neg = _NEG
if lessons:
prompt += " Known mistakes on earlier versions — do not repeat: " + "; ".join(lessons) + "."
neg = _NEG + ", " + ", ".join(lessons)
return {
"1": {"class_type": "LoadImage", "inputs": {"image": ref_name}},
"2": {"class_type": "FluxKontextImageScale", "inputs": {"image": ["1", 0]}},
"3": {"class_type": "CLIPLoader", "inputs": {"clip_name": _QWEN_CLIP, "type": "qwen_image", "device": "default"}},
"4": {"class_type": "UNETLoader", "inputs": {"unet_name": _QWEN_UNET, "weight_dtype": "default"}},
"5": {"class_type": "VAELoader", "inputs": {"vae_name": _QWEN_VAE}},
"6": {"class_type": "TextEncodeQwenImageEditPlus", "inputs": {"clip": ["3", 0], "vae": ["5", 0], "image1": ["2", 0], "prompt": prompt}},
"7": {"class_type": "TextEncodeQwenImageEditPlus", "inputs": {"clip": ["3", 0], "vae": ["5", 0], "image1": ["2", 0], "prompt": neg}},
"10": {"class_type": "VAEEncode", "inputs": {"pixels": ["2", 0], "vae": ["5", 0]}},
"11": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["4", 0], "shift": 3.1}},
"12": {"class_type": "CFGNorm", "inputs": {"model": ["11", 0], "strength": 1.0}},
"13": {"class_type": "KSampler", "inputs": {"model": ["12", 0], "positive": ["6", 0], "negative": ["7", 0], "latent_image": ["10", 0],
"seed": seed if seed is not None else random.randrange(2**31), "steps": 30, "cfg": 4.0,
"sampler_name": "euler", "scheduler": "simple", "denoise": float(denoise)}},
"14": {"class_type": "VAEDecode", "inputs": {"samples": ["13", 0], "vae": ["5", 0]}},
"15": {"class_type": "SaveImage", "inputs": {"images": ["14", 0], "filename_prefix": prefix}},
}
def _inpaint_graph(ref_name: str, mask_name: str, instruction: str, prefix: str, *,
denoise: float = EDIT_DENOISE_DEFAULT, seed: int | None = None,
lessons: list[str] | None = None) -> dict:
"""MASKED Qwen-Image-Edit — the EDIT-SELECTED-AREA action. Same low-denoise edit as
_fix_graph, but SetLatentNoiseMask restricts sampling to the painted region, and
ImageCompositeMasked composites the decode back over the ORIGINAL so pixels OUTSIDE
the mask stay byte-identical. The mask is a user-painted image (white = edit here),
scaled to the source's scaled size so latent + mask align."""
prompt = (f"{instruction.strip()}. Photorealistic; change ONLY the masked region to "
f"apply the requested edit and blend it seamlessly; keep the subject, "
f"lighting, materials and colors consistent with the rest of the image.")
neg = _NEG
if lessons:
prompt += " Known mistakes on earlier versions — do not repeat: " + "; ".join(lessons) + "."
neg = _NEG + ", " + ", ".join(lessons)
return {
"1": {"class_type": "LoadImage", "inputs": {"image": ref_name}},
"2": {"class_type": "FluxKontextImageScale", "inputs": {"image": ["1", 0]}},
"3": {"class_type": "CLIPLoader", "inputs": {"clip_name": _QWEN_CLIP, "type": "qwen_image", "device": "default"}},
"4": {"class_type": "UNETLoader", "inputs": {"unet_name": _QWEN_UNET, "weight_dtype": "default"}},
"5": {"class_type": "VAELoader", "inputs": {"vae_name": _QWEN_VAE}},
# Mask: load its alpha/luma, resize to the scaled source so the latent noise-mask aligns.
"8": {"class_type": "LoadImage", "inputs": {"image": mask_name}},
"9": {"class_type": "ImageToMask", "inputs": {"image": ["8", 0], "channel": "red"}},
"16": {"class_type": "ResizeMask", "inputs": {"mask": ["9", 0], "width": ["2", 0], "height": ["2", 0]}},
"6": {"class_type": "TextEncodeQwenImageEditPlus", "inputs": {"clip": ["3", 0], "vae": ["5", 0], "image1": ["2", 0], "prompt": prompt}},
"7": {"class_type": "TextEncodeQwenImageEditPlus", "inputs": {"clip": ["3", 0], "vae": ["5", 0], "image1": ["2", 0], "prompt": neg}},
"10": {"class_type": "VAEEncode", "inputs": {"pixels": ["2", 0], "vae": ["5", 0]}},
"17": {"class_type": "SetLatentNoiseMask", "inputs": {"samples": ["10", 0], "mask": ["16", 0]}},
"11": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["4", 0], "shift": 3.1}},
"12": {"class_type": "CFGNorm", "inputs": {"model": ["11", 0], "strength": 1.0}},
"13": {"class_type": "KSampler", "inputs": {"model": ["12", 0], "positive": ["6", 0], "negative": ["7", 0], "latent_image": ["17", 0],
"seed": seed if seed is not None else random.randrange(2**31), "steps": 30, "cfg": 4.0,
"sampler_name": "euler", "scheduler": "simple", "denoise": float(denoise)}},
"14": {"class_type": "VAEDecode", "inputs": {"samples": ["13", 0], "vae": ["5", 0]}},
# Composite the edited decode over the ORIGINAL scaled source, masked — unmasked
# pixels are the source's own, so only the painted region ever changes.
"18": {"class_type": "ImageCompositeMasked", "inputs": {"destination": ["2", 0], "source": ["14", 0], "mask": ["16", 0], "x": 0, "y": 0, "resize_source": False}},
"15": {"class_type": "SaveImage", "inputs": {"images": ["18", 0], "filename_prefix": prefix}},
}
# Edit-graph invariants — validated before every Edit dispatch so an Edit can NEVER
# silently become a text-to-image Regenerate. Returns "" if valid, else the reason.
def _assert_edit_graph(graph: dict) -> str:
ks = next((n for n in graph.values() if isinstance(n, dict) and n.get("class_type") == "KSampler"), None)
if not ks:
return "edit graph has no KSampler"
ins = ks.get("inputs", {})
lat_ref = ins.get("latent_image")
lat_node = graph.get(lat_ref[0]) if isinstance(lat_ref, list) and lat_ref else None
lat_ct = (lat_node or {}).get("class_type", "")
if "Empty" in lat_ct:
return "edit KSampler.latent_image is an EmptyLatentImage (that is Regenerate, not Edit)"
if lat_ct not in ("VAEEncode", "SetLatentNoiseMask"):
return f"edit latent must be VAEEncode/SetLatentNoiseMask of the source, not {lat_ct or 'nothing'}"
dn = ins.get("denoise", 1.0)
if not (EDIT_DENOISE_MIN <= float(dn) <= EDIT_DENOISE_MAX):
return f"edit denoise {dn} is outside the edit band [{EDIT_DENOISE_MIN},{EDIT_DENOISE_MAX}] (1.0 = Regenerate)"
if not any(isinstance(n, dict) and n.get("class_type") == "VAEEncode" for n in graph.values()):
return "edit graph is missing VAEEncode of the source"
if not any(isinstance(n, dict) and n.get("class_type") in _SAVERS for n in graph.values()):
return "edit graph does not save an output"
return ""
def _video_graph(prompt: str, prefix: str, *, ref_name: str | None = None,
width: int = 640, height: int = 640, length: int = 73,
seed: int | None = None, lessons: list[str] | None = None) -> dict:
"""The proven Wan 2.2 TI2V-5B text/image-to-video graph, lifted node-for-node from the
spark video proof. UNETLoader → ModelSamplingSD3(shift 8) → KSampler(uni_pc / simple, 20
steps, cfg 5.0, denoise 1.0); CLIPLoader(type ``wan``) + VAELoader feed a
Wan22ImageToVideoLatent whose width/height clamp to [256,1280] and snap to the 32-px grid
the sampler needs, and whose length clamps to [9,121] and snaps to 4n+1. A reference
image, when given, wires LoadImage → start_image (image-to-video); with NONE the node
AND its start_image key are omitted, for pure text-to-video (proof caveat). VAEDecode →
CreateVideo(24 fps) → SaveVideo(mp4/h264). Pure dict builder, same shape as _fix_graph;
cross-checked against zen-video/zen_wan_workflow.py build_prompt (not imported)."""
try:
w = max(256, min(1280, int(width)))
h = max(256, min(1280, int(height)))
n = max(9, min(121, int(length)))
except (TypeError, ValueError):
raise DispatchError("width, height and length must be numbers") # → a clean 400
w -= w % 32 # snap to the 32-px grid (proof caveat)
h -= h % 32
n = ((n - 1) // 4) * 4 + 1 # snap to 4n+1
pos = prompt.strip()
neg = _WAN_NEG
if lessons:
pos += " Known mistakes on earlier versions — do not repeat: " + "; ".join(lessons) + "."
neg = _WAN_NEG + ", " + ", ".join(lessons)
latent = {"vae": ["3", 0], "width": w, "height": h, "length": n, "batch_size": 1}
g = {
"1": {"class_type": "UNETLoader", "inputs": {"unet_name": _WAN_UNET, "weight_dtype": "default"}},
"2": {"class_type": "CLIPLoader", "inputs": {"clip_name": _WAN_CLIP, "type": "wan"}},
"3": {"class_type": "VAELoader", "inputs": {"vae_name": _WAN_VAE}},
"4": {"class_type": "ModelSamplingSD3", "inputs": {"model": ["1", 0], "shift": 8}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": pos}},
"6": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": neg}},
"8": {"class_type": "Wan22ImageToVideoLatent", "inputs": latent},
"9": {"class_type": "KSampler", "inputs": {"model": ["4", 0], "positive": ["5", 0],
"negative": ["6", 0], "latent_image": ["8", 0],
"seed": seed or random.randrange(2**31), "steps": 20, "cfg": 5.0,
"sampler_name": "uni_pc", "scheduler": "simple", "denoise": 1.0}},
"10": {"class_type": "VAEDecode", "inputs": {"samples": ["9", 0], "vae": ["3", 0]}},
"11": {"class_type": "CreateVideo", "inputs": {"images": ["10", 0], "fps": 24}},
"12": {"class_type": "SaveVideo", "inputs": {"video": ["11", 0], "filename_prefix": prefix,
"format": "mp4", "codec": "h264"}},
}
if ref_name:
g["7"] = {"class_type": "LoadImage", "inputs": {"image": ref_name}}
g["8"]["inputs"]["start_image"] = ["7", 0] # TI2V; omitted entirely for pure t2v
return g
def _image_graph(prompt: str, prefix: str, *, width: int = 1024, height: int = 1024,
seed: int | None = None, lessons: list[str] | None = None) -> dict:
"""The proven Z-Image-Turbo text-to-image graph, lifted node-for-node from the shipped
"Text to Image (Z-Image-Turbo)" blueprint. UNETLoader(z_image_turbo_bf16) →
ModelSamplingAuraFlow(shift 3) → KSampler(res_multistep / simple, 4 steps, cfg 1.0,
denoise 1.0) over EmptySD3LatentImage; CLIPLoader(qwen_3_4b, type ``lumina2``) +
VAELoader(ae) feed the positive/negative CLIPTextEncode; VAEDecode → SaveImage.
width/height clamp to [256,2048] and snap to the 16-px grid. Turbo means 4 steps at
cfg 1.0 (a negative prompt is inert at cfg 1, but the node is kept for graph symmetry
with the other builders and so lessons can steer via the positive). Pure dict builder,
same shape as _video_graph — the text-to-image analogue the composer's default lane
dispatches. Unlike _fix_graph this DOES start from an EmptySD3LatentImage: it is a true
text-to-image create, not an edit of an existing asset."""
try:
w = max(256, min(2048, int(width)))
h = max(256, min(2048, int(height)))
except (TypeError, ValueError):
raise DispatchError("width and height must be numbers") # → a clean 400, not a 500
w -= w % 16
h -= h % 16
pos = prompt.strip()
if lessons:
pos += " Known mistakes on earlier versions — do not repeat: " + "; ".join(lessons) + "."
return {
"1": {"class_type": "UNETLoader", "inputs": {"unet_name": _ZIMAGE_UNET, "weight_dtype": "default"}},
"2": {"class_type": "CLIPLoader", "inputs": {"clip_name": _ZIMAGE_CLIP, "type": "lumina2", "device": "default"}},
"3": {"class_type": "VAELoader", "inputs": {"vae_name": _ZIMAGE_VAE}},
"4": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["1", 0], "shift": 3.0}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": pos}},
"6": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": ""}},
"7": {"class_type": "EmptySD3LatentImage", "inputs": {"width": w, "height": h, "batch_size": 1}},
"8": {"class_type": "KSampler", "inputs": {"model": ["4", 0], "positive": ["5", 0],
"negative": ["6", 0], "latent_image": ["7", 0],
"seed": seed if seed is not None else random.randrange(2**31),
"steps": 4, "cfg": 1.0, "sampler_name": "res_multistep", "scheduler": "simple", "denoise": 1.0}},
"9": {"class_type": "VAEDecode", "inputs": {"samples": ["8", 0], "vae": ["3", 0]}},
"10": {"class_type": "SaveImage", "inputs": {"images": ["9", 0], "filename_prefix": prefix}},
}
def _model3d_graph(ref_name: str, prefix: str, *, seed: int | None = None) -> dict:
"""The proven Hunyuan3D-2.1 image-to-mesh graph, lifted node-for-node from ComfyUI's
own 3d_hunyuan3d-v2.1 template. ImageOnlyCheckpointLoader loads MODEL+CLIP_VISION+VAE
from the one bundled checkpoint (the separate-loader path produced noise); the image
is CLIP-vision encoded → Hunyuan3Dv2Conditioning → ModelSamplingAuraFlow(shift 1) — the
flow-matching wrapper WITHOUT which the sampler yields fragments — → KSampler(euler /
normal, 30 steps, cfg 5, denoise 1) over EmptyLatentHunyuan3Dv2(4096) → VAEDecodeHunyuan3D
→ VoxelToMesh(surface net, 0.6) → SaveGLB. Verified on spark: a coherent 3D garment mesh.
A reference image is REQUIRED — there is no text-to-3D path."""
return {
"1": {"class_type": "ImageOnlyCheckpointLoader", "inputs": {"ckpt_name": _HY3D_CKPT}},
"2": {"class_type": "LoadImage", "inputs": {"image": ref_name}},
"3": {"class_type": "CLIPVisionEncode", "inputs": {"clip_vision": ["1", 1], "image": ["2", 0], "crop": "center"}},
"4": {"class_type": "Hunyuan3Dv2Conditioning", "inputs": {"clip_vision_output": ["3", 0]}},
"5": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["1", 0], "shift": 1.0}},
"6": {"class_type": "EmptyLatentHunyuan3Dv2", "inputs": {"resolution": 4096, "batch_size": 1}},
"7": {"class_type": "KSampler", "inputs": {"model": ["5", 0], "positive": ["4", 0], "negative": ["4", 1],
"latent_image": ["6", 0], "seed": seed if seed is not None else random.randrange(2**31),
"steps": 30, "cfg": 5.0, "sampler_name": "euler", "scheduler": "normal", "denoise": 1.0}},
"8": {"class_type": "VAEDecodeHunyuan3D", "inputs": {"samples": ["7", 0], "vae": ["1", 2], "num_chunks": 8000, "octree_resolution": 256}},
"9": {"class_type": "VoxelToMesh", "inputs": {"voxel": ["8", 0], "algorithm": "surface net", "threshold": 0.6}},
"10": {"class_type": "SaveGLB", "inputs": {"mesh": ["9", 0], "filename_prefix": prefix}},
}
def _compose_graph(ref_names: list[str], instruction: str, prefix: str,
lessons: list[str] | None = None) -> dict:
"""Multi-reference compose: several chosen assets become the references and the
user's words say HOW to combine them into a new output. Same proven Qwen
reference-conditioned wiring as _fix_graph (TextEncodeQwenImageEditPlus takes
image1..imageN), so no new pipeline — the multi-input case of the good graph."""
prompt = (f"{instruction.strip()}. Combine the provided reference images as "
f"described. Photorealistic, coherent single result.")
if lessons:
prompt += " Known mistakes on earlier versions — do not repeat: " + "; ".join(lessons) + "."
g: dict = {
"3": {"class_type": "CLIPLoader", "inputs": {"clip_name": _QWEN_CLIP, "type": "qwen_image", "device": "default"}},
"4": {"class_type": "UNETLoader", "inputs": {"unet_name": _QWEN_UNET, "weight_dtype": "default"}},
"5": {"class_type": "VAELoader", "inputs": {"vae_name": _QWEN_VAE}},
}
pos_inputs: dict = {"clip": ["3", 0], "vae": ["5", 0], "prompt": prompt}
nid = 20
for i, name in enumerate(ref_names[:5], start=1): # up to 5 references
li, si = str(nid), str(nid + 1)
g[li] = {"class_type": "LoadImage", "inputs": {"image": name}}
g[si] = {"class_type": "FluxKontextImageScale", "inputs": {"image": [li, 0]}}
pos_inputs[f"image{i}"] = [si, 0]
nid += 2
g["6"] = {"class_type": "TextEncodeQwenImageEditPlus", "inputs": pos_inputs}
if lessons:
neg_prompt = _NEG + ", " + ", ".join(lessons)
else:
neg_prompt = _NEG
g["7"] = {"class_type": "TextEncodeQwenImageEditPlus", "inputs": {"clip": ["3", 0], "prompt": neg_prompt}}
# Qwen's native multi-reference conditioning (image1..imageN on node 6) — NO
# FluxKontextMultiReferenceLatentMethod: the same Flux-Kontext override that
# stippled the single-image fix has no place on Qwen conditioning here either.
g["10"] = {"class_type": "EmptySD3LatentImage", "inputs": {"width": 1024, "height": 1536, "batch_size": 1}}
g["11"] = {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["4", 0], "shift": 3.1}}
g["12"] = {"class_type": "CFGNorm", "inputs": {"model": ["11", 0], "strength": 1.0}}
g["13"] = {"class_type": "KSampler", "inputs": {"model": ["12", 0], "positive": ["6", 0], "negative": ["7", 0], "latent_image": ["10", 0],
"seed": random.randrange(2**31), "steps": 30, "cfg": 4.0, "sampler_name": "euler", "scheduler": "simple", "denoise": 1.0}}
g["14"] = {"class_type": "VAEDecode", "inputs": {"samples": ["13", 0], "vae": ["5", 0]}}
g["15"] = {"class_type": "SaveImage", "inputs": {"images": ["14", 0], "filename_prefix": prefix}}
return g
class DispatchError(ValueError):
"""A create/fix/compose request that can't be honored — bad input, unknown
asset, or a downstream /prompt failure. Carries a human message; the routes
return it as ``{"error": <message>}`` so a dispatch NEVER fails silently."""
def _prompt_error_message(body) -> str:
"""A short, human reason from a /prompt non-200 body. On a model-less cloud
pod the graph fails validation ("unet_name '…' not in []"); the router returns
'no GPU worker' / 'provisioning'. This is what the user must SEE instead of a
silent nothing."""
err = body.get("error") if isinstance(body, dict) else None
if isinstance(err, dict):
msg = err.get("message") or err.get("type") or "render could not be queued"
det = err.get("details")
return f"{msg}{det}" if det and det != msg else msg
if isinstance(err, str) and err.strip():
return err.strip()
if isinstance(body, dict) and body.get("message"):
return str(body["message"])
return "render could not be queued — no GPU worker online for this org"
async def _queue_prompt(request: web.Request, server, graph: dict) -> str:
"""Self-POST to the ONE /prompt path with the caller's own credentials, so
dispatch/billing/tenancy/content-publish behave exactly as a UI run. A non-200
from /prompt (model-less pod fails validation; router has no GPU worker) is
raised as a ``DispatchError`` carrying the real reason — NOT a raw 502 whose
truncated body the dialog can't parse (that was the deployed 'nothing happened /
nothing queued' — the error was swallowed)."""
port = getattr(server, "port", None) or int(os.environ.get("PORT", "8188"))
headers = {"Content-Type": "application/json"}
# Forward the caller's identity AND their per-GPU target (X-Target-GPU) so a fix/
# compose/video/rerun dispatched server-side lands on the machine the user picked —
# one targeting path, honored by dispatch_if_worker for every generation surface.
for h in ("Authorization", "Cookie", "X-Target-GPU"):
if request.headers.get(h):
headers[h] = request.headers[h]
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60)) as s:
async with s.post(f"http://127.0.0.1:{port}/prompt", json={"prompt": graph}, headers=headers) as r:
text = await r.text()
try:
body = json.loads(text) if text else {}
except ValueError:
body = {} # non-JSON (e.g. an HTML error page) — don't 500, report it
if r.status != 200:
raise DispatchError(_prompt_error_message(body) if body
else f"render engine returned HTTP {r.status}")
pid = body.get("prompt_id", "") if isinstance(body, dict) else ""
if not pid:
raise DispatchError("render was not queued (no prompt_id returned)")
return pid
except aiohttp.ClientError as e:
raise DispatchError(f"could not reach the render engine ({e})")
def _graph_params(graph: dict) -> dict:
"""The render params worth showing in the work-log context panel."""
p: dict = {}
for n in graph.values():
if not isinstance(n, dict):
continue
ct, ins = n.get("class_type", ""), n.get("inputs", {})
if "KSampler" in ct:
p.update(steps=ins.get("steps"), cfg=ins.get("cfg"),
sampler=ins.get("sampler_name"), scheduler=ins.get("scheduler"),
seed=ins.get("seed", ins.get("noise_seed")))
elif ct == "EmptySD3LatentImage":
p.update(width=ins.get("width"), height=ins.get("height"))
elif ct == "Wan22ImageToVideoLatent":
p.update(width=ins.get("width"), height=ins.get("height"), length=ins.get("length"))
elif ct == "CreateVideo":
p.update(fps=ins.get("fps"))
return {k: v for k, v in p.items() if v is not None}
def _in_render_jobs(org: str, pid: str) -> bool:
"""True if a prompt_id was dispatched to this org's BYO-GPU lane (gpu-jobs) —
gpu_dispatch._track_dispatched writes it to render_jobs.json during the self-POST."""
return _render_job(org, pid) is not None
def _render_job(org: str, pid: str) -> dict | None:
try:
jf = Path(folder_paths.get_org_output_directory(org)) / "render_jobs.json"
for j in json.loads(jf.read_text()):
if j.get("id") == pid:
return j
except Exception:
pass
return None
def _render_job_node(org: str, pid: str) -> str:
"""The node label the gpu-jobs lane recorded for this dispatch (spark/dbc/…)."""
j = _render_job(org, pid)
return (j.get("node") if j else None) or "gpu"
# ── Authoritative gpu-jobs queue (the tasks engine is the source of truth) ───────────
# The visible queue reads the org's activities STRAIGHT from the cloud tasks engine
# (GET /v1/tasks/namespaces/gpu-jobs/activities, org-scoped by the caller's bearer via
# the tasks gate) — not a local shadow. So every row shows the REAL worker that claimed
# it (activity.identity) and its REAL status, and nothing renders on a GPU without
# appearing here (a claimed job IS an activity). render_jobs.json survives ONLY as an
# optimistic placeholder for the ~2s between enqueue and first claim.
_JOBS_NS = os.environ.get("STUDIO_GPU_JOBS_NS", "gpu-jobs")
_PHASE = {"SCHEDULED": "queued", "PENDING": "queued", "STARTED": "running",
"RUNNING": "running", "COMPLETED": "done", "SUCCEEDED": "done",
"FAILED": "failed", "CANCELED": "cancelled", "CANCELLED": "cancelled"}
def _epoch(ts) -> int | None:
"""Best-effort seconds-since-epoch from a tasks timestamp (RFC3339 string or a
number in s/ms). None for empty/unset so age math can skip it."""
if not ts:
return None
if isinstance(ts, (int, float)):
return int(ts if ts < 1e12 else ts / 1000)
if isinstance(ts, str):
try:
from datetime import datetime
return int(datetime.fromisoformat(ts.strip().replace("Z", "+00:00")).timestamp())
except Exception:
return None
return None
def _act_type_name(a: dict) -> str:
t = a.get("type") or a.get("activityType") or {}
return t.get("name", "") if isinstance(t, dict) else ""
def _activity_phase(a: dict) -> str:
return _PHASE.get(str(a.get("status", "")).upper(), "queued")
def _prompt_title_refs(prompt: dict) -> tuple:
"""A human title (SaveImage/SaveVideo prefix) + first LoadImage ref for a thumbnail,
derived from the activity's OWN input so a row is self-describing without the shadow."""
from middleware.gpu_dispatch import SAVE_NODES
title, refs = "render", []
for n in (prompt or {}).values():
if not isinstance(n, dict):
continue
ct = n.get("class_type", "")
if ct in SAVE_NODES:
title = (n.get("inputs", {}) or {}).get("filename_prefix") or title
elif "LoadImage" in ct:
im = (n.get("inputs", {}) or {}).get("image")
if im:
refs.append(im)
return title.split("/")[-1], refs[:1]
def _job_from_activity(a: dict, now: int) -> dict:
"""Map a StandaloneActivity → the UI's job row: REAL claimer + REAL status."""
ex = a.get("execution") or {}
pid = ex.get("runId") or ex.get("workflowId") or a.get("activityId") or ""
inp = a.get("input") if isinstance(a.get("input"), dict) else {}
title, refs = _prompt_title_refs(inp.get("prompt") or {})
tq = str(a.get("taskQueue") or "")
# identity is the machine that CLAIMED it; before pick-up the "gpu:<name>" lane
# still names the targeted machine so a queued targeted job shows where it's headed.
node = a.get("identity") or (tq[4:] if tq.startswith("gpu:") else None) or "queued"
start = _epoch(a.get("startTime")) or _epoch(a.get("scheduleTime"))
beat = _epoch(a.get("lastHeartbeatTime"))
return {"id": pid, "node": node, "status": _activity_phase(a),
"prefix": title, "refs": refs,
"age": max(0, now - start) if start else 0,
"beat_age": (now - beat) if beat else None}
async def _list_gpu_jobs(request) -> list | None:
"""The caller-org's gpu-jobs activities from the tasks engine. Returns None (not [])
when the tasks API can't be reached, so a transient blip falls back to the local
placeholder rather than flashing an empty queue. Tenant-scoped by the bearer."""
from middleware.gpu_dispatch import CLOUD, _bearer
tok = _bearer(request)
if not tok:
return []
url = f"{CLOUD}/v1/tasks/namespaces/{_JOBS_NS}/activities?pageSize=100"
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=8)) as s:
async with s.get(url, headers={"Authorization": tok}) as r:
if r.status != 200:
return None
data = await r.json()
except Exception:
return None
acts = data.get("activities") if isinstance(data, dict) else None
return acts if isinstance(acts, list) else []
def _render_queue_placeholder(root, now: int, exclude: set) -> list:
"""Optimistic pre-claim rows from render_jobs.json: ONLY jobs the authoritative list
hasn't surfaced yet and that are seconds-young — the bridge across the ~2s between
enqueue and first claim. Anything older is the engine's to report."""
try:
jobs = json.loads((root / "render_jobs.json").read_text())
except Exception:
return []
out = []
for j in jobs:
pid = j.get("id")
if not pid or pid in exclude:
continue
age = now - int(j.get("ts", 0))
if age < 0 or age > 30:
continue
out.append({"id": pid, "node": j.get("node") or "queued", "status": "queued",
"prefix": j.get("prefix") or "render", "refs": (j.get("refs") or [])[:1],
"age": age, "beat_age": None})
return out
def _output_mtime(root: Path | None, rel: str) -> int | None:
try:
return int((root / rel).stat().st_mtime) if root and rel else None
except OSError:
return None
def _remove_render_job(org: str, pid: str) -> bool:
"""Drop a BYO-GPU tracking row from this org's render_jobs.json. The file is
per-org so a match is inherently owned. Returns True if one was removed."""
try:
jf = Path(folder_paths.get_org_output_directory(org)) / "render_jobs.json"
jobs = json.loads(jf.read_text())
except Exception:
return False
keep = [j for j in jobs if j.get("id") != pid]
if len(keep) == len(jobs):
return False
tmp = jf.with_name(jf.name + ".tmp")
tmp.write_text(json.dumps(keep))
tmp.replace(jf)
return True
def _owns_job(server, org: str, pid: str) -> bool:
"""Does this prompt_id belong to the caller's org? True if it's in the org's
work log, or a local-queue item stamped with this org, or the org's BYO-GPU
tracking file. A cross-tenant id matches none of these → the route 404s and
never reveals that it exists for someone else."""
if any(r.get("id") == pid for r in worklog.load(org)):
return True
try:
running, pending = server.prompt_queue.get_current_queue_volatile()
if any(it[1] == pid and (it[3] or {}).get("org_id") == org
for it in list(running) + list(pending)):
return True
except Exception:
pass
return _in_render_jobs(org, pid)
def _cancel_job(server, org: str, pid: str) -> str:
"""Cancel an OWNED job across both realms (caller must have checked ownership).
Local pod queue: delete a pending item, or interrupt a running one — both
org-scoped so one tenant can't touch another's render. BYO-GPU: drop the
tracking row (a queued gpu-job stops showing; one already rendering on the
worker may still finish — the pod can't stop a remote process). Marks the work
log cancelled. Returns the realm acted on."""
realm = "gone"
try:
running, pending = server.prompt_queue.get_current_queue_volatile()
except Exception:
running, pending = [], []
if any(it[1] == pid and (it[3] or {}).get("org_id") == org for it in pending):
server.prompt_queue.delete_queue_item(
lambda it: it[1] == pid and (it[3] or {}).get("org_id") == org)
realm = "local"
elif any(it[1] == pid and (it[3] or {}).get("org_id") == org for it in running):
try:
import nodes
nodes.interrupt_processing()
realm = "local"
except Exception:
pass
if _remove_render_job(org, pid) and realm == "gone":
realm = "gpu"
worklog.set_status(org, pid, "cancelled")
return realm
def _landed_output(root: Path | None, prefix: str) -> str | None:
"""Library-relative path of the newest file a SaveImage prefix produced, or None
if it hasn't landed yet. Lets the work-log report done + wire lineage edges."""
if root is None or not prefix:
return None
try:
parent = root / os.path.dirname(prefix)
stem = os.path.basename(prefix)
# Match a saver's exact "<prefix>_<counter>_.<ext>" (png / mp4 / webm) — NOT a
# loose "<prefix>*" which also matches a CHILD prefix ("a" would swallow
# "a_00001__00001_"), collapsing distinct lineage steps onto one output.
pat = re.compile(r"^" + re.escape(stem) + r"_\d+_\.(?:png|mp4|webm)$")
hits = [p for p in parent.glob(stem + "_*_.*") if pat.match(p.name)]
hits.sort(key=lambda p: p.stat().st_mtime, reverse=True)
if hits:
return str(hits[0].relative_to(root))
except Exception:
pass
return None
def _fix_source_ref(out_dir: Path, subfolder: str, name: str) -> str | None:
"""The source ref staged for the fix output <subfolder>/<name>, read from the
render-jobs record (outPrefix → refs[0]) gpu_dispatch writes on dispatch. None
if the output is not a tracked fix or the record is gone."""
try:
jobs = json.loads((out_dir / "render_jobs.json").read_text())
except Exception:
return None
rel = f"{subfolder}/{name}" if subfolder else name
for j in jobs:
op, refs = j.get("outPrefix") or "", j.get("refs") or []
if op and refs and rel.startswith(op):
return refs[0]
return None
def colormatch_fix_ingest(request, subfolder: str, name: str) -> None:
"""When a BYO-GPU worker uploads a finished FIX render, restore its global colour
to the source — undoing the white-balance/exposure/saturation drift a full-frame
Qwen edit (denoise 1.0) introduces, and its fix-of-a-fix compounding. Scoped to
``fixes/…`` outputs only (never compose/normal renders) whose staged source ref
is still present; a render that did not drift is matched to itself = untouched.
Fail-safe: never raises, and leaves the file exactly as uploaded on any doubt."""
try:
sub, nm = subfolder or "", name or ""
if "fixes" not in sub and "fixes/" not in nm:
return
org = _org_of(request)
out_dir = Path(folder_paths.get_org_output_directory(org))
src_ref = _fix_source_ref(out_dir, sub, nm)
if not src_ref:
return
src_path = Path(folder_paths.get_org_input_directory(org)) / src_ref
from middleware import color_fidelity
color_fidelity.match_in_place(str(out_dir / sub / nm), str(src_path))
except Exception:
pass
# ── Dispatch idempotency: collapse rapid identical re-submits (a double-fired button,
# an unguarded caller, a retried request) onto ONE render instead of billing a second
# GPU job — the single biggest source of "the same image generated over and over".
# Keyed by (org, output prefix, instruction, refs); in-memory is enough because the
# duplicates arrive within seconds on the same pod. ─────────────────────────────────
_RECENT_DISPATCH: dict = {} # key -> (ts, prompt_id)
_DEDUP_WINDOW_S = 60
def _dedup_key(org: str, kind: str, prompt: str, refs: list, uploads: list) -> str:
# Identify a render by its STABLE inputs — NOT output_prefix, which every lane
# stamps with a random _job_tag() before dispatch (so keying on it made two
# identical submits always miss → the anti-double-bill guard never fired). This
# keys on what actually defines the render: org, lane, prompt, and its inputs.
import hashlib
raw = "|".join([org, kind or "", (prompt or "").strip(),
",".join(sorted(refs or [])), ",".join(sorted(uploads or []))])
return hashlib.md5(raw.encode()).hexdigest()
def _recent_dispatch(key: str) -> str | None:
now = time.time()
for k, (ts, _pid) in list(_RECENT_DISPATCH.items()): # prune so the map stays bounded
if now - ts > _DEDUP_WINDOW_S:
_RECENT_DISPATCH.pop(k, None)
hit = _RECENT_DISPATCH.get(key)
return hit[1] if hit and hit[1] and now - hit[0] < _DEDUP_WINDOW_S else None
async def _dispatch(request, server, org: str, graph: dict, *, kind: str, prompt: str,
refs: list, uploads: list, parents: list, output_prefix: str,
lessons: list | None = None) -> str:
"""The ONE place a render is queued AND recorded. On success writes a `queued`
work-log row (lane detected); on failure writes a `failed` row with the reason —
so a dispatch is NEVER invisible, whatever lane it took or however it failed.
Idempotent: an identical dispatch within the dedup window returns the in-flight
prompt_id instead of billing a second render."""
params = _graph_params(graph)
if lessons:
params["lessons"] = lessons # what the user's notes taught THIS job to avoid
common = dict(kind=kind, prompt=prompt, refs=refs, uploads=uploads,
parents=parents, output_prefix=output_prefix, params=params)
key = _dedup_key(org, kind, prompt, refs, uploads)
dup = _recent_dispatch(key)
if dup:
return dup # identical render already in flight — reuse it
try:
pid = await _queue_prompt(request, server, graph)
except DispatchError as e:
worklog.record(org, status="failed", error=str(e), **common)
raise
_RECENT_DISPATCH[key] = (time.time(), pid)
lane = "gpu" if _in_render_jobs(org, pid) else "local"
node = _render_job_node(org, pid) if lane == "gpu" else "studio pod"
worklog.record(org, pid=pid, status="queued", lane=lane, node=node, **common)
_trace("dispatch.queued", org=org, pid=pid, kind=kind, lane=lane, node=node,
refs=len(refs), parents=",".join(parents) or None, prefix=output_prefix)
return pid
async def dispatch_fix(request: web.Request, server, org: str, rel: str,
instruction: str, upload: str | None = None,
stack: str | None = None, strength=None, seed=None,
mask: str | None = None) -> dict:
"""The EDIT action — a LOW-denoise Qwen edit of an already-generated gallery asset.
The base is EITHER a library asset (``rel``, its stable path — the generated output
itself) OR a just-uploaded image (``upload``). The selected generated image is the
SOURCE: it is VAEEncode'd and used as the KSampler latent (never EmptyLatentImage),
at ``strength``→denoise (default 0.25, edit band [0.05,0.95] — a true 1.0 regenerate
is a SEPARATE action). ``seed`` reuses the source's ORIGINAL seed by default (stable
iteration); a caller may vary it and still edit the source. ``mask`` (an uploaded
painted image, white=edit-here) switches to the masked-inpaint graph that keeps
pixels OUTSIDE the mask byte-identical. The output is a CHILD version of the source
(parents=[rel]). The ONE edit path: /v1/library/fix + the chat `create` capability."""
instruction = (instruction or "").strip()
if not 3 <= len(instruction) <= 500:
raise DispatchError("instruction required (3-500 chars)")
denoise = _edit_denoise(strength)
src_seed = None # the source's original seed, reused by default
in_dir = Path(folder_paths.get_org_input_directory(org))
in_dir.mkdir(parents=True, exist_ok=True)
note = None
if upload:
names = _resolve_uploads(org, [upload])
if not names:
raise DispatchError(f"unknown upload: {upload}")
ref_name = names[0]
stem = re.sub(r"[^a-zA-Z0-9_-]", "_", Path(ref_name).stem)[:48]
else:
root = _library_root(org)
# Iteration hygiene: if the chosen base is corrupt (flagged/deleted), fix from
# its nearest clean ancestor instead — never compound a known-bad version.
rel, substituted = _clean_base(org, root, rel)
if substituted:
note = "Fixing from the original — this version is corrupted"
p = _safe_asset(root, rel) if root else None
if p is None or p.suffix.lower() != ".png":
raise DispatchError(f"unknown asset: {rel}")
# FULL-RES source (the real output file, never the .thumbs/ preview), staged under
# a content-addressed name. Reuse the source's ORIGINAL seed from its embedded graph.
ref_name = _stage_source(in_dir, p, "fix")
stem = re.sub(r"[^a-zA-Z0-9_-]", "_", p.stem)[:48]
try:
sp = _provenance(p).get("seed")
src_seed = int(sp) if sp is not None else None
except (TypeError, ValueError):
src_seed = None
# Seed: an explicit caller seed wins; else reuse the source's original (stable
# iteration); else the graph mints one. A varied seed STILL edits the source (the
# VAEEncode latent is the KSampler input regardless of seed).
try:
eff_seed = int(seed) if seed is not None else src_seed
except (TypeError, ValueError):
eff_seed = src_seed
# Masked edit (Edit selected area): stage the painted mask like the source.
mask_name = None
if mask:
mnames = _resolve_uploads(org, [mask])
if not mnames:
raise DispatchError(f"unknown mask: {mask}")
mask_name = mnames[0]
stem = f"{stem}_{_job_tag()}"
lessons = _lessons(org, _library_root(org), [] if upload else [rel])
if mask_name:
graph = _inpaint_graph(ref_name, mask_name, instruction, f"fixes/{stem}",
denoise=denoise, seed=eff_seed, lessons=lessons)
kind = "edit_area"
else:
graph = _fix_graph(ref_name, instruction, f"fixes/{stem}",
denoise=denoise, seed=eff_seed, lessons=lessons)
kind = "fix"
# INVARIANT: an Edit dispatch can NEVER be a text-to-image regenerate.
bad = _assert_edit_graph(graph)
if bad:
raise DispatchError(f"edit rejected: {bad}")
_trace("edit.build", org=org, op=kind, source=(None if upload else rel),
ref=ref_name, denoise=denoise, seed=eff_seed,
prefix=f"fixes/{stem}", lessons=len(lessons))
pid = await _dispatch(request, server, org, graph,
lessons=lessons, kind=kind, prompt=instruction,
refs=[ref_name] + ([mask_name] if mask_name else []),
uploads=[x for x in (upload, mask) if x],
parents=[] if upload else [rel],
output_prefix=f"fixes/{stem}")
if stack:
stacks_store.route_prefix(org, f"fixes/{stem}", stack, added_by=_owner_of(request))
out = {"ok": True, "prompt_id": pid, "output_prefix": f"fixes/{stem}",
"operation": kind, "denoise": denoise, "seed": eff_seed, "parent": None if upload else rel}
if note:
out["note"] = note
return out
async def dispatch_video(request: web.Request, server, org: str, prompt: str,
rel: str | None = None, upload: str | None = None,
width: int | None = None, height: int | None = None,
length: int | None = None, seed: int | None = None,
stack: str | None = None) -> dict:
"""Generate a video from a text prompt and AT MOST ONE reference image — the proven
Wan 2.2 TI2V-5B graph. The reference is EITHER a library asset (``rel``, staged into the
org input dir like fix) OR an image just uploaded via ``/upload/image`` (``upload``);
with neither it is pure text-to-video. The ONE video path: the /v1/library/video route
(and, later, the chat `create` capability) both call this.
No lane-forcing here — _dispatch's self-POST already routes to gpu-jobs when a BYO GPU is
online and surfaces the real DispatchError ("unet_name … not in []" / "no GPU worker")
when not, so the model-locality reality (wan2.2 lives on spark) rides the existing
advisory rather than a second gate."""
prompt = (prompt or "").strip()
if not 3 <= len(prompt) <= 500:
raise DispatchError("prompt required (3-500 chars)")
if upload and rel:
raise DispatchError("use at most one reference (a library image OR an upload)")
root = _library_root(org)
in_dir = Path(folder_paths.get_org_input_directory(org))
in_dir.mkdir(parents=True, exist_ok=True)
ref_name = None
staged = False
if upload:
names = _resolve_uploads(org, [upload])
if not names:
raise DispatchError(f"unknown upload: {upload}")
ref_name = names[0]
elif rel:
# Iteration hygiene, same as fix: a corrupt base falls back to its clean ancestor.
rel, _sub = _clean_base(org, root, rel)
p = _safe_asset(root, rel) if root else None
if p is None or p.suffix.lower() != ".png":
raise DispatchError(f"unknown asset: {rel}")
ref_name = _stage_source(in_dir, p, "video")
staged = True
base = Path(ref_name).stem if ref_name else " ".join(prompt.split()[:8])
stem = re.sub(r"[^a-zA-Z0-9_-]", "_", base)[:48] + f"_{_job_tag()}"
lessons = _lessons(org, root, [rel] if rel else [])
pid = await _dispatch(
request, server, org,
_video_graph(prompt, f"video/{stem}", ref_name=ref_name,
width=width or 640, height=height or 640, length=length or 73,
seed=seed, lessons=lessons),
lessons=lessons, kind="video", prompt=prompt,
refs=[ref_name] if staged else [], uploads=[upload] if upload else [],
parents=[rel] if rel else [], output_prefix=f"video/{stem}")
if stack:
stacks_store.route_prefix(org, f"video/{stem}", stack, added_by=_owner_of(request))
return {"ok": True, "prompt_id": pid, "output_prefix": f"video/{stem}"}
async def dispatch_image(request: web.Request, server, org: str, prompt: str,
width: int | None = None, height: int | None = None,
seed: int | None = None, stack: str | None = None) -> dict:
"""Generate an IMAGE from a text prompt — the proven Z-Image-Turbo text-to-image graph.
This is the composer's default "What should we create?" lane: no reference image (that
is fix/compose), pure text-to-image through the ONE dispatch funnel. Org is the
bearer-token claim (IAM middleware) ONLY — an ``org`` field in the body/query is never
read. Like video/fix, a model-less pod surfaces the real DispatchError ("unet_name …
not in []" / "no GPU worker") rather than a silent nothing, so the composer can ALWAYS
show a queued/generating state or an honest error."""
prompt = (prompt or "").strip()
if not 3 <= len(prompt) <= 2000:
raise DispatchError("describe what to create (3-2000 chars)")
root = _library_root(org)
stem = re.sub(r"[^a-zA-Z0-9_-]", "_", " ".join(prompt.split()[:8]))[:48] + f"_{_job_tag()}"
prefix = f"image/{stem}"
lessons = _lessons(org, root, [])
pid = await _dispatch(
request, server, org,
_image_graph(prompt, prefix, width=width or 1024, height=height or 1024,
seed=seed, lessons=lessons),
lessons=lessons, kind="image", prompt=prompt,
refs=[], uploads=[], parents=[], output_prefix=prefix)
if stack:
stacks_store.route_prefix(org, prefix, stack, added_by=_owner_of(request))
return {"ok": True, "prompt_id": pid, "output_prefix": prefix}
async def dispatch_model3d(request: web.Request, server, org: str,
rel: str | None = None, upload: str | None = None,
seed: int | None = None, stack: str | None = None) -> dict:
"""Generate a 3D mesh (.glb) from ONE reference image — the proven Hunyuan3D-2.1 graph.
The reference is EITHER a library asset (``rel``) OR an upload (``upload``); one is
REQUIRED (no text-to-3D). Same dispatch/gpu-jobs/flywheel path as fix/video; the model
(hunyuan_3d_v2.1) lives on the worker, so model-locality rides the existing advisory."""
if not rel and not upload:
raise DispatchError("a reference image is required for 3D")
if upload and rel:
raise DispatchError("use at most one reference (a library image OR an upload)")
root = _library_root(org)
in_dir = Path(folder_paths.get_org_input_directory(org))
in_dir.mkdir(parents=True, exist_ok=True)
staged = False
if upload:
names = _resolve_uploads(org, [upload])
if not names:
raise DispatchError(f"unknown upload: {upload}")
ref_name = names[0]
else:
rel, _sub = _clean_base(org, root, rel)
p = _safe_asset(root, rel) if root else None
if p is None or p.suffix.lower() != ".png":
raise DispatchError(f"unknown asset: {rel}")
# Hunyuan3D encodes the image at ~518px internally, so a 4k catalog master is
# pure waste — and inlining a ~20MB PNG to the worker overruns the gpu-jobs
# enqueue (the "GPU worker momentarily unavailable" fall-through). Cap the
# staged copy at 1536px; the mesh is identical, the dispatch is reliable.
ref_name = _stage_source(in_dir, p, "model3d", max_px=1536)
staged = True
stem = re.sub(r"[^a-zA-Z0-9_-]", "_", Path(ref_name).stem)[:48] + f"_{_job_tag()}"
pid = await _dispatch(
request, server, org, _model3d_graph(ref_name, f"model3d/{stem}", seed=seed),
kind="model3d", prompt="3D mesh from " + (rel or upload or "image"),
refs=[ref_name] if staged else [], uploads=[upload] if upload else [],
parents=[rel] if rel else [], output_prefix=f"model3d/{stem}")
if stack:
stacks_store.route_prefix(org, f"model3d/{stem}", stack, added_by=_owner_of(request))
return {"ok": True, "prompt_id": pid, "output_prefix": f"model3d/{stem}"}
async def dispatch_compose(request: web.Request, server, org: str, paths: list,
instruction: str, uploads: list | None = None,
stack: str | None = None) -> dict:
"""Stage 2-5 references and run the multi-input Qwen graph. References are
library assets (``paths``, staged into the org input dir) and/or images the
caller just uploaded via ``/upload/image`` (``uploads``, already resident in
that dir). Shared by the /v1/library/compose route, the Fix surface's
add-reference flow, and the chat `create` capability."""
instruction = (instruction or "").strip()
if not 3 <= len(instruction) <= 500:
raise DispatchError("instruction required (3-500 chars)")
paths = paths or []
uploaded = _resolve_uploads(org, uploads)
if not 2 <= len(paths) + len(uploaded) <= 5:
raise DispatchError("select 2-5 references to compose")
root = _library_root(org)
in_dir = Path(folder_paths.get_org_input_directory(org))
in_dir.mkdir(parents=True, exist_ok=True)
ref_names = []
for rel in paths:
p = _safe_asset(root, rel) if root else None
if p is None or p.suffix.lower() != ".png":
raise DispatchError(f"unknown asset: {rel}")
nm = _stage_source(in_dir, p, "compose")
ref_names.append(nm)
ref_names.extend(uploaded)
labels = [Path(x).stem for x in paths] + [Path(u).stem for u in uploaded]
stem = "compose_" + "_".join(re.sub(r"[^a-zA-Z0-9]", "", s)[:12] for s in labels[:2]) + f"_{_job_tag()}"
lessons = _lessons(org, root, list(paths))
pid = await _dispatch(request, server, org, _compose_graph(ref_names, instruction, f"composes/{stem}", lessons=lessons),
lessons=lessons, kind="compose", prompt=instruction, refs=ref_names,
uploads=uploaded, parents=list(paths), output_prefix=f"composes/{stem}")
if stack:
stacks_store.route_prefix(org, f"composes/{stem}", stack, added_by=_owner_of(request))
return {"ok": True, "prompt_id": pid, "output_prefix": f"composes/{stem}", "refs": len(ref_names)}
class NotOwned(DispatchError):
"""The prompt_id isn't the caller's — routes map this to 404 (never reveals it)."""
async def dispatch_amend(request: web.Request, server, org: str, prompt_id: str,
instruction: str, paths: list | None = None,
uploads: list | None = None, stack: str | None = None) -> dict:
"""Amend a still-queued job as an honest ATOMIC SWAP: queued graphs are
immutable, so cancel the original and re-dispatch with the amended prompt and an
augmented reference set. The original's references (already staged in the org
input dir, read from its work-log row — the authoritative ownership record) are
kept and combined with any new library picks (``paths``) and uploads
(``uploads``). The re-dispatch goes through the ONE funnel, so it lands in the
right lane and records a fresh work-log row. Position resets (it re-enters the
queue) — the caller SHOWS that honestly."""
instruction = (instruction or "").strip()
if not 3 <= len(instruction) <= 500:
raise DispatchError("instruction required (3-500 chars)")
orig = next((r for r in worklog.load(org) if r.get("id") == prompt_id), None)
if orig is None:
raise NotOwned(f"no such job: {prompt_id}")
if orig.get("status") in ("done", "failed", "cancelled"):
raise DispatchError("this job already finished — start a new fix instead")
root = _library_root(org)
in_dir = Path(folder_paths.get_org_input_directory(org))
in_dir.mkdir(parents=True, exist_ok=True)
# keep the original references that are still present (already input-dir names)
ref_names = _resolve_uploads(org, orig.get("refs") or [])
new_uploads = _resolve_uploads(org, uploads)
for rel in (paths or []):
p = _safe_asset(root, rel) if root else None
if p is None or p.suffix.lower() != ".png":
raise DispatchError(f"unknown asset: {rel}")
nm = _stage_source(in_dir, p, "amend")
ref_names.append(nm)
# de-dupe, keep order, cap at the graph's 5
seen: set = set()
refs = [n for n in ref_names + new_uploads if not (n in seen or seen.add(n))][:5]
if not refs:
raise DispatchError("no reference images available to amend")
_cancel_job(server, org, prompt_id)
parents = list(orig.get("parents") or []) + list(paths or [])
stem = re.sub(r"[^a-zA-Z0-9_-]", "_", Path(refs[0]).stem)[:40] + f"_amended_{_job_tag()}"
lessons = _lessons(org, _library_root(org), parents)
if len(refs) == 1:
size = _repose_size(in_dir / refs[0]) if _REPOSE.search(instruction) else None
graph, kind, out = _fix_graph(refs[0], instruction, f"fixes/{stem}", size=size, lessons=lessons), "fix", f"fixes/{stem}"
else:
graph, kind, out = _compose_graph(refs, instruction, f"composes/{stem}", lessons=lessons), "compose", f"composes/{stem}"
all_uploads = list(dict.fromkeys(list(orig.get("uploads") or []) + new_uploads))
pid = await _dispatch(request, server, org, graph, lessons=lessons, kind=kind, prompt=instruction,
refs=refs, uploads=all_uploads, parents=parents, output_prefix=out)
if stack:
stacks_store.route_prefix(org, out, stack, added_by=_owner_of(request))
return {"ok": True, "prompt_id": pid, "amended_from": prompt_id,
"output_prefix": out, "refs": len(refs)}
def _short_prompt(prompt: str) -> str:
"""A few words of a prompt → a readable auto-stack name stem."""
words = re.findall(r"[A-Za-z0-9']+", prompt or "")
return (" ".join(words[:6])[:48]).strip() or "Batch"
def _dest_stack(org: str, stack, *, prompt: str, multi: bool, owner: str, workspace) -> str | None:
"""Resolve where a dispatch's outputs are organized. An explicit Stack id (the
'Save outputs to → Existing/New Stack' choice, the client having created a New
Stack first) wins. Otherwise, when the org's 'auto-stack batch generations'
setting is on AND the job is multi-output, mint a '<short prompt> — <date>'
Stack. Returns the Stack id, or None (Main Gallery / single output / setting off)."""
sid = (stack or "").strip() if isinstance(stack, str) else None
if sid:
return sid
if multi and stacks_store.get_setting(org, "auto_stack_batches", False):
name = f"{_short_prompt(prompt)}{time.strftime('%Y-%m-%d')}"
return stacks_store.create(org, name, [], owner=owner, workspace=workspace)["id"]
return None
def _graph_is_batch(graph: dict) -> bool:
"""A graph whose latent asks for more than one image — a multi-output job."""
for n in (graph or {}).values():
if isinstance(n, dict) and int(n.get("inputs", {}).get("batch_size", 1) or 1) > 1:
return True
return False
def _workflows(org: str) -> list[str]:
"""Saved workflow files for the org (any user), for the Pipelines tab."""
base = Path(folder_paths.get_user_directory())
roots = [base / "orgs" / org / "user", base / "default"]
seen: set[str] = set()
for root in roots:
if not root.is_dir():
continue
for wf in root.rglob("workflows/*/*.json"):
seen.add(str(wf).split("workflows/", 1)[1])
for wf in root.rglob("workflows/*.json"):
seen.add(str(wf).split("workflows/", 1)[1])
return sorted(seen)
@web.middleware
async def home_redirect(request: web.Request, handler):
"""Deployed default: land on the content view. The editor stays one click
away (?advanced=1) and every non-root path is untouched."""
if (_home_default() and request.path == "/" and request.method == "GET"
and "advanced" not in request.query
and "text/html" in request.headers.get("Accept", "")):
raise web.HTTPFound("/studio")
return await handler(request)
# ── Shared shell: the ONE chrome (Studio/Editor toggle, wallet, GPUs, chat
# sidebar) loaded by BOTH views. The Studio home <script>-includes it; the Editor
# gets it injected into the engine's index.html by server.get_root. ──────────────
_SHELL_HEAD = '<link rel="stylesheet" href="/studio/shell.css">'
_SHELL_BODY = '<script src="/studio/shell.js" defer></script>'
# Client chat config, injected as window.HZ before the shell loads. `pk` is a
# PUBLISHABLE key (safe in the browser, scoped to chat completions) — the shell
# streams enso from api.hanzo.ai/v1/chat/completions with it. When it is absent the
# Chat sidebar falls back to the secure same-origin copilot path (httpOnly cookie),
# so no key is exposed. NEVER a server secret.
_PUBLISHABLE_KEY_ENV = ("NEXT_PUBLIC_HANZO_PUBLISHABLE_KEY", "STUDIO_PUBLISHABLE_KEY")
_PUBLISHABLE_PREFIX = "pk-" # gateway iamauth.go: the ONE client-safe key family
def _publishable(pk: str) -> str:
"""Fail CLOSED via allowlist: hand the browser a value ONLY when it is a
publishable key (`pk-`, the single client-safe prefix the gateway defines).
Every server secret (`hk-`/`sk-`/`fw_`/`hz_`), JWT, or unknown value is dropped
so a misconfigured secret can never reach page source; Chat then falls back to
the secure same-origin copilot path."""
pk = pk.strip()
return pk if pk.startswith(_PUBLISHABLE_PREFIX) else ""
def _hz_config_script() -> str:
"""`<script>window.HZ={...}</script>` — JSON-encoded so no value can break out
of the tag. Reads the publishable key from env; empty when unset."""
pk = ""
for key in _PUBLISHABLE_KEY_ENV:
pk = _publishable(os.environ.get(key, ""))
if pk:
break
cfg = {"aiBase": (os.environ.get("STUDIO_AI_BASE", "").strip() or "https://api.hanzo.ai").rstrip("/"),
"pk": pk,
"model": os.environ.get("STUDIO_CHAT_MODEL", "").strip() or "enso"}
# json.dumps escapes quotes/backslashes and (ensure_ascii) U+2028/2029; also
# neutralize any literal "</" so no value can close the <script> tag.
return "<script>window.HZ=" + json.dumps(cfg).replace("</", "<\\/") + ";</script>"
def _asset(name: str, ctype: str) -> web.Response:
"""Serve a shell asset. no-store like the SPA HTML — it changes every release."""
return web.Response(text=(_ASSET_DIR / name).read_text(), content_type=ctype,
headers={"Cache-Control": "no-store, must-revalidate"})
def editor_index(web_root: str) -> web.Response:
"""The engine's index.html with the shared shell injected — the ONE mechanism
the Editor picks up the Studio/Editor toggle, wallet, GPUs and chat sidebar, the
same chrome the Studio home includes. Idempotent; no-cache like the upstream
root handler (the frontend changes every release)."""
html = Path(web_root, "index.html").read_text()
# Rebrand the upstream editor frontend → Hanzo Studio. The Editor is a first-party
# surface, so the upstream name stays out of the UI (the ComfyUI-format compat is
# internal only). Cheap, idempotent string swaps on the served index (tab title +
# the loading status a screen reader announces).
html = html.replace("<title>ComfyUI</title>", "<title>Hanzo Studio</title>")
html = html.replace("Loading ComfyUI...", "Loading Hanzo Studio…")
if "/studio/shell.js" not in html:
body = _hz_config_script() + _SHELL_BODY
html = html.replace("</head>", _SHELL_HEAD + "</head>", 1) if "</head>" in html else _SHELL_HEAD + html
html = html.replace("</body>", body + "</body>", 1) if "</body>" in html else html + body
return web.Response(text=html, content_type="text/html",
headers={"Cache-Control": "no-cache", "Pragma": "no-cache", "Expires": "0"})
def _templates_root(org: str) -> Path:
"""orgs/<org>/templates — a per-org sibling of the output tree that holds
library.json/worklog.json, so templates are tenant-scoped exactly like every
other per-org file. Created on demand."""
out = Path(folder_paths.get_org_output_directory(org))
base = out.parent if out.parent.name == org else out
d = base / "templates"
d.mkdir(parents=True, exist_ok=True)
return d
def _template_slug(name: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", (name or "").lower()).strip("-")[:60]
def _template_api_prompt(graph) -> dict | None:
"""The runnable API prompt inside a stored template graph. A graph saved from the
Editor is ``{workflow, output}`` (output = API prompt); one captured from an
output PNG is a bare API prompt (numeric ids -> {class_type, inputs}). A UI-only
graph has neither and can't be queued headless — returns None so the caller says
so instead of dispatching nothing."""
if not isinstance(graph, dict) or not graph:
return None
out = graph.get("output")
if isinstance(out, dict) and out:
return out
if all(isinstance(v, dict) and "class_type" in v for v in graph.values()):
return graph
return None
async def _finance_balance(tok: str) -> dict | None:
"""Raw cloud finance balance for the caller's IAM token, or None on any failure.
The token scopes the org server-side (cloud pins org from the verified owner
claim), so this read is inherently tenant-isolated."""
from middleware.gpu_dispatch import CLOUD
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as s:
async with s.get(f"{CLOUD}/v1/finance/balance", headers={"Authorization": tok}) as r:
return await r.json() if r.status == 200 else None
except Exception:
return None
async def _wallet(request: web.Request, org: str) -> dict:
"""{org, balance, currency} for the wallet chip — a display-only proxy. Balance
is the spendable dollar amount (availableCents/100); None when signed-out or the
cloud is unreachable (the chip shows '—', never a fabricated number)."""
from middleware.gpu_dispatch import _bearer
out = {"org": org, "balance": None, "currency": None}
tok = _bearer(request)
if not tok:
return out
b = await _finance_balance(tok)
if isinstance(b, dict):
cents = b.get("availableCents")
if isinstance(cents, (int, float)):
out["balance"] = round(cents / 100, 2)
out["currency"] = b.get("currency") or "usd"
return out
_MAX_UPLOAD = 256 * 1024 * 1024 # 256 MB — ONE ceiling, equal to the worker mirror cap (gpu.go outputMax); holds a 4k master OR a short render's video
_UPLOAD_EXT = (".png", ".jpg", ".jpeg", ".webp", ".mp4", ".webm", ".glb")
# Explicit types for served assets: the runtime's mimetypes table lacks webp, and
# octet-stream + nosniff breaks <img>/<video>/<model-viewer> rendering of references.
_CTYPE = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp",
".mp4": "video/mp4", ".webm": "video/webm", ".glb": "model/gltf-binary"}
_SUBPATH_RE = re.compile(r"[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*")
def _safe_subpath(raw: str) -> str | None:
"""The library subfolder an upload lands in — allowlist chars only (no traversal,
no absolute), default ``renders``. Returns None for anything outside the allowlist
so the route can reject it."""
sub = (raw or "").strip("/") or "renders"
return sub if _SUBPATH_RE.fullmatch(sub) else None
def _row_output(root: Path | None, output_prefix: str | None) -> str | None:
"""The library-relative output for a work-log row: the file a SaveImage prefix
produced, or — for an INGESTED render whose output_prefix is already the stored
file's path — that path itself. Lets a mirrored render show its thumbnail and open
its graph, without a SaveImage-style counter suffix."""
landed = _landed_output(root, output_prefix)
if landed is None and root is not None and output_prefix and _safe_asset(root, output_prefix) is not None:
landed = output_prefix
return landed
# ── Honesty guard + iteration hygiene ────────────────────────────────────────────
# A >=10-step 1024x1536 Qwen edit takes MINUTES on any real GPU (≈350s on the GB10).
# If an output lands within this many seconds of the job being dispatched, the engine
# did not sample — a graph that errored straight into history, a stale-history reuse,
# a degenerate self-POST on a model-less pod. Such a result is marked FAILED, never
# delivered. The bound is a queued→done wall clock: `ts` (dispatch) and the output's
# mtime are the two timestamps we measure reliably (started_at is a lazy display
# stamp set by the poller, so a real render observed late must never read as fast).
_IMPLAUSIBLE_SECS = 5
def _implausibly_fast(row: dict, finished_at: int) -> bool:
"""True when a completion cannot have sampled for its step count. Sound: a genuine
>=10-step render's dispatch→landed wall always far exceeds the bound, so a real
result is never failed; a sub-bound wall proves no sampling ran. steps<10 or an
unknown step count is exempt (nothing to contradict)."""
steps = (row.get("params") or {}).get("steps")
if not isinstance(steps, (int, float)) or steps < 10:
return False
ts = row.get("ts")
if not isinstance(ts, (int, float)):
return False
return finished_at - ts < _IMPLAUSIBLE_SECS
def _finalize_landed(org: str, root: Path | None, rows: list[dict], now: int) -> None:
"""The ONE place a landed render becomes terminal, shared by the queue poller and
the work-log view. For each non-terminal row whose output has landed: close it
`done` at the file mtime — UNLESS it landed implausibly fast, then `failed` with
"engine did not sample" so a degenerate result never surfaces as a normal one. A
matched output OLDER than the dispatch is a pre-existing sibling (same prefix,
earlier fix), not this job's result — left pending until its own output lands.
Mutates rows in place (mirrors the persisted status)."""
for r in rows:
if r.get("status") in ("done", "failed", "cancelled"):
continue
landed = _landed_output(root, r.get("output_prefix"))
if not landed:
continue
mt = _output_mtime(root, landed) or now
if mt < int(r.get("ts", now)):
continue
if _implausibly_fast(r, mt):
worklog.set_status(org, r["id"], "failed",
note="completed implausibly fast — engine did not sample")
r["status"] = "failed"
else:
worklog.mark_finished(org, r["id"], mt)
r["status"], r["finished_at"] = "done", mt
def _resolve_outputs(org: str, root: Path | None, rows: list[dict], now: int) -> None:
"""Attach each row's OWN landed output — the ONE resolution every view and
lineage walk uses. Runs the terminal-state mechanism first; a row that is not
done gets no output, so a pending job can never claim an earlier run's file
(a rejected same-stem sibling) as its result or as a lineage version."""
_finalize_landed(org, root, rows, now)
for r in rows:
r["output"] = _row_output(root, r.get("output_prefix")) if r.get("status") == "done" else None
_CORRUPT = ("flagged", "deleted")
def _asset_status(root: Path | None, rel: str) -> str | None:
"""The library curation status of an asset path (draft/approved/flagged/…), or
None if it isn't in the library."""
if root is None:
return None
for a in _load_library(root).get("assets", []):
if a.get("path") == rel:
return a.get("status")
return None
def _clean_base(org: str, root: Path | None, rel: str) -> tuple[str, bool]:
"""Iteration hygiene, the ONE place the fix base is chosen: if ``rel`` is corrupt
(quality-gate ``flagged``, or ``deleted``), fix from the NEAREST clean ancestor in
its lineage instead — so a fix never compounds a known-bad version. Returns
``(path_to_fix_from, substituted)``; a clean base is returned unchanged."""
if root is None or _asset_status(root, rel) not in _CORRUPT:
return rel, False
rows = worklog.load(org)
_resolve_outputs(org, root, rows, int(time.time()))
ancestors = worklog.build_lineage(rows, rel).get("ancestors") or []
for anc in reversed(ancestors): # nearest parent first, walking back to the root
p = anc.get("path")
if p and _asset_status(root, p) not in _CORRUPT and _safe_asset(root, p) is not None:
return p, True
return rel, False
def _index_upload(org: str, root: Path, rel: str, *, design, kind, role, node) -> bool:
"""Event-driven index at the ingest point so a just-stored render is INSTANTLY
findable — in Assets (a library.json entry) and in Queue & History (a done
work-log row carrying its source node) — without waiting for the manifest sweeper.
Atomic per-org writes like every other metadata file; upserts by path so a
re-render of the same name never duplicates the asset. Returns True when a NEW
asset row was created (a newly-surfaced render) — the quality gate's trigger."""
now_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
lib = _load_library(root)
assets = lib.setdefault("assets", [])
ex = next((a for a in assets if a.get("path") == rel), None)
created = ex is None
if created:
assets.append({"path": rel, "design": design, "kind": kind, "role": role,
"status": "draft", "tags": [], "updatedAt": now_iso})
else:
ex.update(design=design, kind=kind, role=role, updatedAt=now_iso)
_save_library(root, lib)
# The worklog upserts by output too: re-ingesting identical bytes (an
# interrupted mirror, a restored store) must repair a missing row, never
# stack a second one.
if not worklog.has_output(org, rel):
worklog.record(org, kind="render", prompt="", refs=[], uploads=[], parents=[],
output_prefix=rel, node=node, status="done",
lane=("gpu" if node not in ("upload", "studio pod") else "local"),
pid="up-" + uuid.uuid4().hex[:12])
# If this render was dispatched with a "save outputs to Stack" destination, the
# landed output joins that Stack now — ALL outputs of a multi-output job do, so
# membership is assigned at the ONE ingest point (never in the source of truth).
stacks_store.absorb(org, rel)
return created
def _org_store(org: str, name: str) -> Path:
"""A per-org metadata file beside worklog.json/library.json (favorites, stacks).
The same tenant-scoped output tree every per-org file uses; created on demand so
the store works before the org has a library.json."""
d = Path(folder_paths.get_org_output_directory(org))
d.mkdir(parents=True, exist_ok=True)
return d / name
def _load_store(p: Path, default):
try:
return json.loads(p.read_text())
except Exception:
return default
def _save_store(p: Path, data) -> None:
"""Atomic tmp+replace, the ONE write pattern for per-org metadata."""
tmp = p.with_name(p.name + ".tmp")
tmp.write_text(json.dumps(data))
tmp.replace(p)
def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
# Stacks — Procreate-style gallery folders — are their own concern (per-org
# SQLite, the full REST surface), registered here through injected tenancy
# resolvers + the library soft-deleter so the module stays decoupled.
stacks_store.add_stacks_routes(
routes, server, org_of=_org_of, owner_of=_owner_of,
workspace_of=_workspace_of, soft_delete_assets=_soft_delete_assets)
# Office documents — the non-GPU pipeline (catalog + AI-fill + renderers) — are
# their own orthogonal subsystem, registered here through the same injected tenancy
# resolvers so the module stays decoupled (identical seam to stacks_store).
from middleware.documents import add_documents_routes
add_documents_routes(routes, server, org_of=_org_of, owner_of=_owner_of)
@routes.get("/studio")
async def studio_home(request: web.Request):
# no-store: the home HTML changes every release; without this, browsers
# (and any proxy) heuristically cache it and serve a STALE page after a
# deploy — which is why UI fixes appeared "not working" to a user still
# running yesterday's cached page. Always serve fresh; the page is small
# and its shell.js/shell.css keep their own cache.
html = _HTML.read_text().replace(_SHELL_BODY, _hz_config_script() + _SHELL_BODY, 1)
return web.Response(text=html, content_type="text/html",
headers={"Cache-Control": "no-store, must-revalidate"})
@routes.get("/studio/shell.js")
async def shell_js(request: web.Request):
return _asset("shell.js", "text/javascript")
@routes.get("/studio/shell.css")
async def shell_css(request: web.Request):
return _asset("shell.css", "text/css")
@routes.get("/v1/workflow")
async def get_workflow(request: web.Request):
"""The graph that produced a work-log item, for 'Open in Editor'. Tenant-
scoped: the id must be in THIS org's work log or it 404s (a cross-tenant id
is never confirmed). The graph is read from the item's output PNG, which
embeds the litegraph 'workflow' (UI) and/or the API 'prompt'."""
org = _org_of(request)
pid = request.query.get("id", "")
rows = worklog.load(org)
row = next((r for r in rows if r.get("id") == pid), None)
if row is None:
return web.json_response({"error": "not found"}, status=404)
root = _library_root(org)
# Same terminal-state mechanism as every other view; only a DONE row owns
# a landed output — a pending row must never open an older sibling's graph.
_finalize_landed(org, root, rows, int(time.time()))
landed = _row_output(root, row.get("output_prefix")) if row.get("status") == "done" else None
p = _safe_asset(root, landed) if (root and landed) else None
if p is None or p.suffix.lower() not in _SAVE_EXT:
return web.json_response({"error": "workflow not available yet"}, status=404)
info = _embedded(p) # PNG info OR video-container metadata — the ONE reader
for key, fmt in (("workflow", "ui"), ("prompt", "api")):
try:
g = json.loads(info.get(key, "null"))
except Exception:
g = None
if isinstance(g, dict) and g:
return web.json_response({"id": pid, "format": fmt, "graph": g})
return web.json_response({"error": "no embedded workflow in this output"}, status=422)
@routes.post("/v1/templates")
async def save_template(request: web.Request):
"""Save a workflow graph as an org template (orgs/<org>/templates/<slug>.json,
atomic tmp+replace like the work log). Server-resolved org — a template is
only ever written to, and read from, the caller's own tenant tree."""
try:
body = await request.json()
except Exception:
return web.json_response({"error": "invalid JSON"}, status=400)
name = (body.get("name") or "").strip()
graph = body.get("graph")
# A flow template captures a reusable generation FLOW (kind + instruction
# pattern + references) rather than a node graph — what "favorite → turn into
# a template" saves for a chat/run. Either a graph or a flow makes a template.
flow = body.get("flow")
slug = _template_slug(name)
if not slug:
return web.json_response({"error": "name required"}, status=400)
has_graph = isinstance(graph, dict) and bool(graph)
has_flow = isinstance(flow, dict) and bool(flow)
if not has_graph and not has_flow:
return web.json_response({"error": "graph or flow required"}, status=400)
rec = {"name": name, "slug": slug, "ts": int(time.time())}
if has_graph:
rec["graph"] = graph
if has_flow:
rec["flow"] = flow
d = _templates_root(_org_of(request))
tmp = d / (slug + ".json.tmp")
tmp.write_text(json.dumps(rec))
tmp.replace(d / (slug + ".json"))
return web.json_response({"ok": True, "name": name, "slug": slug})
@routes.get("/v1/templates")
async def list_templates(request: web.Request):
org = _org_of(request)
out = []
for f in sorted(_templates_root(org).glob("*.json")):
try:
rec = json.loads(f.read_text())
except Exception:
continue
out.append({"name": rec.get("name") or f.stem, "slug": rec.get("slug") or f.stem,
"ts": rec.get("ts", 0),
"renderable": _template_api_prompt(rec.get("graph")) is not None})
return web.json_response({"org": org, "templates": out})
@routes.post("/v1/templates/render")
async def render_template(request: web.Request):
"""Queue a saved template through the ONE dispatch funnel (reseeded); its
output lands in the library via the manifest sidecar — no new path. A
UI-only graph can't run headless, so it's rejected with a clear reason."""
try:
body = await request.json()
except Exception:
return web.json_response({"error": "invalid JSON"}, status=400)
org = _org_of(request)
slug = _template_slug(body.get("name", ""))
f = _templates_root(org) / (slug + ".json")
if not slug or not f.is_file():
return web.json_response({"error": "not found"}, status=404)
try:
rec = json.loads(f.read_text())
except Exception:
return web.json_response({"error": "corrupt template"}, status=422)
prompt = _template_api_prompt(rec.get("graph"))
if prompt is None:
return web.json_response(
{"error": "this template has no runnable graph — open it in the Editor to run"}, status=422)
out_prefix = ""
for n in prompt.values():
if isinstance(n, dict) and n.get("class_type") in _SAVERS:
out_prefix = n.get("inputs", {}).get("filename_prefix", "") or out_prefix
out_prefix = _stamp(out_prefix or f"templates/{slug}") # unique per render (image OR video)
sid = _dest_stack(org, body.get("stack"), prompt=rec.get("name", slug),
multi=_graph_is_batch(prompt), owner=_owner_of(request),
workspace=_workspace_of(request))
try:
g = _retag(_reseed(json.loads(json.dumps(prompt))), out_prefix)
pid = await _dispatch(request, server, org, g,
kind="template", prompt=rec.get("name", slug), refs=[], uploads=[],
parents=[], output_prefix=out_prefix)
except DispatchError as e:
return web.json_response({"error": str(e)}, status=400)
if sid:
stacks_store.route_prefix(org, out_prefix, sid, added_by=_owner_of(request))
return web.json_response({"ok": True, "prompt_id": pid, "slug": slug, "stack": sid})
@routes.get("/v1/templates/{name}")
async def get_template(request: web.Request):
org = _org_of(request)
slug = _template_slug(request.match_info.get("name", ""))
f = _templates_root(org) / (slug + ".json")
if not slug or not f.is_file():
return web.json_response({"error": "not found"}, status=404)
try:
return web.json_response(json.loads(f.read_text()))
except Exception:
return web.json_response({"error": "corrupt template"}, status=422)
@routes.get("/v1/wallet")
async def get_wallet(request: web.Request):
"""The org's wallet balance for the shell chip — a display-only proxy of the
user's IAM token to the cloud finance balance. Returns {org, balance,
currency}; top-up is a link to pay.hanzo.ai in the UI. No funds move here."""
org = _org_of(request)
return web.json_response(await _wallet(request, org))
@routes.post("/v1/agent")
async def agent_bridge(request: web.Request):
"""Same-origin bridge to the cloud agent orchestrator. The session cookie
stays host-only (never widened to *.hanzo.ai); this forwards the caller's
bearer server-side — the wallet pattern. Direct-to-gateway remains
available to the SPA via VITE_AGENT_API."""
from middleware.gpu_dispatch import CLOUD, _bearer
tok = _bearer(request)
if not tok:
return web.json_response({"error": "auth required"}, status=401)
body = await request.read()
if len(body) > 1 << 20:
return web.json_response({"error": "body too large"}, status=413)
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=180)) as s:
async with s.post(f"{CLOUD}/v1/agent", data=body,
headers={"Authorization": tok,
"Content-Type": "application/json"}) as r:
return web.Response(status=r.status, body=await r.read(),
content_type="application/json")
except Exception:
return web.json_response({"error": "agent unreachable"}, status=502)
@routes.get("/v1/library")
async def get_library(request: web.Request):
org = _org_of(request)
root = _library_root(org)
if root is None:
return web.json_response({"org": org, "assets": [], "workflows": _workflows(org),
"note": "no library.json for this org yet"})
lib = _load_library(root)
assets = []
for a in lib.get("assets", []):
p = _safe_asset(root, a.get("path", ""))
if p is None:
continue
row = dict(a)
row["view"] = _view_params(p)
row["mtime"] = int(p.stat().st_mtime)
suf = p.suffix.lower()
if suf == ".png" or suf in _VIDEO_EXT:
row["prov"] = _provenance(p)
assets.append(row)
return web.json_response({"org": org, "assets": assets, "workflows": _workflows(org)},
headers={"Cache-Control": "no-store"}) # mutable: a delete must never be masked by a cached copy
@routes.get("/v1/library/file")
async def get_asset_file(request: web.Request):
# Org-scoped asset serving. The Studio engine's core /view serves the
# GLOBAL output dir, so org assets (orgs/<org>/output/…) 404 there → blank
# thumbnails. This resolves the caller's org and serves from its library root.
# ?thumb=1 → a cached, downsized JPEG (grid loads full-res 1-2MB PNGs one by
# one otherwise, which makes the whole page crawl). Full-res on click/zoom.
org = _org_of(request)
root = _library_root(org)
# ?input=1 → a staged reference in orgs/<org>/input (fix/compose sources),
# so YouTube-style queue rows can show the referenced work as a thumbnail.
if request.query.get("input"):
in_dir = Path(folder_paths.get_org_input_directory(org))
name = os.path.basename(request.query.get("path", ""))
ip = in_dir / name
if not name or not ip.is_file() or not str(ip.resolve()).startswith(str(in_dir.resolve())):
return web.Response(status=404)
p = ip
else:
p = _safe_asset(root, request.query.get("path", "")) if root else None
if p is None:
return web.Response(status=404)
suf = p.suffix.lower()
if request.query.get("thumb") and suf in _REF_EXT:
try:
tdir = root / ".thumbs"
tdir.mkdir(exist_ok=True)
key = re.sub(r"[^a-zA-Z0-9]", "_", str(p))[-120:]
tp = tdir / f"{key}.webp"
if not tp.is_file() or tp.stat().st_mtime < p.stat().st_mtime:
from PIL import Image
im = Image.open(p)
im.thumbnail((480, 720)) # grid-sized; keeps aspect
# WEBP is ~30% smaller than JPEG at the same quality → less bandwidth.
im.convert("RGB").save(tp, "WEBP", quality=80, method=4)
# Content-Type MUST be set explicitly: aiohttp's FileResponse guesses
# from the extension via Python's mimetypes, which does NOT know .webp,
# so it fell back to application/octet-stream — which the browser refuses
# to render as an <img> (blank grid, "assets won't load").
# Long cache (the ?v=sha URL busts on change) but REVALIDATABLE — not
# `immutable`, which would lock a single bad response in the browser for a
# year with no way out but a hard-refresh. A week + revalidate keeps the
# repeat-load speed without the poisoned-cache trap.
return web.FileResponse(tp, headers={"Content-Type": "image/webp",
"Cache-Control": "public, max-age=604800, stale-while-revalidate=86400"})
except Exception:
pass # fall through to full-res on any thumbnail failure
elif request.query.get("thumb") and suf in _VIDEO_EXT:
# A video's poster is its first frame — the SAME cache scheme as images. On ANY
# failure return 404, NEVER the full file (falling through would serve the whole
# mp4 as an <img>). root is the tenant library here, so it is never None.
try:
if root is None:
return web.Response(status=404)
tdir = root / ".thumbs"
tdir.mkdir(exist_ok=True)
key = re.sub(r"[^a-zA-Z0-9]", "_", str(p))[-120:]
tp = tdir / f"{key}.jpg"
if not tp.is_file() or tp.stat().st_mtime < p.stat().st_mtime:
im = _poster(p)
im.thumbnail((480, 720))
im.convert("RGB").save(tp, "JPEG", quality=82)
return web.FileResponse(tp, headers={"Content-Type": "image/jpeg",
"Cache-Control": "public, max-age=86400"})
except Exception:
return web.Response(status=404)
elif request.query.get("thumb") and suf in _MODEL3D_EXT:
# A mesh has no raster frame; the card renders <model-viewer> instead. 404 so
# the thumb request never falls through and serves the whole .glb as an <img>.
return web.Response(status=404)
ct = _CTYPE.get(suf)
return web.FileResponse(p, headers={"Content-Type": ct} if ct else None)
@routes.post("/v1/library/upload")
async def library_upload(request: web.Request):
"""Tenant-scoped render ingest so EVERY render lands in the library — including
ones produced on a GPU node outside the job path (the node's mirror loop POSTs
here). Body is multipart (field ``image``/``file``) or raw bytes + ``?name=``,
with an optional ``?subpath=`` (default ``renders``). Writes
orgs/<org>/output/<subpath>/<name> atomically (tmp+replace, same as the work
log); rejects traversal (basename-only name + allowlisted subpath); caps size;
and skips a byte-identical existing file (name+size match → existed). The
manifest sidecar indexes output/ — no other wiring."""
org = _org_of(request)
sub = _safe_subpath(request.query.get("subpath", ""))
if sub is None:
return web.json_response({"error": "invalid subpath"}, status=400)
clen = request.content_length
if clen is not None and clen > _MAX_UPLOAD:
return web.json_response({"error": "file too large"}, status=413)
if request.headers.get("Content-Type", "").startswith("multipart/"):
post = await request.post()
f = post.get("image") or post.get("file")
if f is None or not hasattr(f, "file"):
return web.json_response({"error": "no image field"}, status=400)
name = os.path.basename(f.filename or "")
data = f.file.read()
else:
data = await request.read()
name = os.path.basename(request.query.get("name", ""))
if not name or os.path.splitext(name)[1].lower() not in _UPLOAD_EXT:
return web.json_response({"error": "file name required (.png/.jpg/.jpeg/.webp/.mp4/.webm)"}, status=400)
if name.startswith("."):
# `._foo.png` (AppleDouble fork) and other dotfiles pass the extension
# check but are not renders.
return web.json_response({"error": "hidden file"}, status=400)
# The name is rendered into gallery card HTML downstream, so <>"'& in it is
# a stored-XSS vector. Collapse to a safe charset at the boundary (keep the
# already-validated extension) — the pipeline's own names are safe already,
# so this only ever touches a hostile upload name.
_stem, _ext = os.path.splitext(name)
name = (re.sub(r"[^A-Za-z0-9._-]", "_", _stem)[:120] or "upload") + _ext.lower()
if not data:
return web.json_response({"error": "empty body"}, status=400)
if len(data) > _MAX_UPLOAD:
return web.json_response({"error": "file too large"}, status=413)
if not (data[:8] == b"\x89PNG\r\n\x1a\n"
or data[:3] == b"\xff\xd8\xff"
or (data[:4] == b"RIFF" and data[8:12] == b"WEBP")
or (len(data) > 12 and data[4:8] == b"ftyp") # mp4/mov: brand box at OFFSET 4
or data[:4] == b"\x1a\x45\xdf\xa3" # webm/mkv: EBML header
or data[:4] == b"glTF"): # glb: glTF binary magic
# The name lies sometimes; the bytes never do.
return web.json_response({"error": "not a supported media file"}, status=400)
out_root = Path(folder_paths.get_org_output_directory(org))
dest = (out_root / sub / name).resolve()
if not str(dest).startswith(str(out_root.resolve()) + os.sep):
return web.json_response({"error": "invalid path"}, status=400)
rel = f"{sub}/{name}"
# Identical bytes: skip the write but STILL index — files can exist without
# rows (an interrupted mirror, a restored store), and the index upsert is
# idempotent by path. Recovery depends on this.
existed = dest.is_file() and dest.stat().st_size == len(data)
if not existed:
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_name(dest.name + ".tmp")
tmp.write_bytes(data)
tmp.replace(dest)
# A FIX render arriving through this door (the worker mirror) keeps its colour
# restore — self-scoped to fixes/, fail-safe, image-only in practice.
colormatch_fix_ingest(request, sub, name)
# index at ingest → instantly findable in Assets + Queue & History (with node).
# kind defaults by suffix so a video row selects the motion judge + the video card,
# and a .glb selects the 3D (model-viewer) card.
_suf = os.path.splitext(name)[1].lower()
kind = request.query.get("kind") or (
"model3d" if _suf in _MODEL3D_EXT else "video" if _suf in _VIDEO_EXT else "render")
created = _index_upload(org, out_root, rel,
design=request.query.get("design") or None,
kind=kind,
role=request.query.get("role") or None,
node=request.query.get("node") or "upload")
if created:
# A newly-surfaced render: judge its quality out-of-band (never blocks
# this response). A clear FAIL flags it out of the default library.
quality_gate.schedule(request, org, out_root, rel)
resp = {"ok": True, "path": rel}
if existed:
resp["existed"] = True
return web.json_response(resp)
@routes.get("/v1/gpu-status")
async def gpu_status(request: web.Request):
"""Is a GPU worker online for the caller's org? Advisory only — it drives the
⚠ warning, never a render decision (dispatch always enqueues to gpu-jobs and
waits). Reads the fleet with a SERVICE identity so an org whose BYO GPUs are
registered under a sibling org still sees them; the org-scoped user-token read
alone reported a false 'no GPU' and drove the whole confusion."""
try:
from middleware.gpu_dispatch import _bearer, has_online_gpu_advisory
except ImportError:
from .middleware.gpu_dispatch import _bearer, has_online_gpu_advisory
try:
online = has_online_gpu_advisory(_bearer(request))
except Exception:
online = False
return web.json_response({"online": online})
@routes.get("/v1/render-queue")
async def render_queue(request: web.Request):
"""The org's IN-FLIGHT BYO-GPU renders — read AUTHORITATIVELY from the cloud
gpu-jobs tasks engine: each row carries the REAL claiming GPU (activity.identity)
and REAL status, so a dispatched fix/compose/rerun is visible AND attributed to
the machine actually running it — and nothing can render on a GPU without
appearing here. render_jobs.json is used ONLY as a ~2s pre-claim placeholder for
a job the engine hasn't listed yet. Terminal (done/failed/cancelled) and stale
rows drop off. Tenant-scoped by the caller's bearer via the tasks gate."""
org = _org_of(request)
root = _library_root(org)
now = int(time.time())
acts = await _list_gpu_jobs(request)
live, seen = [], set()
if acts is not None:
for a in acts:
if _act_type_name(a) not in ("studio.render", ""):
continue
job = _job_from_activity(a, now)
if not job["id"]:
continue
seen.add(job["id"])
if job["status"] in ("done", "failed", "cancelled") or job["age"] > 1500:
continue
live.append(job)
placeholder = _render_queue_placeholder(root, now, seen) if root is not None else []
return web.json_response({"jobs": placeholder + live})
@routes.post("/v1/library/status")
async def set_status(request: web.Request):
body = await request.json()
rel, status = body.get("path", ""), body.get("status", "")
if status not in _STATUSES:
return web.json_response({"error": f"status must be one of {_STATUSES}"}, status=400)
org = _org_of(request)
root = _library_root(org)
if root is None or _safe_asset(root, rel) is None:
return web.json_response({"error": "unknown asset"}, status=404)
lib = _load_library(root)
for a in lib.get("assets", []):
if a.get("path") == rel:
old = a.get("status")
a["status"] = status
a["updatedAt"] = int(time.time())
_save_library(root, lib)
# Curation feeds the flywheel: archiving/flagging an output records it as
# a bad response for training; restoring clears that auto-negative. Only
# on a real change, so a no-op re-set never double-logs.
if status != old:
_train_on_status(request, root, org, rel, status)
return web.json_response({"ok": True, "path": rel, "status": status})
return web.json_response({"error": "asset not in library"}, status=404)
@routes.post("/v1/library/dedup")
async def dedup(request: web.Request):
"""Soft-delete exact-content duplicates — keep one per byte-identical cluster,
move the rest to Deleted (recoverable, files kept). Content hash, so only TRUE
duplicates go; distinct re-renders (different seeds) are untouched. Returns the
moved paths so the UI can offer a one-shot undo."""
import hashlib
org = _org_of(request)
root = _library_root(org)
if root is None:
return web.json_response({"removed": 0, "deleted": []},
headers={"Cache-Control": "no-store"})
lib = _load_library(root)
seen: set[str] = set()
moved = []
for a in sorted(lib.get("assets", []), key=lambda x: x.get("path", "")):
if a.get("status") == "deleted":
continue
p = _safe_asset(root, a.get("path", ""))
if p is None:
continue
try:
digest = hashlib.md5()
with p.open("rb") as fh: # bounded chunks — a 256MB video never lands in memory
for chunk in iter(lambda: fh.read(1 << 20), b""):
digest.update(chunk)
h = digest.hexdigest()
except OSError:
continue
if h in seen:
moved.append({"path": a["path"], "prev": a.get("status", "draft")})
a["status"] = "deleted"
a["updatedAt"] = int(time.time())
else:
seen.add(h)
if moved:
_save_library(root, lib)
return web.json_response({"removed": len(moved), "deleted": moved},
headers={"Cache-Control": "no-store"})
@routes.post("/v1/library/purge")
async def purge(request: web.Request):
"""Permanently remove ARCHIVED assets — unlink the file AND drop the library
record. Irreversible: no ⌘Z can bring back an unlinked file, so the UI confirms
first. Archive-only by design — an asset must already be soft-deleted
(status='deleted'); a live asset is never hard-deleted directly, so a mis-click
in the gallery can only ever archive (recoverable), never destroy. Body is
{paths:[...]} to purge those, or {all:true} to empty the whole Archive."""
org = _org_of(request)
root = _library_root(org)
if root is None:
return web.json_response({"removed": []}, headers={"Cache-Control": "no-store"})
try:
body = await request.json()
except Exception:
body = {}
want_all = bool(body.get("all"))
ids = {p for p in (body.get("paths") or ([body["path"]] if body.get("path") else []))
if isinstance(p, str)}
if not want_all and not ids:
return web.json_response({"error": "paths or all required"}, status=400)
lib = _load_library(root)
kept, removed = [], []
for a in lib.get("assets", []):
if a.get("status") == "deleted" and (want_all or a.get("path") in ids):
p = _safe_asset(root, a.get("path", ""))
if p is not None:
try:
p.unlink()
except OSError:
pass
_drop_thumb(root, a.get("path", ""))
removed.append(a.get("path"))
else:
kept.append(a)
if removed:
lib["assets"] = kept
_save_library(root, lib)
return web.json_response({"removed": removed}, headers={"Cache-Control": "no-store"})
@routes.post("/v1/library/tags")
async def add_tags(request: web.Request):
"""Merge tags onto a set of library assets (the bulk 'Add tags' action). Tags
enrich; they never gate. One atomic per-org write, upsert by path."""
body = await request.json()
paths = [p for p in (body.get("paths") or []) if isinstance(p, str)]
tags = [t.strip()[:40] for t in (body.get("tags") or []) if isinstance(t, str) and t.strip()]
if not paths or not tags:
return web.json_response({"error": "paths and tags required"}, status=400)
root = _library_root(_org_of(request))
if root is None:
return web.json_response({"error": "no library for org"}, status=404)
want = set(paths)
lib = _load_library(root)
n = 0
for a in lib.get("assets", []):
if a.get("path") in want:
merged = list(dict.fromkeys((a.get("tags") or []) + tags))
a["tags"] = merged
a["updatedAt"] = int(time.time())
n += 1
_save_library(root, lib)
return web.json_response({"ok": True, "updated": n, "tags": tags})
@routes.post("/v1/library/rerun")
async def rerun(request: web.Request):
body = await request.json()
org = _org_of(request)
root = _library_root(org)
p = _safe_asset(root, body.get("path", "")) if root else None
if p is None or p.suffix.lower() not in _SAVE_EXT:
return web.json_response({"error": "unknown asset"}, status=404)
try:
graph = json.loads(_embedded(p).get("prompt", "null")) # PNG OR video metadata
except Exception:
graph = None
if not isinstance(graph, dict) or not graph:
return web.json_response({"error": "no embedded workflow in this output"}, status=422)
rel = body.get("path", "")
out_prefix = ""
instr = ""
for n in graph.values():
if not isinstance(n, dict):
continue
if n.get("class_type") in _SAVERS:
out_prefix = n.get("inputs", {}).get("filename_prefix", "") or out_prefix
elif "TextEncode" in n.get("class_type", "") and not instr:
t = n.get("inputs", {}).get("prompt") or n.get("inputs", {}).get("text") or ""
if t and t != _NEG and t != _WAN_NEG: # the positive, not either negative
instr = t
count = max(1, min(int(body.get("count", 1)), 4))
base = out_prefix or f"reruns/{re.sub(r'[^a-zA-Z0-9_-]', '_', p.stem)[:40]}"
# Save-outputs-to destination: an explicit Stack, or an auto-stack when the
# org opted in and this is a batch (count>1). Routing keys on the shared base,
# so every uniquely-tagged rerun output still lands in the one destination.
sid = _dest_stack(org, body.get("stack"), prompt=instr, multi=count > 1,
owner=_owner_of(request), workspace=_workspace_of(request))
ids = []
try:
for _ in range(count):
tag = _stamp(base) # each rerun owns a unique namespace (no tag accumulation)
g = _retag(_reseed(json.loads(json.dumps(graph))), tag)
ids.append(await _dispatch(
request, server, org, g,
kind="rerun", prompt=instr, refs=[], uploads=[], parents=[rel],
output_prefix=tag))
if sid:
stacks_store.route_prefix(org, tag, sid, added_by=_owner_of(request))
except DispatchError as e:
return web.json_response({"error": str(e)}, status=400)
return web.json_response({"ok": True, "prompt_ids": ids, "stack": sid})
@routes.post("/v1/queue/cancel")
async def cancel_job(request: web.Request):
"""Cancel one of the caller's OWN queued/running jobs (local pod queue or
BYO-GPU lane). Ownership is resolved server-side; a prompt_id that isn't the
caller's 404s — never confirming another tenant's job exists."""
org = _org_of(request)
pid = (await request.json()).get("prompt_id", "")
if not pid:
return web.json_response({"error": "prompt_id required"}, status=400)
if not _owns_job(server, org, pid):
return web.json_response({"error": "not found"}, status=404)
return web.json_response({"ok": True, "canceled": _cancel_job(server, org, pid)})
@routes.post("/v1/queue/amend")
async def amend_job(request: web.Request):
"""Amend a still-queued job (atomic cancel + re-dispatch with more context)."""
body = await request.json()
try:
return web.json_response(await dispatch_amend(
request, server, _org_of(request), body.get("prompt_id", ""),
body.get("instruction", ""), body.get("paths"), body.get("uploads"), body.get("stack")))
except NotOwned as e:
return web.json_response({"error": str(e)}, status=404)
except DispatchError as e:
return web.json_response({"error": str(e)}, status=400)
@routes.get("/v1/queue/status")
async def queue_status(request: web.Request):
"""This org's LIVE queue with where + when: per-lane position N of M, the
node each job runs on, elapsed for the running item, and an ETA computed
server-side from the org's OWN measured render medians. Tenant-scoped — only
the caller's active jobs and their positions. Cheap: reads the org work log,
stamps started/finished as it observes them, no fleet call."""
org = _org_of(request)
root = _library_root(org)
now = int(time.time())
rows = worklog.load(org)
# finalize any job whose output has landed: done at the file mtime, or FAILED
# when it landed implausibly fast for its step count (the honesty guard).
_finalize_landed(org, root, rows, now)
active = [r for r in rows if r.get("status") not in ("done", "failed", "cancelled")
and now - int(r.get("ts", now)) <= 1800]
# the oldest job in each lane is the one rendering — stamp its start once
by_lane: dict[str, list[dict]] = {}
for r in active:
by_lane.setdefault(r.get("lane") or "local", []).append(r)
for jobs in by_lane.values():
head = min(jobs, key=lambda j: j.get("ts", 0))
if not head.get("started_at"):
worklog.mark_started(org, head["id"], now)
head["started_at"] = now
head["status"] = "running"
medians = worklog.median_durations(rows)
est, counts = worklog.estimate_lanes(active, medians, now)
items = {}
for r in active:
items[r["id"]] = {**est.get(r["id"], {}), "id": r["id"], "kind": r.get("kind", ""),
"prompt": r.get("prompt", ""), "status": r.get("status"),
"node": r.get("node")}
return web.json_response({"items": items, "medians": medians, "lanes": counts, "now": now})
@routes.get("/v1/nodes")
async def get_nodes(request: web.Request):
"""The caller-org's GPU nodes (online/offline) for the queue header badges, plus
this studio pod — each annotated with LIVE queue depth read from the authoritative
gpu-jobs activities (jobs whose claiming identity, or targeted 'gpu:<name>' lane,
is this node). The fleet is token-scoped, so only the caller-org's nodes return."""
try:
from middleware.gpu_dispatch import _bearer, _fleet_machines, _node_label
except ImportError:
from .middleware.gpu_dispatch import _bearer, _fleet_machines, _node_label
now = int(time.time())
depth: dict = {}
for a in (await _list_gpu_jobs(request) or []):
if _act_type_name(a) not in ("studio.render", ""):
continue
ph = _activity_phase(a)
if ph not in ("queued", "running"):
continue
d = depth.setdefault(_job_from_activity(a, now)["node"], {"queued": 0, "running": 0})
d[ph] += 1
nodes = []
try:
tok = _bearer(request)
for m in (_fleet_machines(tok) if tok else []):
if not m.get("gpu"):
continue
name = _node_label(m)
d = depth.get(name, {"queued": 0, "running": 0})
nodes.append({"name": name, "online": m.get("status") == "online",
"queued": d["queued"], "running": d["running"],
"depth": d["queued"] + d["running"]})
except Exception:
nodes = []
return web.json_response({"nodes": nodes, "pod": {"name": "studio pod", "online": True}})
@routes.get("/v1/worklog")
async def get_worklog(request: web.Request):
"""This org's dispatched renders (fix/compose/rerun) with REAL status — the
queue/history backbone. Reverse-chronological; ?q= filters over prompt+kind.
Server-resolved org: a caller only ever sees its OWN log file."""
org = _org_of(request)
root = _library_root(org)
rows = worklog.load(org)
_resolve_outputs(org, root, rows, int(time.time()))
out = [dict(r) for r in reversed(rows)]
q = (request.query.get("q") or "").lower().strip()
if q:
# search prompt + kind + the render's paths (output_prefix/output) + refs, so
# an ingested render with an empty prompt is still findable by its filename.
out = [r for r in out if q in " ".join(str(x) for x in
[r.get("prompt", ""), r.get("kind", ""), r.get("output_prefix", ""),
r.get("output", ""), *(r.get("refs") or [])]).lower()]
return web.json_response({"org": org, "items": out[:200]})
@routes.post("/v1/worklog/delete")
async def delete_worklog(request: web.Request):
"""Remove queue/history entries by id — the user clearing render requests they
don't want (a cancelled probe, a failed attempt, finished clutter). Deletes ONLY
the log rows; output images live in the library and are archived/deleted there.
Body: {id} or {ids:[...]}."""
try:
body = await request.json()
except Exception:
body = {}
ids = body.get("ids")
if ids is None:
ids = [body.get("id")] if body.get("id") else []
org = _org_of(request)
removed = worklog.remove(org, ids)
return web.json_response({"ok": True, "removed": removed},
headers={"Cache-Control": "no-store"})
@routes.get("/v1/worklog/lineage")
async def get_lineage(request: web.Request):
"""The version chain for one library asset — ancestors (what it was derived
from, with the prompt at each step) and descendants (later fixes of it).
Pure over THIS org's rows; cross-tenant lineage is impossible by construction."""
org = _org_of(request)
root = _library_root(org)
target = request.query.get("path", "")
rows = worklog.load(org)
_resolve_outputs(org, root, rows, int(time.time()))
return web.json_response(worklog.build_lineage(rows, target))
@routes.post("/v1/library/fix")
async def fix(request: web.Request):
"""Edit a generated gallery image. Body: {path|upload, instruction, strength?,
seed?, mask?, stack?}. `strength` (edit-strength → denoise, default 0.25) is how
much may change; `seed` reuses the source's original by default; `mask` (an
uploaded painted image) restricts the edit to a region. Always a low-denoise edit
of the SOURCE — never a text-to-image regenerate (that is /v1/library/rerun)."""
body = await request.json()
try:
return web.json_response(await dispatch_fix(
request, server, _org_of(request), body.get("path", ""),
body.get("instruction", ""), body.get("upload"), body.get("stack"),
strength=body.get("strength"), seed=body.get("seed"), mask=body.get("mask")))
except DispatchError as e:
return web.json_response({"error": str(e)}, status=400)
@routes.post("/v1/library/image")
async def image(request: web.Request):
"""Generate an image from {prompt, width?, height?, seed?, stack?} — the proven
Z-Image-Turbo text-to-image graph, the composer's default create lane. Org from the
bearer claim only; a bad request is {"error": …} 400, never a silent nothing, so
the composer can always transition to a queued/generating state."""
body = await request.json()
try:
return web.json_response(await dispatch_image(
request, server, _org_of(request), body.get("prompt", ""),
width=body.get("width"), height=body.get("height"),
seed=body.get("seed"), stack=body.get("stack")))
except DispatchError as e:
return web.json_response({"error": str(e)}, status=400)
@routes.post("/v1/library/video")
async def video(request: web.Request):
"""Generate a video from {prompt, path?, upload?, width?, height?, length?, seed?,
stack?}. Org is the bearer-token claim (IAM middleware) ONLY — an ``org`` field in
the body or query is never read. The proven Wan 2.2 graph, dispatched through the
ONE funnel; a bad request surfaces as {"error": …} 400, never a silent nothing."""
body = await request.json()
try:
return web.json_response(await dispatch_video(
request, server, _org_of(request), body.get("prompt", ""),
rel=body.get("path"), upload=body.get("upload"),
width=body.get("width"), height=body.get("height"),
length=body.get("length"), seed=body.get("seed"), stack=body.get("stack")))
except DispatchError as e:
return web.json_response({"error": str(e)}, status=400)
@routes.post("/v1/library/model3d")
async def model3d(request: web.Request):
"""Generate a 3D mesh (.glb) from {path?|upload?, seed?, stack?} — ONE reference
image required. Org from the bearer claim only. The proven Hunyuan3D-2.1 graph
through the ONE dispatch funnel; a bad request is {"error": …} 400."""
body = await request.json()
try:
return web.json_response(await dispatch_model3d(
request, server, _org_of(request),
rel=body.get("path"), upload=body.get("upload"),
seed=body.get("seed"), stack=body.get("stack")))
except DispatchError as e:
return web.json_response({"error": str(e)}, status=400)
@routes.post("/v1/library/compose")
async def compose(request: web.Request):
"""Select N references (library assets and/or freshly uploaded images) + say
HOW to combine them → a new generated output. The multi-input case of the
proven Qwen reference-conditioned graph."""
body = await request.json()
try:
return web.json_response(await dispatch_compose(
request, server, _org_of(request), body.get("paths") or [],
body.get("instruction", ""), body.get("uploads"), body.get("stack")))
except DispatchError as e:
return web.json_response({"error": str(e)}, status=400)
# ── Ratings + notes: the curation feedback loop (HIP-0506 §5) ──────────────
# Stored per-org in orgs/<org>/output/ratings.json keyed by asset path OR run
# prompt_id. 0-5 stars + free notes; the copilot reads these before assembling
# a workflow so generations get closer to intent each pass.
@routes.get("/v1/library/ratings")
async def get_ratings(request: web.Request):
root = _library_root(_org_of(request))
if root is None:
return web.json_response({})
try:
return web.json_response(json.loads((root / "ratings.json").read_text()))
except Exception:
return web.json_response({})
@routes.post("/v1/library/rate")
async def rate(request: web.Request):
body = await request.json()
key = (body.get("key") or "").strip() # asset path or run prompt_id
if not key:
return web.json_response({"error": "key required"}, status=400)
stars = body.get("stars")
if stars is not None and not (isinstance(stars, int) and 0 <= stars <= 5):
return web.json_response({"error": "stars must be 0-5"}, status=400)
org = _org_of(request)
root = _library_root(org)
if root is None:
return web.json_response({"error": "no library for org"}, status=404)
patch: dict = {}
if stars is not None:
patch["stars"] = stars
if "vote" in body:
patch["vote"] = _norm_vote(body.get("vote")) # 👍 1 · 👎 -1 · cleared 0
if "notes" in body:
patch["notes"] = str(body.get("notes") or "")[:2000]
if "path" in body:
# The run's landed output, stored beside the rating so the flywheel row
# carries {path, stars, note, ts} keyed by run id — the AI can load the
# image it's being told to do differently next time.
patch["path"] = str(body.get("path") or "")[:512]
if ("vote" in body) or ("notes" in body):
patch["archived"] = False # an explicit judgement is user-owned, not the auto archive-negative
entry = _rate_write(root, key, patch) # ONE atomic writer, shared with the archive hook
# Every judgement (vote / stars / text) is also logged, immutable, to the
# training-feedback dataset the model trainer consumes.
if ("vote" in body) or ("notes" in body) or (stars is not None):
_append_feedback(request, root, org, key, entry)
return web.json_response({"ok": True, "key": key, "entry": entry})
@routes.post("/v1/library/critique")
async def critique(request: web.Request):
"""A conversation ABOUT one generated image, for critiquing the AI's work on
it. Unlike the node-editor copilot (/v1/copilot/chat, graph-only), this loads
the actual image so the reply is grounded in what was rendered — and EVERY
user turn is recorded as a training lesson on this image's lineage (the same
notes→_mistakes→_lessons flywheel a 💬 note feeds), so critiquing here
conditions the next generation. Body: {path, message}. Reply: {reply, thread}."""
body = await request.json()
rel = (body.get("path") or "").strip()
msg = (body.get("message") or "").strip()
if not rel or not msg:
return web.json_response({"error": "path and message required"}, status=400)
org = _org_of(request)
root = _library_root(org)
p = _safe_asset(root, rel) if root else None
if p is None:
return web.json_response({"error": "unknown asset"}, status=404)
# The running thread lives on the image's rating row.
rf = root / "ratings.json"
try:
data = json.loads(rf.read_text())
except Exception:
data = {}
entry = data.get(rel, {})
thread = entry.get("thread") or []
thread.append({"role": "user", "content": msg[:2000], "ts": int(time.time())})
# Ask a vision model, grounded in the actual image + the critique so far.
from middleware.gpu_dispatch import CLOUD, _bearer
from middleware import quality_gate as qg
tok = _bearer(request)
b64 = qg._downscale(p)
reply = ""
if b64:
sys_prompt = ("You are the studio's critique copilot. The user is critiquing a "
"generated image to improve FUTURE generations of this design. Look at "
"the image, acknowledge the specific flaw they point out, and restate it "
"as a precise, actionable correction the image model should apply next "
"time (color, likeness, pose, lighting, anatomy, fabric, framing). Be "
"concise — two sentences. Do not apologize; do not offer to regenerate.")
content = [{"type": "text", "text": sys_prompt}]
for t in thread[-8:]:
content.append({"type": "text", "text": f"{t['role'].upper()}: {t['content']}"})
content.append({"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64," + b64}})
payload = {"model": os.environ.get("STUDIO_GATE_MODEL", "").strip() or "zen-vl",
"messages": [{"role": "user", "content": content}],
"temperature": 0.2, "max_tokens": 400}
headers = {"Content-Type": "application/json"}
if tok:
headers["Authorization"] = tok
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60)) as s:
async with s.post(f"{CLOUD}/v1/chat/completions", json=payload, headers=headers) as r:
if r.status == 200:
reply = qg._reply_text(await r.json()).strip()
except Exception as e:
log.warning("critique: judge unreachable: %s", e)
if not reply:
reply = "Noted — I've recorded this so the next render avoids it."
thread.append({"role": "assistant", "content": reply, "ts": int(time.time())})
# The flywheel: the user's critique points become the note _mistakes reads,
# so the next fix/compose over this lineage is conditioned to avoid them.
crit = " ".join(t["content"] for t in thread if t["role"] == "user")[-2000:]
entry["thread"] = thread[-40:]
entry["notes"] = crit
entry["path"] = rel
entry["updatedAt"] = int(time.time())
data[rel] = entry
tmp = root / "ratings.json.tmp"
tmp.write_text(json.dumps(data, indent=1))
tmp.replace(rf)
_append_feedback(request, root, org, rel, entry)
return web.json_response({"ok": True, "reply": reply, "thread": thread})
@routes.get("/v1/library/critique")
async def critique_thread(request: web.Request):
"""The saved critique thread for one image (to reopen the conversation)."""
org = _org_of(request)
root = _library_root(org)
rel = (request.query.get("path") or "").strip()
if root is None or not rel:
return web.json_response({"thread": []})
try:
data = json.loads((root / "ratings.json").read_text())
except Exception:
data = {}
return web.json_response({"thread": (data.get(rel, {}) or {}).get("thread") or []})
@routes.get("/v1/feedback/stats")
async def feedback_stats(request: web.Request):
"""👍/👎/with-text/total counts for this org's training-feedback dataset."""
root = _library_root(_org_of(request))
up = down = withtext = total = 0
if root is not None:
try:
for line in (root / "feedback.jsonl").read_text().splitlines():
if not line.strip():
continue
r = json.loads(line)
total += 1
up += r.get("vote") == 1
down += r.get("vote") == -1
withtext += bool(r.get("text"))
except Exception:
pass
return web.json_response({"up": up, "down": down, "withText": withtext, "total": total})
@routes.get("/v1/feedback/export")
async def feedback_export(request: web.Request):
"""This org's append-only training-feedback dataset (JSONL): every vote / stars
/ text event paired with the output it judged. What the model trainer pulls to
build preference, reward, and instruction-tuning data — the output PNG each row
anchors to carries its own prompt + refs in its embedded graph."""
root = _library_root(_org_of(request))
text = ""
if root is not None:
try:
text = (root / "feedback.jsonl").read_text()
except Exception:
text = ""
return web.Response(text=text, content_type="application/x-ndjson",
headers={"Content-Disposition": "attachment; filename=feedback.jsonl",
"Cache-Control": "no-store"})
# ── Favorites: the heart toggle. A per-org set of saved runs/flows, keyed by run
# id (or output path) exactly like ratings — but a SEPARATE concern: a favorite is
# the user's bookmark (→ "turn this into a template"), a rating is quality feedback
# for the AI. One store each; a run can be either, both, or neither. ─────────────
@routes.get("/v1/favorites")
async def get_favorites(request: web.Request):
org = _org_of(request)
favs = _load_store(_org_store(org, "favorites.json"), {})
return web.json_response({"org": org, "favorites": favs if isinstance(favs, dict) else {}})
@routes.post("/v1/favorites")
async def set_favorite(request: web.Request):
org = _org_of(request)
try:
body = await request.json()
except Exception:
return web.json_response({"error": "invalid JSON"}, status=400)
key = (body.get("key") or "").strip()[:512]
if not key:
return web.json_response({"error": "key required"}, status=400)
fav = bool(body.get("favorite", True))
p = _org_store(org, "favorites.json")
data = _load_store(p, {})
if not isinstance(data, dict):
data = {}
if fav:
data[key] = {"ts": int(time.time())}
else:
data.pop(key, None)
_save_store(p, data)
return web.json_response({"ok": True, "key": key, "favorite": fav})