Compare commits

..
Author SHA1 Message Date
Hanzo AI 70d2068264 render: cap startToClose at 4h — the tasks default (~1h) reaped live renders
Liveness is the 1200s heartbeat; the cap only bounds a live-but-stuck render.
Observed: activities reaped at ~68m mid-render, queue re-ran the same job six
times, results stranded on the node.
2026-07-16 19:31:10 -07:00
722ea7fd39 Queue transparency: which node runs it + position/ETA/elapsed (#10)
Makes the unified queue honest about WHERE and WHEN, all server-computed and
tenant-scoped (an org sees only its own jobs' positions and its own nodes).

WHERE — node identity:
- gpu_dispatch reads whatever the gpu-jobs fleet publishes (_node_label prefers
  name/hostname/label; home-lab labels spark/dbc/evo land there) — no parallel
  registry invented. dispatch_if_worker records the target node (the single online
  GPU is definitive; ambiguous 'gpu' with several) on the render_jobs row; _dispatch
  copies it onto the work-log row. Local-core jobs are 'studio pod'.
- GET /v1/nodes: the org's GPU nodes (online/offline) + the pod, for header badges.
  Fleet is token-scoped → only the caller's nodes.

WHEN — position + ETA (server-side, never client math over private data):
- worklog records queued/started/finished timestamps (mark_started when a job first
  heads its lane; mark_finished from the output file mtime). Pure functions:
  median_durations (rolling median render seconds per kind) and estimate_lanes
  (position N of M per lane; wait ≈ jobs-ahead × medians + running remaining;
  eta = wait + own median). Unknown kinds fall back to a default; labeled honestly.
- GET /v1/queue/status: this org's active jobs with {position, of, node, status,
  elapsed_seconds, wait_seconds, remaining_seconds, eta_seconds} + medians. Reads
  the org work log only; no fleet call on the hot path.

UI: 'Your work' rows and the BYO-GPU row show ⚙ node · #N/M · running <elapsed> ·
~Xm left  /  #N/M in line · ~X min est. Queue header shows per-node online/offline
badges + the median-render stat. Auto-refresh while the queue view is open (6s
positions, 20s nodes — no hammering); the running item's elapsed ticks each second
client-side; a completion toast fires when an open page sees a job land.

Tests (11 new): _node_label + online-node filtering; record-node; started/finished
+ median; median_durations per kind + ts fallback; estimate_lanes positions/ETA +
default-median. Route smoke: positions #1/2/3 with ETAs from the org's own median,
landed→finished drop, globex sees none of acme's queue, /v1/nodes maps fleet→badges.
Browser smoke: node+position+ETA render, elapsed ticks 0s→2s, median stat + pod
badge, zero console errors.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 16:28:30 -07:00
845ddd9dae Cancel + amend queued jobs (tenant-scoped, both realms) (#9)
Cancel and amend any of the caller's OWN queued/running renders, resolved
server-side; a prompt_id that isn't the caller's 404s (never reveals it exists).

Cancel — POST /v1/queue/cancel {prompt_id}:
- local pod queue: delete a pending item OR interrupt a running one, org-scoped
  (reuses the core prompt_queue primitives — one tenant can't touch another's).
- BYO-GPU lane: drop the render_jobs.json tracking row (per-org file → inherently
  owned); a job already rendering on the worker may still finish (the pod can't
  stop a remote process) — the UI says so honestly.
- marks the work-log row cancelled. _owns_job gates it: work-log OR org-stamped
  local-queue item OR the org's gpu tracking file; else 404.

Amend — POST /v1/queue/amend {prompt_id, instruction, paths?, uploads?}:
- honest atomic swap (queued graphs are immutable): cancel the original, then
  re-dispatch through the ONE funnel with the amended prompt + the original refs
  (kept, read from the authoritative work-log row) + new library picks/uploads.
  Records a fresh work-log row; position resets, shown honestly in the UI.

UI: Edit + Cancel on every queued/running work row and in the context panel;
Cancel on the BYO-GPU live row; the local 'Remove' now routes through the unified
cancel (dead delQueued removed). Amend reuses the Fix dialog in amend mode
(openAmend): prompt primed, current refs shown locked, add more via the same
dropzone + history picker. Errors surface in-dialog (no silent swallow).

Hardening: _queue_prompt tolerates a non-JSON downstream response (reports it
instead of 500-ing).

Tests: cancel org-scoped + gpu-row removal + cross-tenant not-owned; amend atomic
swap keeps original refs + adds new, unknown→NotOwned(404), terminal rejected (5).
Route smoke: amend swaps + re-queues, gpu cancel removes tracking, globex gets 404
on acme's job (cancel AND amend). Browser: Edit/Cancel on queued rows, amend dialog
primed with prompt + current ref, correct POST, zero console errors.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 13:24:24 -07:00
f569319988 Work history with full context + parent→child lineage/versions (#8)
Adds a per-org work log — the ONE record of every render dispatched — as the
backbone for a queue/history that never loses a job and shows what produced it.

Backend (middleware/worklog.py, one JSON per org at orgs/<org>/output/worklog.json;
same pattern as library.json/render_jobs.json; no new DB, no backfill):
- The single dispatch funnel (dispatch_fix/dispatch_compose/rerun) records one row
  at dispatch via _dispatch(): {id, ts, kind, prompt, refs, uploads, parents,
  output_prefix, params, lane, status, events}. A FAILED dispatch is recorded too
  (status=failed + reason) so the deployed 'nothing showed as queued' can't recur —
  every dispatch is visible with real status, gpu-lane or local.
- GET /v1/worklog — org's dispatched renders, reverse-chron, ?q= over prompt+kind,
  status enriched (done when the output lands). Server-resolved org only.
- GET /v1/worklog/lineage?path= — a library asset's version chain (ancestors it was
  derived from + later fixes), a pure function over this org's rows.
- _landed_output matches SaveImage's exact <prefix>_<counter>_.png (not a loose
  <prefix>*, which collapsed distinct lineage steps).

Frontend (studio_home.html):
- Queue & History → 'Your work': searchable, reverse-chron list with status badge +
  lane + prompt + thumbnail; click → context panel (prompt, reference/result thumbs,
  workflow family, params, process trail, and the Versions chain inline).
- 'Versions' affordance on every library card → the same lineage chain; click any
  version to open it.
- 'Reuse context' / 'Fix this version' open the Fix dialog primed with the item's
  prompt + references (openFixPath/primeFix) — reuse = continue from any version.

Tenant isolation: one log file per org; routes resolve org server-side; lineage is
pure over the caller's rows — cross-tenant leakage is impossible by construction.

Tests: worklog record/failed/status/cap, lineage chain + cycle-termination,
cross-tenant isolation (7). Route smoke: dispatch→done+output, 2-step lineage,
failed dispatch recorded with reason, globex sees none of acme's work. Browser
smoke: work list + status badge + context panel + Versions chain, zero console
errors.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 13:15:21 -07:00
c249ac41a4 Fix live blockers: surface dispatch errors + working '+' attach (no silent swallow) (#7)
Two user-reported live regressions on studio.hanzo.ai, root-caused at the
deployed image (sha-0d307dc, pre-#6):

1) Fix 'won't work / nothing queued' — the GPU-less prod pod fails /prompt
   validation (models absent) or the router has no GPU worker; _queue_prompt
   raised web.HTTPBadGateway(text=json.dumps(body)[:500]) — a raw 502 whose
   TRUNCATED body the dialog couldn't parse, so the error was swallowed into an
   empty 'Failed:'. Now _queue_prompt raises DispatchError carrying the REAL
   reason (_prompt_error_message), which the fix/compose routes already return as
   {"error"}. Every dialog/sender now SHOWS backend errors persistently and keeps
   the dialog open on failure — no silent swallow anywhere on fix/compose paths.

2) The composer '+' attach button did nothing (no handler). Wired it to the
   shared upload lane (POST /upload/image → org input dir, multi-tenant) with a
   visible thumbnail; added drag-and-drop + paste to the Fix dialog AND the
   composer, all through ONE shared helper (uploadImage/uploadMany/wireDrop/
   wirePaste). Upload failures are shown, never swallowed. Reference tray now
   holds library AND uploaded refs; a single uploaded photo drives a fix via the
   new dispatch_fix(upload=) base (edit a fresh photo without landing it first).

Tests: text-only fix regression lock, uploaded-base fix + traversal refusal,
_prompt_error_message extraction, and a real-aiohttp test that a non-200 /prompt
surfaces the real reason (not a 502). Browser smoke (headless Chromium on the
served page): '+' opens the chooser and uploads, drag-drop adds a ref, text-only
Fix posts {path,instruction}, zero console errors.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 13:00:40 -07:00
268857c16e Fix surface: reference images via drag-drop upload + add-from-history (#6)
The Fix action on a generated image becomes a reference-capable surface:
- base image (the clicked output) + up to 4 references
- drag-and-drop / click-to-upload image files as references, via the
  existing core /upload/image route (org-scoped input dir — the same place
  fix/compose already stage refs and the BYO-GPU collector ships from)
- add-from-history picker: attach past library generations as references
- no references -> single-image /v1/library/fix (unchanged); any reference
  -> multi-input /v1/library/compose, base image first

Backend: dispatch_compose now accepts uploaded input-dir names (uploads[])
alongside library asset paths, validated as safe existing images; the 2-5
bound is on the combined reference count. One compose path, additive.

Tests: _resolve_uploads filtering, compose upload path + single-ref
rejection; corrected stale _STATUSES vocab (includes 'deleted').

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 12:36:47 -07:00
Hanzo AI a9223e59e8 studio/web: call api.hanzo.ai/v1/chat directly — no studio proxy layer
Chat hits the cloud clients/chat orchestrator directly (credentials:'include'
carries the .hanzo.ai cookie cross-origin; handler replays it into the billed
completion). Engine calls stay same-origin. Needs gateway CORS to allow the
studio.hanzo.ai origin with credentials — infra config, no backend code.
2026-07-16 10:57:33 -07:00
Hanzo AI 10eec303fd studio: web/ — our unique product frontend (chat app on the shared Hanzo stack)
Vite+React studio-chat: say what to create, the cloud /v1/chat orchestrator drives
studio's render tools, outputs land in the library. Increment 1 (own minimal UI
wired to /v1/chat create + library/queue/gpu). Served at /studio; the ComfyUI node
editor stays separate in studio-ui (was studio-frontend). Builds clean (npm-published
@hanzo/*).
2026-07-16 09:48:39 -07:00
Hanzo AI b07530398d studio: /v1/mcp render tools (fix/compose/ask) for the cloud chat tool registry
MCP-over-HTTP (JSON-RPC 2.0) exposing the studio render pipeline as tools the
cloud /v1/chat 'create' capability drives via the tool registry. Reuses the ONE
dispatch path (studio_home.dispatch_fix/dispatch_compose, extracted from the
/v1/library/{fix,compose} routes) so an LLM-issued fix behaves identically to a
UI one. Registered per-org via cloud POST /v1/tools/servers. v0.17.3.
2026-07-16 09:01:36 -07:00
Hanzo AI 0d307dceb2 studio: responsive — home was desktop-only (0 media queries). Add tablet(≤900) + phone(≤600) breakpoints: fluid grid, wrapping controls, full-width chat sheet, bigger tap targets, always-show card tools on touch (no hover-reveal). Works mobile/tablet/laptop/desktop. v0.17.2 2026-07-16 00:21:55 -07:00
Hanzo AI 1b713f904c studio: fix queue jobs VANISHING after 30s — render-queue completion check did a fuzzy base-prefix match that collided with the SOURCE image (a fix's output prefix derives from the source name), so every job looked 'done' after 30s and disappeared. Now checks for the EXACT output file (<outPrefix>_NNNNN_.png). This is why 'the queue is not working'. v0.17.1 2026-07-15 23:19:39 -07:00
Hanzo AI c969641d7d studio: EXIT Advanced mode — always-visible '← Studio' button (fixed top-left) + 'Exit to Studio Home' in the user menu. Advanced mode (the full-screen node editor) had NO way back to Studio Home — users got stuck. v0.17.0 2026-07-15 23:11:34 -07:00
Hanzo AI b7dff20861 studio: fix 'connect a GPU' when a GPU IS connected — UI checked /v1/engines (worker-federation) but 'hanzo gpu connect' registers with the FLEET (gpu-jobs), which is what dispatch actually uses. New /v1/gpu-status reports the SAME source as dispatch (_has_online_gpu → /v1/machines); UI now shows spark connected. v0.16.9 2026-07-15 22:58:04 -07:00
Hanzo AI e696fb7480 studio: SHOW BYO-GPU renders in the queue — fix/compose dispatch to gpu-jobs (worker), NOT the pod's local /queue, so a queued fix was invisible ('didn't bring it into queue'). Now dispatch_if_worker tracks each job per-org; /v1/render-queue serves in-flight ones (drops on output-landed/age-out); live-bar + Queue tab show them with thumbnail + ETA. v0.16.8 2026-07-15 22:20:50 -07:00
Hanzo AI b3f20495f8 studio: /studio served no-store — browsers cached the SPA HTML and served a STALE page after deploys, making UI fixes (delete) look broken to users on yesterday's cached page. v0.16.7 2026-07-15 22:12:29 -07:00
Hanzo AI 179b577add studio SECURITY: org-scope the inherited core endpoints (multi-tenant release blockers from red audit) — /history + /history/{id} filtered by caller org (was cross-tenant BOLA, live in Queue&History UI); /queue GET filtered; /queue clear/delete scoped (was: any tenant's 'Clear all' wiped ALL orgs); /interrupt scoped to caller's running job; org_id path-traversal sanitization in get_org_base_path. Single-tenant (auth off) unchanged. v0.16.6 2026-07-15 21:37:33 -07:00
Hanzo AI c2336e0c1e studio: YouTube-style up-next queue rows — reference thumbnail + title + brief description (the prompt) + ETA/#-in-line; serves staged fix/compose refs via ?input=1; v0.16.5 2026-07-15 20:51:01 -07:00
Hanzo AI 0538abf199 studio: FAST thumbnails (?thumb=1 → cached 480px JPEG, fixes slow full-res grid loading) + always-visible instant 🗑 trash on every card (optimistic delete, feels instant) + tray/compose use thumbs; v0.16.4 2026-07-15 20:49:03 -07:00
Hanzo AI 7eefdcd206 studio: persistent live-queue bar (always visible, non-advanced) — shows what's Generating now + queued count + ETA, polls /queue every 5s; auto-hiding header + full-bleed immersive stage CSS + Hanzo AI chat widget scaffold; v0.16.3 2026-07-15 20:42:01 -07:00
Hanzo AI b238b8c568 studio: bump BYO-GPU render deadline 600s→1200s (heavy Qwen edit on cold GB10 exceeds 10min); v0.16.2 2026-07-15 20:18:30 -07:00
Hanzo AI e97194613c studio: '+' on each photo → reference tray for the next generation; 1 ref=fix/edit, 2-5=compose; describe + Generate from the floating tray; v0.16.1 2026-07-15 20:12:56 -07:00
Hanzo AI b31a1ea3e2 studio: YouTube-style Queue & History — Now-running + Up-next (live /queue, remove/clear) + History with 0-5 star ratings + notes-for-AI (real /v1/library/rate + ratings.json curation store) + favorite→save-as-template guided flow; v0.16.0 2026-07-15 19:58:21 -07:00
Hanzo AI dd21fd9f85 studio: multi-asset Compose (select N → describe how to combine → /v1/library/compose, multi-ref Qwen graph) + Fix/Compose now report GPU-online status clearly (Qwen models live on BYO-GPU); v0.15.9 2026-07-15 19:50:41 -07:00
Hanzo AI 109cd04a54 studio: soft-delete (recoverable, Deleted filter) + multi-select bulk (approve/publish/fork/delete) + hover-reveal tools (cleaner grid) + systematized fashion templates (CAD→ghost, outfit-switcher, product, lifestyle, CAD→3D grouped by category); v0.15.8 2026-07-15 19:22:32 -07:00
Hanzo AI 552f47b844 studio: chat-first Claude-Design home ('What should we create?' + full Hanzo template set: product/ghost/CAD-3D/video/music/voice/social/docs/paper) + org-scoped /v1/library/file (fixes blank thumbnails); v0.15.7 2026-07-15 18:33:59 -07:00
Hanzo AI b0d1b06a6f studio home: Hanzo logo top-left + clean inline org switcher/avatar (drop broken floating identity pill); v0.15.6 2026-07-15 18:30:09 -07:00
Hanzo AI 023367bfec studio home: monochrome brand (drop yellow --brand) + surface org-switcher/user/wallet identity pill; v0.15.5 2026-07-15 17:42:05 -07:00
Hanzo AI b2121abc71 chore: version 0.15.4 — rebuild Studio Home (0.15.3 image never reached GHCR) 2026-07-14 17:51:53 -07:00
hanzo-dev 0a3146e00f docs: de-tenant the manifest + home docstrings
Claude-Session: https://claude.ai/code/session_01Gq8suw7uuodAMPDRpo6iAB
2026-07-14 17:06:03 -07:00
hanzo-dev 8e55ecac9a refactor: karma_manifest.py -> library_manifest.py — org-generic name for the org-generic tool (unified multi-tenant SaaS; no tenant names in code)
Claude-Session: https://claude.ai/code/session_01Gq8suw7uuodAMPDRpo6iAB
2026-07-14 17:05:44 -07:00
30 changed files with 5096 additions and 173 deletions
+17
View File
@@ -110,6 +110,12 @@
}
rows.push(el("div", { class: "hzid-sep" }));
// Exit the advanced node editor back to the content-forward Studio Home.
// Without this there is NO way out of Advanced mode (the node editor is the
// full-screen ComfyUI SPA) — the user gets stuck.
rows.push(el("a", { class: "hzid-item hzid-link", href: "/studio" }, [
el("span", { class: "hzid-tick", text: "←" }), el("span", { text: "Exit to Studio Home" }),
]));
rows.push(el("a", { class: "hzid-item hzid-link", href: s.console_url || "https://console.hanzo.ai", target: "_blank", rel: "noopener" }, [
el("span", { class: "hzid-tick", text: "↗" }), el("span", { text: "Console" }),
]));
@@ -151,6 +157,17 @@
// ------------------------------------------------------------- boot
function mount() {
if (document.getElementById("hanzo-identity")) return;
// Always-visible "Exit to Studio" button so Advanced mode is never a trap.
// Fixed top-left, above the node editor; independent of the user menu.
if (!document.getElementById("hanzo-exit-advanced")) {
var exit = el("a", {
id: "hanzo-exit-advanced", href: "/studio", title: "Exit Advanced — back to Studio Home",
style: "position:fixed;top:10px;left:12px;z-index:100000;display:flex;align-items:center;gap:6px;" +
"background:rgba(20,20,24,.92);color:#ececf1;border:1px solid #33333c;border-radius:8px;" +
"padding:6px 11px;font:600 12px/1 Inter,system-ui,sans-serif;text-decoration:none;cursor:pointer;backdrop-filter:blur(8px)"
}, [el("span", { text: "←" }), el("span", { text: "Studio" })]);
document.body.appendChild(exit);
}
var root = el("div", { id: "hanzo-identity", class: "hanzo-identity" });
document.body.appendChild(root);
getSession().then(function (s) {
+1 -1
View File
@@ -21,7 +21,7 @@ import sys
STATIC_DIR = sys.argv[1]
BRANDING_DIR = sys.argv[2]
BUNDLE_VERSION = "3"
BUNDLE_VERSION = "4"
JS = "hanzo-identity.js"
CSS = "hanzo-identity.css"
LINK_TAG = f'<link rel="stylesheet" href="{CSS}?v={BUNDLE_VERSION}"/>'
+15 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import os
import re
import time
import mimetypes
import logging
@@ -94,6 +95,19 @@ def get_org_id() -> str | None:
return _org_id
_ORG_ID_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
def _safe_org_id(oid: str) -> str:
"""The single choke point where an org value becomes a filesystem path — so it
is also the one place to reject traversal. An org id must be a plain slug; `.`,
`..`, `/`, `\\`, or any other character is refused so a malformed/hostile claim
can never escape the orgs/ tree (defense-in-depth; org id is IAM-derived today)."""
if oid in (".", "..") or not _ORG_ID_RE.match(oid):
raise ValueError(f"invalid org_id for path scoping: {oid!r}")
return oid
def get_org_base_path(org_id: str | None = None) -> str:
"""
Get the org-scoped base path. If multi-tenant is enabled and org_id is set,
@@ -101,7 +115,7 @@ def get_org_base_path(org_id: str | None = None) -> str:
"""
oid = org_id or _org_id
if _multi_tenant and oid:
org_path = os.path.join(base_path, "orgs", oid)
org_path = os.path.join(base_path, "orgs", _safe_org_id(oid))
os.makedirs(org_path, exist_ok=True)
return org_path
return base_path
+84 -5
View File
@@ -76,11 +76,36 @@ def _bearer(request) -> str:
return f"Bearer {tok}" if tok else ""
def _has_online_gpu(tok: str) -> bool:
"""True if the caller's org has an online GPU machine in the cloud fleet."""
# Identity keys a fleet machine might carry, most human-friendly first. We READ
# whatever the gpu-jobs lane publishes (home-lab labels like spark/dbc/evo land in
# `name`/`hostname`); we never invent a parallel registry.
_NODE_KEYS = ("name", "hostname", "label", "node", "worker_id", "machine_id", "id", "gpu_model")
def _node_label(m: dict) -> str:
for k in _NODE_KEYS:
v = m.get(k)
if isinstance(v, str) and v.strip():
return v.strip()
return "gpu"
def _fleet_machines(tok: str) -> list:
"""The caller-org's machines from the cloud fleet. The token scopes the fleet to
the caller's org, so this is inherently tenant-isolated."""
req = urllib.request.Request(f"{CLOUD}/v1/machines", headers={"Authorization": tok})
d = json.loads(urllib.request.urlopen(req, timeout=10).read())
return any(m.get("status") == "online" and m.get("gpu") for m in d.get("machines", []))
ms = d.get("machines", [])
return ms if isinstance(ms, list) else []
def _online_gpu_nodes(tok: str) -> list:
return [m for m in _fleet_machines(tok) if m.get("status") == "online" and m.get("gpu")]
def _has_online_gpu(tok: str) -> bool:
"""True if the caller's org has an online GPU machine in the cloud fleet."""
return bool(_online_gpu_nodes(tok))
def dispatch_if_worker(request, org_id: str, prompt_id: str, prompt: dict) -> bool:
@@ -89,14 +114,25 @@ def dispatch_if_worker(request, org_id: str, prompt_id: str, prompt: dict) -> bo
Never raises — any failure falls back to local."""
try:
tok = _bearer(request)
if not tok or not _has_online_gpu(tok):
online = _online_gpu_nodes(tok) if tok else []
if not online:
return False
# Which node runs it: the single online GPU is definitive; with several the
# claimer is decided at pick-up time, so record the ambiguous "gpu".
node = _node_label(online[0]) if len(online) == 1 else "gpu"
body = json.dumps({
"activityId": prompt_id,
"runId": prompt_id,
"activityType": {"name": "studio.render"},
"taskQueue": JOBS_NS,
"heartbeatTimeout": "600s",
# 1200s: a heavy Qwen edit on a cold GB10 (model reload + full 30-step
# sample) can exceed 600s; the worker heartbeats each step, so this is
# the ceiling for a genuinely long single render, not idle slack.
"heartbeatTimeout": "1200s",
# Liveness is the heartbeat above; this cap only bounds a live-but-stuck
# render. Unset, the tasks default (~1h) reaped real renders mid-run and
# the queue re-ran them for hours.
"startToCloseTimeout": "14400s",
"input": {
"prompt": prompt,
"org": org_id,
@@ -113,6 +149,49 @@ def dispatch_if_worker(request, org_id: str, prompt_id: str, prompt: dict) -> bo
headers={"Authorization": tok, "Content-Type": "application/json"},
)
urllib.request.urlopen(req, timeout=15).read()
_track_dispatched(org_id, prompt_id, prompt, node=node)
return True
except Exception:
return False
def _track_dispatched(org_id: str, prompt_id: str, prompt: dict, node: str = "gpu") -> None:
"""Record a BYO-GPU render dispatched to gpu-jobs so the studio UI can SHOW it
in the queue. BYO-GPU jobs render on the worker (gpu-jobs), not the pod's local
/queue — so without this the user's fix/compose 'never appears in the queue'.
A tiny per-org JSON the /v1/render-queue endpoint reads; entries drop off when
their output lands in the library or they age out. Never raises."""
try:
import time
import folder_paths
# human title from the SaveImage prefix; refs from LoadImage for a thumbnail
full_prefix, refs = "render", []
for n in prompt.values():
if not isinstance(n, dict):
continue
ct = n.get("class_type", "")
if ct == "SaveImage":
full_prefix = n.get("inputs", {}).get("filename_prefix") or "render"
elif "LoadImage" in ct:
im = n.get("inputs", {}).get("image")
if im:
refs.append(im)
out_dir = folder_paths.get_org_output_directory(org_id)
os.makedirs(out_dir, exist_ok=True)
jf = os.path.join(out_dir, "render_jobs.json")
try:
jobs = json.loads(open(jf).read())
except Exception:
jobs = []
jobs = [j for j in jobs if j.get("id") != prompt_id][-40:] # dedupe + cap
# store the FULL output prefix (e.g. "fixes/model01_..._00001_") so the queue
# endpoint can check for the ACTUAL output file, not a fuzzy base match that
# collides with the source image and makes the job vanish after 30s.
jobs.append({"id": prompt_id, "prefix": full_prefix.split("/")[-1],
"outPrefix": full_prefix, "refs": refs[:1], "node": node,
"ts": int(time.time()), "status": "rendering"})
tmp = jf + ".tmp"
open(tmp, "w").write(json.dumps(jobs))
os.replace(tmp, jf)
except Exception:
pass
+187
View File
@@ -0,0 +1,187 @@
"""MCP-over-HTTP for Hanzo Studio — the studio render pipeline as a Model Context
Protocol server so the cloud chat `create` capability can drive it.
POST /v1/mcp speaks JSON-RPC 2.0 (the MCP wire format) and exposes the SAME render
dispatch the /v1/library/{fix,compose} routes use — ``studio_home.dispatch_fix`` /
``studio_home.dispatch_compose`` — as MCP tools. So a fix/compose an LLM issues
behaves identically to one the Studio UI issues: one dispatch path, no second
pipeline. Auth and org ride the existing IAM middleware exactly like every other
/v1/* route (org = ``studio_home._org_of(request)``); nothing is registered here
that the global auth layer does not already gate.
Methods (JSON-RPC 2.0 envelope: {jsonrpc,id,result|error}):
initialize -> {protocolVersion, serverInfo, capabilities:{tools:{}}}
tools/list -> the fix / compose / ask tools + JSON schemas
tools/call -> dispatch fix|compose|ask and return MCP tool-result content
(the render prompt_id / output_prefix). A DispatchError
becomes an isError tool-result, never a 500. An unknown
METHOD is a JSON-RPC method-not-found error.
── Registration: the ONE recipe ──────────────────────────────────────────────
An operator/admin registers THIS studio MCP server per-org, once, through the
cloud tool registry — the studio public URL with the /v1/mcp path:
POST https://api.hanzo.ai/v1/tools/servers
{ "name": "studio",
"url": "https://studio.hanzo.ai/v1/mcp",
"org": "<org>" }
Cloud then calls tools/list here and exposes the returned tools to /v1/chat's
`create` capability as SourceMCP tools. There is no per-tool wiring in cloud: the
tool set is whatever tools/list returns, so adding a studio tool is a one-line
change to _TOOLS here — it flows to chat with no cloud deploy.
"""
from __future__ import annotations
import json
from aiohttp import web
from middleware import studio_home
from studio_version import __version__
# The MCP revision this server implements. Reported in `initialize`.
_PROTOCOL_VERSION = "2025-06-18"
# JSON-RPC 2.0 error codes (only the ones we emit).
_PARSE_ERROR = -32700
_INVALID_REQUEST = -32600
_METHOD_NOT_FOUND = -32601
# The studio render tools. `inputSchema` is the JSON Schema cloud advertises to
# the model; the dispatch functions validate again (instruction length, asset
# existence), so the schema is a hint, not the trust boundary.
_TOOLS = [
{
"name": "fix",
"description": (
"Edit one library asset with an instruction (Qwen image-edit). "
"Returns the render prompt_id and output_prefix."
),
"inputSchema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "library-relative path of the asset to edit"},
"instruction": {"type": "string", "description": "the edit to apply (3-500 chars)"},
},
"required": ["path", "instruction"],
},
},
{
"name": "compose",
"description": (
"Combine 2-5 library assets into a new output per an instruction. "
"Returns the render prompt_id and output_prefix."
),
"inputSchema": {
"type": "object",
"properties": {
"paths": {
"type": "array",
"items": {"type": "string"},
"minItems": 2,
"maxItems": 5,
"description": "2-5 library-relative asset paths to combine",
},
"instruction": {"type": "string", "description": "how to combine them (3-500 chars)"},
},
"required": ["paths", "instruction"],
},
},
{
"name": "ask",
"description": (
"Clarifier no-op: echo a question back to the user when the request is "
"ambiguous. Produces no render."
),
"inputSchema": {
"type": "object",
"properties": {
"question": {"type": "string", "description": "the clarifying question to ask"},
},
"required": ["question"],
},
},
]
def _result(id_, result: dict) -> dict:
return {"jsonrpc": "2.0", "id": id_, "result": result}
def _error(id_, code: int, message: str) -> dict:
return {"jsonrpc": "2.0", "id": id_, "error": {"code": code, "message": message}}
def _content(payload: dict, is_error: bool = False) -> dict:
"""An MCP tool result: text content parts + optional isError flag. The text is
the JSON dispatch payload (prompt_id / output_prefix) or the error message."""
r = {"content": [{"type": "text", "text": json.dumps(payload)}]}
if is_error:
r["isError"] = True
return r
async def _call_tool(request: web.Request, server, name: str, arguments: dict):
"""Dispatch one tool call to the shared render path. Returns the MCP
tool-result, or None for an unknown tool name (caller maps that to isError)."""
org = studio_home._org_of(request)
if name == "fix":
out = await studio_home.dispatch_fix(
request, server, org, arguments.get("path", ""), arguments.get("instruction", ""))
return _content(out)
if name == "compose":
out = await studio_home.dispatch_compose(
request, server, org, arguments.get("paths") or [], arguments.get("instruction", ""))
return _content(out)
if name == "ask":
return _content({"ok": True, "question": str(arguments.get("question", "")).strip()})
return None
async def _handle(request: web.Request, server, msg) -> dict | None:
"""Process one JSON-RPC 2.0 message. Returns the response envelope, or None
for a notification (a message with no ``id`` — no response is sent)."""
if not isinstance(msg, dict) or msg.get("jsonrpc") != "2.0":
return _error(None, _INVALID_REQUEST, "invalid JSON-RPC 2.0 request")
is_notification = "id" not in msg
id_ = msg.get("id")
method = msg.get("method")
params = msg.get("params") or {}
if method == "initialize":
result = {
"protocolVersion": _PROTOCOL_VERSION,
"serverInfo": {"name": "hanzo-studio", "version": __version__},
"capabilities": {"tools": {"listChanged": False}},
}
elif method == "tools/list":
result = {"tools": _TOOLS}
elif method == "tools/call":
name = params.get("name")
arguments = params.get("arguments") or {}
try:
result = await _call_tool(request, server, name, arguments)
except studio_home.DispatchError as e:
result = _content({"error": str(e)}, is_error=True)
if result is None:
result = _content({"error": f"unknown tool: {name}"}, is_error=True)
else:
return None if is_notification else _error(id_, _METHOD_NOT_FOUND, f"unknown method: {method}")
return None if is_notification else _result(id_, result)
def add_mcp_routes(routes: web.RouteTableDef, server) -> None:
"""Register POST /v1/mcp — the studio MCP-over-HTTP endpoint."""
@routes.post("/v1/mcp")
async def mcp_endpoint(request: web.Request):
try:
msg = await request.json()
except Exception:
return web.json_response(_error(None, _PARSE_ERROR, "invalid JSON"), status=400)
resp = await _handle(request, server, msg)
if resp is None: # notification: acknowledge with no body
return web.Response(status=202)
return web.json_response(resp)
+806 -117
View File
@@ -3,161 +3,850 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Hanzo Studio</title>
<style>
:root{--bg:#0b0b0d;--panel:#131317;--line:#232329;--txt:#ececf1;--dim:#9a9aa5;
--brand:#e8ff47;--ok:#3ddc84;--warn:#ffb648;--pub:#6aa5ff;--bad:#ff5d5d;font-size:15px}
:root{--bg:#0a0a0b;--panel:#131317;--panel2:#17171c;--line:#232329;--txt:#ececf1;--dim:#8a8a95;
--brand:#f4f4f6;--ok:#3ddc84;--warn:#ffb648;--pub:#6aa5ff;font-size:15px}
*{box-sizing:border-box;margin:0}
body{background:var(--bg);color:var(--txt);font:400 1rem/1.45 Inter,system-ui,-apple-system,sans-serif}
header{display:flex;align-items:center;gap:16px;padding:14px 22px;border-bottom:1px solid var(--line);
position:sticky;top:0;background:rgba(11,11,13,.92);backdrop-filter:blur(8px);z-index:5}
header h1{font-size:1.05rem;font-weight:600;letter-spacing:.2px}
header .org{color:var(--dim);font-size:.85rem;border:1px solid var(--line);border-radius:99px;padding:3px 10px}
body{background:var(--bg);color:var(--txt);font:400 1rem/1.5 Inter,system-ui,-apple-system,sans-serif}
.serif{font-family:"Iowan Old Style","Palatino Linotype",Georgia,serif}
a{color:inherit}
header{display:flex;align-items:center;gap:12px;padding:14px 26px;border-bottom:1px solid var(--line);
position:fixed;top:0;left:0;right:0;background:rgba(10,10,11,.82);backdrop-filter:blur(14px);z-index:30;
transition:transform .28s ease,opacity .28s ease}
/* immersive: header hides; a top hover-zone or mouse-near-top reveals it */
body.immersive header{transform:translateY(-100%);opacity:0;border-bottom-color:transparent}
body.immersive header.reveal{transform:translateY(0);opacity:1}
#tophover{position:fixed;top:0;left:0;right:0;height:24px;z-index:29}
header .logo{width:26px;height:26px;border-radius:7px;display:block;flex:0 0 auto}
header h1{font-size:1.02rem;font-weight:600;letter-spacing:.2px}
header .beta{font-size:.62rem;color:var(--dim);border:1px solid var(--line);border-radius:5px;padding:1px 5px;text-transform:uppercase;letter-spacing:.08em}
header .sp{flex:1}
.btn{border:1px solid var(--line);background:var(--panel);color:var(--txt);border-radius:8px;
padding:7px 13px;font-size:.85rem;cursor:pointer;transition:.15s}
.btn:hover{border-color:var(--dim)}
.btn.brand{background:var(--brand);border-color:var(--brand);color:#111;font-weight:600}
nav{display:flex;gap:4px;padding:12px 22px 0}
nav button{background:none;border:none;color:var(--dim);font-size:.92rem;padding:8px 14px;cursor:pointer;
border-radius:8px 8px 0 0;border-bottom:2px solid transparent}
nav button.on{color:var(--txt);border-bottom-color:var(--brand)}
main{padding:18px 22px 60px;max-width:1500px;margin:0 auto}
.chips{display:flex;gap:8px;flex-wrap:wrap;margin:6px 0 18px}
.chip{border:1px solid var(--line);border-radius:99px;padding:4px 12px;font-size:.82rem;color:var(--dim);cursor:pointer}
header .lnk{color:var(--dim);font-size:.85rem;text-decoration:none}
header .user{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:99px;padding:3px 11px 3px 4px;cursor:pointer}
header .user .av{width:24px;height:24px;border-radius:50%;background:#26262e;display:flex;align-items:center;justify-content:center;font-size:.72rem;font-weight:600}
header .user .nm{font-size:.82rem;color:var(--dim)}
.btn{border:1px solid var(--line);background:var(--panel);color:var(--txt);border-radius:8px;padding:7px 13px;font-size:.85rem;cursor:pointer}
.btn:hover{border-color:var(--dim)} .btn.brand{background:var(--brand);border-color:var(--brand);color:#111;font-weight:600}
main{max-width:1120px;margin:0 auto;padding:12px 24px 80px}
body{padding-top:56px} /* clear the fixed header */
body.immersive{padding-top:0}
/* persistent live-queue bar — always visible so you can see what's generating */
#livebar{position:sticky;top:56px;z-index:19;max-width:1120px;margin:0 auto 8px;display:flex;align-items:center;gap:14px;
background:var(--panel);border:1px solid var(--line);border-radius:11px;padding:9px 15px;font-size:.84rem}
#livebar .lb-run{color:var(--ok);display:flex;align-items:center;gap:7px}
#livebar .lb-run .spin{width:9px;height:9px;border-radius:50%;background:var(--ok);animation:pulse 1.1s infinite}
@keyframes pulse{0%,100%{opacity:.35}50%{opacity:1}}
#livebar .lb-q{color:var(--dim)} #livebar .lb-eta{color:var(--warn)} #livebar .sp{flex:1}
#livebar a.lb-more{color:var(--dim);text-decoration:none;font-size:.8rem;border:1px solid var(--line);border-radius:7px;padding:3px 10px;cursor:pointer}
#livebar.idle{opacity:.72}
.hero{text-align:center;font-size:3rem;font-weight:500;margin:56px 0 26px;letter-spacing:-.01em}
/* prompt box */
.prompt{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:16px 16px 12px;max-width:820px;margin:0 auto}
.prompt textarea{width:100%;background:none;border:none;color:var(--txt);font:inherit;font-size:1.02rem;resize:none;outline:none;min-height:26px;max-height:180px}
.prompt textarea::placeholder{color:var(--dim)}
.pctl{display:flex;align-items:center;gap:8px;margin-top:12px}
.pchip{display:flex;flex-direction:column;line-height:1.1;border:1px solid var(--line);border-radius:9px;padding:5px 11px;background:var(--panel2);cursor:pointer;font-size:.8rem}
.pchip .k{color:var(--dim);font-size:.64rem;text-transform:uppercase;letter-spacing:.05em}
.pchip .v{color:var(--txt);font-weight:500}
.pico{border:1px solid var(--line);border-radius:9px;padding:8px 10px;background:var(--panel2);cursor:pointer;color:var(--dim);font-size:.9rem}
.psend{margin-left:auto;width:38px;height:38px;border-radius:10px;border:none;background:var(--brand);color:#111;font-size:1.1rem;cursor:pointer}
/* templates */
.sec-l{color:var(--dim);font-size:.82rem;margin:40px 0 12px}
.tgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px}
.tcard{background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden;cursor:pointer;transition:.12s}
.tcard:hover{border-color:#3a3a48;transform:translateY(-2px)}
.tcard .ic{aspect-ratio:16/10;display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:1.7rem;background:var(--panel2)}
.tcard .tl{padding:9px 11px;font-size:.83rem;font-weight:500}
.tcard .td{padding:0 11px 10px;font-size:.7rem;color:var(--dim)}
/* tabs */
.tabs{display:flex;gap:6px;align-items:center;margin:44px 0 16px;border-bottom:1px solid var(--line);padding-bottom:0}
.tabs button{background:none;border:none;color:var(--dim);font-size:.9rem;padding:8px 12px;cursor:pointer;border-radius:8px 8px 0 0;border-bottom:2px solid transparent}
.tabs button.on{color:var(--txt);border-bottom-color:var(--brand)}
.tabs .sp{flex:1}
.search{background:var(--panel);border:1px solid var(--line);border-radius:9px;padding:6px 12px;color:var(--txt);font:inherit;font-size:.85rem;width:220px}
.chips{display:flex;gap:8px;flex-wrap:wrap;margin:4px 0 18px}
.chip{border:1px solid var(--line);border-radius:99px;padding:4px 12px;font-size:.8rem;color:var(--dim);cursor:pointer}
.chip.on{color:#111;background:var(--brand);border-color:var(--brand);font-weight:600}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:16px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:14px;overflow:hidden;display:flex;flex-direction:column}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(215px,1fr));gap:15px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:13px;overflow:hidden;display:flex;flex-direction:column}
.card .im{aspect-ratio:2/3;background:#0f0f12 center/cover no-repeat;cursor:zoom-in}
.card .doc{aspect-ratio:2/3;display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:.8rem}
.card .meta{padding:10px 12px;display:flex;flex-direction:column;gap:8px}
.card .t{font-size:.83rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--txt)}
.card .sub{font-size:.72rem;color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.card .doc{aspect-ratio:2/3;display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:.8rem;text-align:center;padding:12px}
.card .meta{padding:10px 12px;display:flex;flex-direction:column;gap:7px}
.card .t{font-size:.82rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card .sub{font-size:.7rem;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.row{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
.st{font-size:.7rem;font-weight:600;border-radius:99px;padding:2px 9px;text-transform:uppercase;letter-spacing:.4px}
.st{font-size:.66rem;font-weight:600;border-radius:99px;padding:2px 8px;text-transform:uppercase;letter-spacing:.4px}
.st.draft{background:#2a2a31;color:var(--dim)}.st.approved{background:rgba(61,220,132,.15);color:var(--ok)}
.st.queued{background:rgba(255,182,72,.15);color:var(--warn)}.st.published{background:rgba(106,165,255,.18);color:var(--pub)}
.mini{border:1px solid var(--line);background:none;color:var(--dim);border-radius:7px;padding:4px 9px;font-size:.74rem;cursor:pointer}
.mini:hover{color:var(--txt);border-color:var(--dim)}
.mini.hot{color:#111;background:var(--brand);border-color:var(--brand);font-weight:600}
.list{display:flex;flex-direction:column;gap:10px}
.wf{display:flex;align-items:center;gap:12px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 16px}
.wf .name{font-size:.9rem;flex:1}
.empty{color:var(--dim);padding:40px;text-align:center}
#toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:#1c1c22;border:1px solid var(--line);
border-radius:10px;padding:10px 18px;font-size:.85rem;opacity:0;transition:.25s;pointer-events:none;max-width:80vw}
.mini{border:1px solid var(--line);background:none;color:var(--dim);border-radius:7px;padding:4px 9px;font-size:.72rem;cursor:pointer}
.mini:hover{color:var(--txt);border-color:var(--dim)} .mini.hot{color:#111;background:var(--brand);border-color:var(--brand);font-weight:600}
.empty{color:var(--dim);padding:44px;text-align:center}
#toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:#1c1c22;border:1px solid var(--line);border-radius:10px;padding:10px 18px;font-size:.85rem;opacity:0;transition:.25s;pointer-events:none;z-index:50}
#toast.show{opacity:1}
#zoom{position:fixed;inset:0;background:rgba(0,0,0,.88);display:none;align-items:center;justify-content:center;z-index:20;cursor:zoom-out}
#zoom{position:fixed;inset:0;background:rgba(0,0,0,.9);display:none;align-items:center;justify-content:center;z-index:40;cursor:zoom-out}
#zoom img{max-width:94vw;max-height:94vh;border-radius:8px}
dialog{background:var(--panel);color:var(--txt);border:1px solid var(--line);border-radius:14px;padding:22px;max-width:460px;width:92vw}
dialog::backdrop{background:rgba(0,0,0,.6)}
dialog textarea{width:100%;background:#0f0f12;color:var(--txt);border:1px solid var(--line);border-radius:8px;
padding:10px;font:inherit;font-size:.9rem;min-height:90px;margin:12px 0}
dialog::backdrop{background:rgba(0,0,0,.6)} dialog textarea{width:100%;background:#0f0f12;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:10px;font:inherit;font-size:.9rem;min-height:90px;margin:12px 0}
#bulkbar{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1a1a24;border:1px solid #34344a;border-radius:12px;padding:9px 14px;display:none;gap:9px;align-items:center;z-index:45;box-shadow:0 10px 32px #000a}
#bulkbar.on{display:flex} #bulkn{font-size:.82rem;color:#cfcfe0;margin-right:4px}
.card.sel-mode{cursor:pointer} .card.picked{outline:2px solid var(--brand);outline-offset:-2px}
/* cleaner grid: tools hidden until hover */
.card .tools{max-height:0;overflow:hidden;opacity:0;transition:.15s}
.card:hover .tools{max-height:60px;opacity:1;margin-top:2px}
.card .meta{gap:5px}
.card .pick{position:absolute;top:8px;left:9px;width:20px;height:20px;border-radius:5px;border:1.5px solid #fff;background:rgba(0,0,0,.5);display:none;align-items:center;justify-content:center;font-size:.8rem;color:#111;z-index:3}
.card{position:relative} .sel-mode .pick{display:flex} .picked .pick{background:var(--brand)}
/* YouTube-style queue + history */
.qcol{margin-bottom:26px} .qh{font-size:.9rem;font-weight:600;margin-bottom:10px;color:var(--txt);display:flex;align-items:center;gap:8px}
.qlist{display:flex;flex-direction:column;gap:8px}
.qrow{display:flex;align-items:center;gap:13px;background:var(--panel);border:1px solid var(--line);border-radius:11px;padding:9px 13px}
.qrow:hover{border-color:#333340}
.qrow .qthumb{width:60px;height:80px;border-radius:7px;background:#0f0f12 center/cover;flex:0 0 auto;font-size:1.1rem}
.qrow .qtitle{flex:1;min-width:0;font-size:.88rem;font-weight:500}
.qrow .qdesc{font-size:.76rem;color:#b3b3bd;margin-top:3px;line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.qrow .qsub{font-size:.72rem;color:var(--dim);margin-top:4px}
.stars{display:inline-flex;gap:1px;cursor:pointer} .stars span{font-size:1rem;color:#3a3a44;transition:.1s} .stars span.on{color:#ffcf4a}
.notebox{width:100%;background:#0f0f12;color:var(--txt);border:1px solid var(--line);border-radius:7px;padding:6px 9px;font:inherit;font-size:.76rem;margin-top:6px;min-height:34px;resize:vertical}
.fav{cursor:pointer;font-size:1rem;color:#3a3a44} .fav.on{color:#ffcf4a}
dialog .drop{border:1.5px dashed var(--line);border-radius:9px;padding:14px;text-align:center;color:var(--dim);font-size:.82rem;margin:8px 0;cursor:pointer}
.drop.drag{border-color:var(--brand);color:var(--txt)}
.prompt.drag{outline:2px dashed var(--brand);outline-offset:2px}
.dlgerr{display:none;color:#ff8f8f;background:rgba(192,57,43,.12);border:1px solid rgba(192,57,43,.4);border-radius:8px;padding:8px 11px;font-size:.8rem;margin:8px 0 0;white-space:pre-wrap}
.ctxlabel{font-size:.66rem;text-transform:uppercase;letter-spacing:.05em;color:var(--dim);margin:14px 0 6px}
.wst{font-size:.66rem;font-weight:600;border-radius:99px;padding:1px 7px}
.wst.queued{color:var(--warn)}.wst.running{color:var(--ok)}.wst.done{color:var(--pub)}.wst.failed{color:#ff8f8f}.wst.cancelled{color:var(--dim)}
.wmeta{font-size:.72rem;color:var(--dim);margin-top:3px;display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.wnode{border:1px solid var(--line);border-radius:6px;padding:0 6px;color:var(--txt)}
.qhead{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:0 0 14px;font-size:.8rem;color:var(--dim)}
.nodebadge{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:99px;padding:3px 11px;background:var(--panel)}
.nodebadge .dot{width:7px;height:7px;border-radius:50%;background:#555}
.nodebadge.on .dot{background:var(--ok)} .nodebadge.off{opacity:.6}
.qhead .mstat{margin-left:auto;color:var(--dim)}
#ctxdlg{max-width:560px}
.card .addref{position:absolute;top:8px;right:9px;width:26px;height:26px;border-radius:50%;border:none;background:rgba(0,0,0,.6);color:#fff;font-size:1rem;cursor:pointer;z-index:4;line-height:1}
.card .addref:hover{background:var(--brand);color:#111} .card .addref.on{background:var(--brand);color:#111}
.card .delx{position:absolute;top:8px;left:9px;width:26px;height:26px;border-radius:50%;border:none;background:rgba(0,0,0,.6);color:#fff;font-size:.85rem;cursor:pointer;z-index:4;line-height:1;opacity:.8}
.card .delx:hover{background:#c0392b;color:#fff;opacity:1}
#reftray{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1a1a24;border:1px solid #34344a;border-radius:12px;padding:10px 14px;display:none;gap:11px;align-items:center;z-index:44;box-shadow:0 10px 32px #000a}
#reftray.on{display:flex} #reftray .rt{width:38px;height:56px;object-fit:cover;border-radius:5px;border:1px solid var(--line)}
/* ── Immersive full-viewport stage (editorial hero) ────────────────────────── */
#stage{position:relative;width:100vw;height:100vh;overflow:hidden;background:#000;display:none;user-select:none}
body.immersive #stage{display:block}
#stage .slide{position:absolute;inset:0;background:#000 center/cover no-repeat;opacity:0;transition:opacity .6s ease}
#stage .slide.on{opacity:1}
#stage .cap{position:absolute;left:48px;bottom:56px;z-index:6;max-width:60vw;text-shadow:0 2px 20px rgba(0,0,0,.6)}
#stage .cap .k{font-size:.72rem;letter-spacing:.18em;text-transform:uppercase;color:rgba(255,255,255,.75)}
#stage .cap .t{font-size:2.2rem;font-weight:500;color:#fff;margin-top:6px;font-family:"Iowan Old Style",Georgia,serif}
#stage .cap .tools{display:flex;gap:8px;margin-top:14px;opacity:0;transition:.2s} #stage:hover .cap .tools{opacity:1}
#stage .nav{position:absolute;top:0;bottom:0;width:16vw;z-index:5;cursor:pointer;display:flex;align-items:center;color:rgba(255,255,255,.0);font-size:2rem;transition:.2s}
#stage .nav:hover{color:rgba(255,255,255,.9)} #stage .nav.prev{left:0;justify-content:flex-start;padding-left:28px} #stage .nav.next{right:0;justify-content:flex-end;padding-right:28px}
#stage .dots{position:absolute;bottom:26px;left:50%;transform:translateX(-50%);display:flex;gap:7px;z-index:6}
#stage .dots span{width:6px;height:6px;border-radius:50%;background:rgba(255,255,255,.35);cursor:pointer} #stage .dots span.on{background:#fff;width:20px;border-radius:4px}
#stage .exit{position:absolute;top:18px;right:22px;z-index:7;color:#fff;background:rgba(0,0,0,.4);border:1px solid rgba(255,255,255,.25);border-radius:8px;padding:6px 12px;font-size:.8rem;cursor:pointer;opacity:0;transition:.2s} #stage:hover .exit{opacity:1}
.viewbtn{border:1px solid var(--line);background:var(--panel);color:var(--txt);border-radius:8px;padding:7px 13px;font-size:.85rem;cursor:pointer}
/* ── Hanzo AI chat widget ──────────────────────────────────────────────────── */
#chatfab{position:fixed;bottom:22px;right:22px;width:52px;height:52px;border-radius:50%;background:var(--brand);color:#111;border:none;font-size:1.4rem;cursor:pointer;z-index:48;box-shadow:0 8px 24px #0008;display:flex;align-items:center;justify-content:center}
#chatpanel{position:fixed;bottom:22px;right:22px;width:380px;max-width:92vw;height:560px;max-height:82vh;background:#0e0e12;border:1px solid var(--line);border-radius:16px;z-index:49;display:none;flex-direction:column;overflow:hidden;box-shadow:0 20px 60px #000b}
#chatpanel.on{display:flex} #chatpanel .ch{display:flex;align-items:center;gap:9px;padding:14px 16px;border-bottom:1px solid var(--line)}
#chatpanel .ch .av{width:26px;height:26px;border-radius:7px;background:#111;display:flex;align-items:center;justify-content:center}
#chatpanel .ch b{font-size:.9rem} #chatpanel .ch .x{margin-left:auto;cursor:pointer;color:var(--dim);font-size:1.2rem}
#chatmsgs{flex:1;overflow:auto;padding:14px;display:flex;flex-direction:column;gap:10px}
.cmsg{max-width:86%;padding:9px 12px;border-radius:12px;font-size:.86rem;line-height:1.45;white-space:pre-wrap}
.cmsg.u{align-self:flex-end;background:var(--brand);color:#111;border-bottom-right-radius:3px}
.cmsg.a{align-self:flex-start;background:#1a1a20;border:1px solid var(--line);border-bottom-left-radius:3px}
#chatbar{display:flex;gap:8px;padding:12px;border-top:1px solid var(--line)}
#chatbar input{flex:1;background:#0a0a0d;border:1px solid var(--line);border-radius:9px;color:var(--txt);padding:9px 11px;font:inherit;font-size:.85rem}
/* ── Responsive: tablet ≤900px, phone ≤600px ─────────────────────────────── */
@media (max-width:900px){
main{padding:12px 16px 90px}
.hero{font-size:2.1rem;margin:34px 0 20px}
.grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:11px}
.tgrid{grid-template-columns:repeat(auto-fill,minmax(120px,1fr))}
#livebar{margin-left:16px;margin-right:16px}
#chatpanel{width:calc(100vw - 24px);height:70vh}
}
@media (max-width:600px){
header{padding:11px 14px;gap:9px}
header h1{font-size:.95rem} header .beta{display:none} header .lnk{display:none}
header .user .nm{display:none} /* avatar only on phone */
.hero{font-size:1.6rem;margin:22px 0 16px}
.prompt{padding:12px 12px 10px;border-radius:13px}
.prompt textarea{font-size:.95rem}
.pctl{flex-wrap:wrap;gap:6px} .psend{margin-left:0}
.grid{grid-template-columns:repeat(auto-fill,minmax(46vw,1fr));gap:9px}
.tgrid{grid-template-columns:repeat(auto-fill,minmax(44vw,1fr))}
.sec-l{margin:26px 0 10px}
.tabs{overflow-x:auto;white-space:nowrap;-webkit-overflow-scrolling:touch}
.tabs button{padding:8px 10px;font-size:.84rem}
.search{width:120px} .tabs .sp{display:none}
#livebar{flex-wrap:wrap;font-size:.78rem}
#bulkbar,#reftray{left:8px;right:8px;transform:none;flex-wrap:wrap;justify-content:center}
#chatfab{bottom:16px;right:16px}
#chatpanel{bottom:0;right:0;left:0;width:100vw;height:82vh;max-height:82vh;border-radius:16px 16px 0 0}
.card .delx,.card .addref{width:30px;height:30px} /* bigger tap targets */
}
/* touch: no hover-reveal — always show card tools on touch devices */
@media (hover:none){
.card .tools{max-height:none;opacity:1;margin-top:2px}
}
</style>
<header>
<h1>Hanzo Studio</h1><span class="org" id="org"></span><span class="sp"></span>
<svg class="logo" viewBox="0 0 520 520" xmlns="http://www.w3.org/2000/svg"><rect width="520" height="520" rx="120" fill="#111"/><g transform="translate(100,100) scale(4.78)" fill="#fff"><path d="M22.21 67V44.6369H0V67H22.21Z"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z"/><path d="M22.21 0H0V22.3184H22.21V0Z"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z"/></g></svg>
<h1>Hanzo Studio</h1><span class="beta">Beta</span><span class="sp"></span>
<a class="lnk" href="/?advanced=1">Advanced mode</a>
<button class="btn" onclick="load()">Refresh</button>
<a class="btn brand" href="/?advanced=1" style="text-decoration:none">Advanced mode</a>
<div class="user" id="user" title="Account"><span class="av" id="av">·</span><span class="nm" id="org"></span></div>
</header>
<nav>
<button class="on" data-tab="assets" onclick="tab('assets')">Assets</button>
<button data-tab="pipelines" onclick="tab('pipelines')">Pipelines</button>
<button data-tab="runs" onclick="tab('runs')">Runs</button>
</nav>
<div id="tophover"></div>
<div id="livebar" class="idle">
<span id="lbstatus" class="lb-q">Checking queue…</span>
<span class="sp"></span>
<a class="lb-more" onclick="tab('runs');document.getElementById('runs').scrollIntoView({behavior:'smooth'})">Queue &amp; History →</a>
</div>
<main>
<h2 class="hero serif">What should we create?</h2>
<div class="prompt">
<textarea id="pin" rows="1" placeholder="Describe what you want to create…"></textarea>
<div class="pctl">
<span class="pico" title="Attach a photo" onclick="attachPick()">+</span>
<input id="attachfile" type="file" accept="image/*" multiple hidden>
<span class="pchip" onclick="pickPreset()"><span class="k">Design system</span><span class="v" id="ds">None</span></span>
<span class="pchip" onclick="tab('templates')"><span class="k">Template</span><span class="v" id="tmpl">None</span></span>
<span class="pico" title="Code">&lt;/&gt;</span>
<span class="pchip" style="margin-left:auto"><span class="k">Model</span><span class="v">Opus 4.8</span></span>
<button class="psend" onclick="create()" title="Create"></button>
</div>
</div>
<div class="sec-l">Use a template</div>
<div class="tgrid" id="tcards"></div>
<div class="tabs">
<button class="on" data-tab="assets" onclick="tab('assets')">Assets</button>
<button data-tab="templates" onclick="tab('templates')">Templates</button>
<button data-tab="pipelines" onclick="tab('pipelines')">Workflows</button>
<button data-tab="runs" onclick="tab('runs')">Queue &amp; History</button>
<span class="sp"></span>
<button class="btn" id="selbtn" onclick="toggleSel()" style="padding:6px 12px">Select</button>
<input class="search" id="q" placeholder="Search…" oninput="render()">
</div>
<section id="assets"><div class="chips" id="filters"></div><div class="grid" id="agrid"></div></section>
<section id="pipelines" hidden><div class="list" id="wflist"></div></section>
<section id="runs" hidden><div class="grid" id="rgrid"></div></section>
<div id="bulkbar"><span id="bulkn">0 selected</span>
<button class="mini hot" onclick="openCompose()" title="Combine selected assets into a new one">✦ Compose new</button>
<button class="mini" onclick="bulk('approved')">Approve</button>
<button class="mini" onclick="bulk('published')">Publish</button>
<button class="mini" onclick="bulkFork()">Fork / redo</button>
<button class="mini" onclick="bulk('deleted')">Delete</button>
<button class="mini" onclick="clearSel()">Cancel</button>
</div>
<section id="templates" hidden><div class="grid" id="tgall"></div></section>
<section id="pipelines" hidden><div class="grid" id="wfgrid"></div></section>
<section id="runs" hidden>
<div id="qhead" class="qhead"></div>
<div class="qcol"><h3 class="qh">🗂 Your work
<input class="search" id="wlq" placeholder="Search prompts…" oninput="worklog()" style="margin-left:10px"></h3>
<div class="qlist" id="wllist"></div></div>
<div class="qcol"><h3 class="qh">▶ Now running <span id="qrunN" class="cnt">0</span></h3><div class="qlist" id="qrun"></div></div>
<div class="qcol"><h3 class="qh">⏳ Up next <span id="qpendN" class="cnt">0</span> <button class="mini" onclick="clearQueue()" style="margin-left:8px">Clear all</button></h3><div class="qlist" id="qpend"></div></div>
<div class="qcol"><h3 class="qh">✓ History — rate &amp; note for better future generations</h3><div class="grid" id="rgrid"></div></div>
</section>
</main>
<div id="reftray">
<div style="font-size:.72rem;color:var(--dim);text-transform:uppercase;letter-spacing:.05em">Next generation</div>
<div id="refthumbs" style="display:flex;gap:6px;align-items:center"></div>
<input id="refprompt" placeholder="Describe what to make from these…" style="background:#0f0f12;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:7px 11px;font:inherit;font-size:.82rem;width:280px">
<button class="mini hot" onclick="genFromTray()">✦ Generate</button>
<button class="mini" onclick="clearTray()">Clear</button>
</div>
<div id="toast"></div>
<div id="zoom" onclick="this.style.display='none'"><img id="zoomimg"></div>
<dialog id="fixdlg">
<h3 style="font-size:1rem">Fix with chat</h3>
<p style="color:var(--dim);font-size:.83rem;margin-top:6px">Describe the change — a new render of this exact shot is queued with only that change applied.</p>
<textarea id="fixtext" placeholder="e.g. make the straps thicker and remove the blotchy skin texture"></textarea>
<div class="row" style="justify-content:flex-end">
<button class="mini" onclick="fixdlg.close()">Cancel</button>
<button class="mini hot" onclick="sendFix()">Queue fix</button>
<h3 id="fixtitle" style="font-size:1rem">Fix with chat</h3>
<p id="fixdesc" style="color:var(--dim);font-size:.83rem;margin-top:6px">Describe the change — a new render is queued from this image. Add reference photos (drag in, or pick from your history) to guide it.</p>
<div class="row" id="fixrefs" style="margin:12px 0 0;flex-wrap:wrap"></div>
<div class="drop" id="fixdrop">Drag images here, or <b>click to upload</b> references</div>
<input id="fixfile" type="file" accept="image/*" multiple hidden>
<textarea id="fixtext" placeholder="e.g. make the straps thicker, smooth the skin"></textarea>
<div id="gpuwarn" style="display:none;color:var(--warn);font-size:.78rem;margin:-4px 0 8px"></div>
<div id="fixerr" class="dlgerr"></div>
<div class="row" style="justify-content:space-between">
<button class="mini" id="fixhistbtn" onclick="toggleFixHistory()">+ From history</button>
<div class="row"><button class="mini" onclick="fixdlg.close()">Cancel</button><button class="mini hot" onclick="sendFix()">Queue fix</button></div>
</div>
<div id="fixhist" style="display:none;max-height:210px;overflow:auto;margin-top:10px;border-top:1px solid var(--line);padding-top:10px"></div>
</dialog>
<dialog id="tmpldlg">
<h3 style="font-size:1rem">★ Save as template</h3>
<p style="color:var(--dim);font-size:.83rem;margin-top:6px">Turn this favorited generation into a reusable template — its workflow becomes a one-click KIND you can run again with new inputs.</p>
<label style="display:block;font-size:.72rem;color:var(--dim);margin:12px 0 4px;text-transform:uppercase;letter-spacing:.04em">Template name</label>
<input id="tmplname" style="width:100%;background:#0f0f12;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:9px 11px;font:inherit" placeholder="e.g. Karma ghost mannequin — white studio">
<label style="display:block;font-size:.72rem;color:var(--dim);margin:12px 0 4px;text-transform:uppercase;letter-spacing:.04em">What it makes</label>
<input id="tmpldesc" style="width:100%;background:#0f0f12;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:9px 11px;font:inherit" placeholder="e.g. Product on invisible form, real fabric">
<div id="tmplsteps" style="color:var(--dim);font-size:.78rem;margin-top:12px;line-height:1.6"></div>
<div class="row" style="justify-content:flex-end;margin-top:8px"><button class="mini" onclick="tmpldlg.close()">Cancel</button><button class="mini hot" onclick="saveTemplate()">Save template</button></div>
</dialog>
<dialog id="ctxdlg">
<div class="row"><h3 id="ctxtitle" style="font-size:1rem;flex:1">Work item</h3><button class="mini" onclick="ctxdlg.close()">Close</button></div>
<div id="ctxbody" style="margin-top:6px"></div>
<div class="row" style="justify-content:flex-end;margin-top:14px;gap:6px">
<button class="mini" id="ctxcancel" onclick="cancelCtx()" style="display:none">✕ Cancel job</button>
<button class="mini" id="ctxedit" onclick="editCtx()" style="display:none">✎ Edit</button>
<button class="mini" id="ctxreuse" onclick="reuseCtx()" title="Open Fix primed with this prompt + references">↻ Reuse context</button>
<button class="mini hot" id="ctxfix" onclick="fixCtx()">Fix this version</button>
</div>
</dialog>
<dialog id="compdlg">
<h3 style="font-size:1rem">✦ Compose new from <span id="compn">0</span> assets</h3>
<p style="color:var(--dim);font-size:.83rem;margin-top:6px">Explain HOW to use the selected assets — e.g. "put the garment from the first onto the model in the second, keep the second's lighting."</p>
<div class="row" id="compthumbs" style="margin:10px 0 0;flex-wrap:wrap"></div>
<textarea id="comptext" placeholder="e.g. combine these — garment from #1 on the model + pose from #2, studio white background"></textarea>
<div id="gpuwarn2" style="display:none;color:var(--warn);font-size:.78rem;margin:-4px 0 8px"></div>
<div class="row" style="justify-content:flex-end"><button class="mini" onclick="compdlg.close()">Cancel</button><button class="mini hot" onclick="sendCompose()">Generate</button></div>
</dialog>
<script>
let DATA={assets:[],workflows:[]},FILTER='all',FIXPATH=null;
// ── Template / KIND catalog — everything Studio supports ─────────────────────
const TEMPLATES=[
// ── Fashion / commerce (the proven karma workflows, systematized) ──
{k:'cad_ghost', t:'CAD → Ghost mannequin', d:'Real fabric, invisible form', ic:'👕', wf:'cad_ghost', grp:'Fashion'},
{k:'outfit', t:'Outfit switcher', d:'Swap clothes, keep model+light', ic:'🔁', wf:'outfit', grp:'Fashion'},
{k:'product', t:'Product photography', d:'Ecom front·side·back', ic:'🛍', wf:'product', grp:'Fashion'},
{k:'lifestyle', t:'Lifestyle photography', d:'Same model/3D, in scene', ic:'🌅', wf:'lifestyle', grp:'Fashion'},
{k:'cad_mockup',t:'CAD → mockup', d:'Fashion / packaging', ic:'✳️', wf:'cad_mockup', grp:'Fashion'},
{k:'cad_3d', t:'CAD → 3D + drape', d:'Multi-view → 3D, fabric drape',ic:'🧊', wf:'cad_3d', grp:'Fashion'},
// ── Media ──
{k:'video', t:'Video', d:'Wan · LTX · Hunyuan', ic:'🎬', wf:'video', grp:'Media'},
{k:'music', t:'Music', d:'ACE-Step · song', ic:'🎵', wf:'music', grp:'Media'},
{k:'voice', t:'Voice', d:'Narration · TTS', ic:'🎙', wf:'voice', grp:'Media'},
// ── Marketing ──
{k:'social', t:'Social / Ad', d:'Posts · ads · campaigns', ic:'📣', wf:'social', grp:'Marketing'},
{k:'email', t:'Marketing email', d:'HTML / MJML', ic:'✉️', wf:'email', grp:'Marketing'},
{k:'deck', t:'Deck', d:'Sales · investor', ic:'📊', wf:'deck', grp:'Marketing'},
// ── Documents ──
{k:'doc', t:'Document', d:'Word · guide', ic:'📄', wf:'doc', grp:'Documents'},
{k:'sheet', t:'Spreadsheet', d:'xlsx · data', ic:'🧮', wf:'sheet', grp:'Documents'},
{k:'paper', t:'Paper (LaTeX)', d:'Academic · PDF', ic:'📐', wf:'paper', grp:'Documents'},
// ── Web ──
{k:'site', t:'Website / App', d:'From hanzo.app templates', ic:'🖥', wf:'site', grp:'Web'},
];
let DATA={assets:[],workflows:[]},FILTER='all';
const $=id=>document.getElementById(id);
const toast=m=>{const t=$('toast');t.textContent=m;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),3200)};
const viewURL=v=>`/view?filename=${encodeURIComponent(v.filename)}&subfolder=${encodeURIComponent(v.subfolder)}&type=${v.type}`;
function tab(name){document.querySelectorAll('nav button').forEach(b=>b.classList.toggle('on',b.dataset.tab===name));
for(const s of ['assets','pipelines','runs'])$(s).hidden=s!==name; if(name==='runs')runs();}
async function load(){
const r=await fetch('/v1/library');DATA=await r.json();
$('org').textContent=DATA.org||'';
const groups=[...new Set(DATA.assets.map(a=>a.design).filter(Boolean))].sort();
$('filters').innerHTML=['all','marketing',...groups].map(g=>`<span class="chip ${g===FILTER?'on':''}" onclick="FILTER='${g}';render()">${g}</span>`).join('');
render();pipes();
const toast=m=>{const t=$('toast');t.textContent=m;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),3000)};
const imgURL=a=>`/v1/library/file?path=${encodeURIComponent(a.path)}`;
const thumbURL=a=>`/v1/library/file?thumb=1&path=${encodeURIComponent(a.path)}`;
function tcard(x){return `<div class="tcard" onclick="pickTemplate('${x.k}','${x.t.replace(/'/g,"")}')"><div class="ic">${x.ic}</div><div class="tl">${x.t}</div><div class="td">${x.d}</div></div>`;}
function tab(name){document.querySelectorAll('.tabs button').forEach(b=>b.classList.toggle('on',b.dataset.tab===name));
for(const s of ['assets','templates','pipelines','runs'])$(s).hidden=s!==name;
if(name==='runs')runs(); if(name==='pipelines')pipes();}
function pickTemplate(k,t){$('tmpl').textContent=t; tab('assets'); $('pin').focus(); toast('Template: '+t);}
function pickPreset(){toast('Design systems / presets — coming in the preset library');}
function create(){const p=$('pin').value.trim(); const t=$('tmpl').textContent;
if(!p&&t==='None'){toast('Describe what to create, or pick a template');return;}
toast('Creation flow wires copilot → exec/tasks (HIP-0506 phase 1)');
// real path once phase 1 lands: POST /v1/studio/create {prompt,template,design_system}
}
async function load(){
const r=await fetch('/v1/library'); DATA=await r.json(); $('org').textContent=DATA.org||'';
$('tcards').innerHTML=TEMPLATES.slice(0,7).map(tcard).join('');
// Templates tab: grouped by category
const grps=[...new Set(TEMPLATES.map(t=>t.grp))];
$('tgall').innerHTML=grps.map(g=>`<div style="grid-column:1/-1;color:var(--dim);font-size:.8rem;margin:8px 0 2px">${g}</div>`+
TEMPLATES.filter(t=>t.grp===g).map(tcard).join('')).join('');
const groups=[...new Set(DATA.assets.map(a=>a.design).filter(Boolean))].sort();
const delN=DATA.assets.filter(a=>a.status==='deleted').length;
const chips=['all','marketing',...groups];
$('filters').innerHTML=chips.map(g=>`<span class="chip ${g===FILTER?'on':''}" onclick="FILTER='${g}';render()">${g}</span>`).join('')
+`<span class="chip ${FILTER==='deleted'?'on':''}" onclick="FILTER='deleted';render()" style="margin-left:auto">🗑 Deleted${delN?' ('+delN+')':''}</span>`;
render();
}
let SELMODE=false; const SEL=new Set();
function toggleSel(){SELMODE=!SELMODE;$('selbtn').classList.toggle('brand',SELMODE);if(!SELMODE)SEL.clear();updateBulk();render();}
function clearSel(){SEL.clear();SELMODE=false;$('selbtn').classList.remove('brand');updateBulk();render();}
function updateBulk(){$('bulkbar').classList.toggle('on',SELMODE&&SEL.size>0);$('bulkn').textContent=SEL.size+' selected';}
function pick(ev,path){ev.stopPropagation();if(SEL.has(path))SEL.delete(path);else SEL.add(path);updateBulk();render();}
function render(){
const rows=DATA.assets.filter(a=>FILTER==='all'?true:FILTER==='marketing'?(a.path||'').startsWith('marketing/'):a.design===FILTER);
$('agrid').innerHTML=rows.map((a,i)=>{
const img=a.view&&/\.(png|jpe?g|webp)$/i.test(a.view.filename);
const prov=a.prov&&a.prov.workflow?`· ${a.prov.workflow}${a.prov.cfg?` · cfg ${a.prov.cfg}`:''}`:'';
return `<div class="card">
${img?`<div class="im" style="background-image:url('${viewURL(a.view)}')" onclick="zoom('${viewURL(a.view)}')"></div>`
const q=($('q').value||'').toLowerCase();
const showDel=FILTER==='deleted';
let rows=DATA.assets.filter(a=>{
if(showDel)return a.status==='deleted';
if(a.status==='deleted')return false; // hide trashed from normal views
return FILTER==='all'?true:FILTER==='marketing'?(a.path||'').startsWith('marketing/'):a.design===FILTER;
});
if(q)rows=rows.filter(a=>(a.path||'').toLowerCase().includes(q));
$('agrid').innerHTML=rows.map(a=>{
const i=DATA.assets.indexOf(a);
const img=/\.(png|jpe?g|webp)$/i.test(a.path||'');
const prov=a.prov&&a.prov.workflow?` · ${a.prov.workflow}`:'';
const picked=SEL.has(a.path);
const cardClick=SELMODE?`onclick="pick(event,'${a.path.replace(/'/g,"\\'")}')"`:'';
const inTray=trayHasLib(a.path);
const pe=a.path.replace(/'/g,"\\'");
return `<div class="card ${SELMODE?'sel-mode':''} ${picked?'picked':''}" ${cardClick}>
<div class="pick">${picked?'✓':''}</div>
${img&&!SELMODE?`<button class="addref ${inTray?'on':''}" title="Add to next generation" onclick="event.stopPropagation();addRef('${pe}')">${inTray?'✓':'+'}</button>`:''}
${img&&!SELMODE&&!showDel?`<button class="delx" title="Move to Deleted (recoverable)" onclick="event.stopPropagation();quickDel('${pe}')">🗑</button>`:''}
${img?`<div class="im" style="background-image:url('${thumbURL(a)}')" ${SELMODE?'':`onclick="zoom('${imgURL(a)}')"`}></div>`
:`<div class="doc">${(a.path||'').split('/').pop()}</div>`}
<div class="meta">
<span class="t">${(a.path||'').split('/').pop()}</span>
<span class="sub">${a.design||a.kind||''} ${prov}</span>
<span class="sub">${a.design||a.kind||''}${prov}</span>
<div class="row"><span class="st ${a.status}">${a.status}</span></div>
<div class="row">
${SELMODE?'':`<div class="row tools">
${showDel?`<button class="mini" onclick="setSt(${i},'draft')">Recover</button>`:`
${a.status!=='approved'?`<button class="mini" onclick="setSt(${i},'approved')">Approve</button>`:''}
${a.status!=='queued'?`<button class="mini" onclick="setSt(${i},'queued')">Queue</button>`:''}
${a.status!=='published'?`<button class="mini" onclick="setSt(${i},'published')">Publish</button>`:''}
${a.status!=='draft'?`<button class="mini" onclick="setSt(${i},'draft')">Draft</button>`:''}
</div>
${img?`<div class="row">
<button class="mini" onclick="rerun(${i})">Re-run</button>
<button class="mini hot" onclick="openFix(${i})">Fix with chat</button>
</div>`:''}
</div></div>`;}).join('')||'<div class="empty">No assets yet — run a pipeline.</div>';
${img?`<button class="mini" onclick="rerun(${i})">Re-run</button><button class="mini" onclick="openLineage('${pe}')" title="Show this image's versions (what it came from + later fixes)">Versions</button><button class="mini hot" onclick="openFix(${i})">Fix</button>`:''}
<button class="mini" onclick="setSt(${i},'deleted')" title="Move to Deleted (recoverable)">🗑</button>`}
</div>`}
</div></div>`;}).join('')||`<div class="empty">${showDel?'Deleted is empty.':'No assets in this view yet.'}</div>`;
}
function pipes(){
$('wflist').innerHTML=(DATA.workflows||[]).map(w=>`<div class="wf"><span class="name">${w}</span>
<a class="mini" style="text-decoration:none" href="/?advanced=1&workflow=${encodeURIComponent(w)}">Edit</a>
<a class="mini hot" style="text-decoration:none" href="/?advanced=1&workflow=${encodeURIComponent(w)}&run=1">Run</a></div>`).join('')
||'<div class="empty">No saved workflows for this org.</div>';
async function bulk(status){
if(!SEL.size)return;
if(status==='deleted'&&!confirm('Move '+SEL.size+' asset(s) to Deleted? (recoverable)'))return;
const paths=[...SEL];
for(const p of paths){await fetch('/v1/library/status',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:p,status})});
const a=DATA.assets.find(x=>x.path===p);if(a)a.status=status;}
toast(paths.length+' → '+status); clearSel();
}
async function bulkFork(){ if(!SEL.size)return; toast('Fork/redo '+SEL.size+' — re-running each with a fresh seed');
for(const p of [...SEL]){await fetch('/v1/library/rerun',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:p})});}
clearSel(); }
// ── Shared image-upload lane (ONE implementation) ────────────────────────────
// Every "add a photo" surface (composer "+", Fix dialog, drag/drop, paste) funnels
// through here → POST /upload/image, which lands the file in the caller's org input
// dir (multi-tenant) and returns its name. Errors are RETURNED, never swallowed, so
// every caller can SHOW them (no-silent-swallow rule).
function errMsg(j){ if(!j)return ''; const e=j.error; return (e&&(e.message||e))||j.message||''; }
async function uploadImage(file){
if(!file||!file.type||!file.type.startsWith('image/'))return {error:'not an image file'};
const fd=new FormData(); fd.append('image',file,file.name||'upload.png');
try{
const r=await fetch('/upload/image',{method:'POST',body:fd});
let j={}; try{j=await r.json();}catch(_){}
if(!r.ok)return {error:errMsg(j)||('upload failed ('+r.status+')')};
if(!j.name)return {error:'upload failed (no name returned)'};
return {name:j.name};
}catch(e){ return {error:'upload failed ('+((e&&e.message)||e)+')'}; }
}
async function uploadMany(files,onName,onErr){ for(const f of [...files]){ const r=await uploadImage(f); if(r.name)onName(r.name); else onErr(r.error); } }
function wireDrop(el,onFiles){
['dragenter','dragover'].forEach(ev=>el.addEventListener(ev,e=>{e.preventDefault();el.classList.add('drag');}));
['dragleave','dragend'].forEach(ev=>el.addEventListener(ev,e=>{e.preventDefault();el.classList.remove('drag');}));
el.addEventListener('drop',e=>{e.preventDefault();el.classList.remove('drag');if(e.dataTransfer&&e.dataTransfer.files&&e.dataTransfer.files.length)onFiles(e.dataTransfer.files);});
}
function wirePaste(el,onFiles){ el.addEventListener('paste',e=>{const items=(e.clipboardData&&e.clipboardData.items)||[]; const fs=[...items].filter(i=>i.kind==='file').map(i=>i.getAsFile()).filter(Boolean); if(fs.length){e.preventDefault();onFiles(fs);}}); }
const inputThumb=name=>`/v1/library/file?input=1&path=${encodeURIComponent(name)}`;
// ── Reference tray: "+" on any photo / composer attach → next generation ──────
// Holds mixed refs: {kind:'lib', ref:<library path>} and {kind:'up', ref:<input name>}.
let TRAY=[];
const trayHasLib=path=>TRAY.some(r=>r.kind==='lib'&&r.ref===path);
function addRef(path){ const i=TRAY.findIndex(r=>r.kind==='lib'&&r.ref===path); if(i>=0)TRAY.splice(i,1); else{ if(TRAY.length>=5){toast('Up to 5 references');return;} TRAY.push({kind:'lib',ref:path,thumb:thumbURL({path})}); } drawTray(); render(); }
function addTrayUpload(name){ if(TRAY.length>=5){toast('Up to 5 references');return;} TRAY.push({kind:'up',ref:name,thumb:inputThumb(name)}); drawTray(); }
function removeTrayRef(i){ TRAY.splice(i,1); drawTray(); render(); }
function clearTray(){ TRAY=[]; drawTray(); render(); }
function drawTray(){
$('reftray').classList.toggle('on',TRAY.length>0);
$('refthumbs').innerHTML=TRAY.map((r,i)=>`<span style="position:relative;display:inline-block">
<img class="rt" src="${r.thumb}" title="${r.ref}">
<button onclick="removeTrayRef(${i})" title="Remove" style="position:absolute;top:-6px;right:-6px;width:16px;height:16px;border-radius:50%;border:none;background:#c0392b;color:#fff;font-size:.62rem;line-height:1;cursor:pointer">×</button></span>`).join('');
}
function attachPick(){ $('attachfile').click(); }
async function attachFiles(files){ await uploadMany(files, addTrayUpload, err=>toast('Upload failed: '+err)); }
async function genFromTray(){
const t=$('refprompt').value.trim();
if(!TRAY.length){toast('Add photos with + first');return;}
if(t.length<3){toast('Describe what to make');return;}
if(!(await gpuOnline()))toast('⚠ No GPU online — queues until a BYO-GPU connects');
const libs=TRAY.filter(r=>r.kind==='lib').map(r=>r.ref), ups=TRAY.filter(r=>r.kind==='up').map(r=>r.ref);
let r;
if(libs.length+ups.length===1){ // single ref → edit (library or uploaded base)
r=await fetch('/v1/library/fix',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify(libs.length?{path:libs[0],instruction:t}:{upload:ups[0],instruction:t})});
}else{ // 2-5 refs → compose
r=await fetch('/v1/library/compose',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths:libs,uploads:ups,instruction:t})});
}
const j=await r.json().catch(()=>({}));
if(r.ok){ toast('Queued → see Queue & History'); $('refprompt').value=''; clearTray(); }
else toast('Failed: '+(errMsg(j)||('error '+r.status)));
}
// ── GPU status: fix/compose need an ONLINE GPU worker (Qwen models live there) ──
let GPU_ONLINE=null, GPU_TS=0;
async function gpuOnline(){
// Check the FLEET (same source dispatch uses), not /v1/engines — a `hanzo gpu
// connect` box registers with the fleet, so /v1/engines missed it and the UI
// wrongly said "connect a GPU". Cache 15s so we don't hammer it.
if(GPU_ONLINE!==null && Date.now()-GPU_TS<15000)return GPU_ONLINE;
try{const s=await (await fetch('/v1/gpu-status')).json(); GPU_ONLINE=!!s.online;}
catch(_){GPU_ONLINE=false;}
GPU_TS=Date.now(); return GPU_ONLINE;
}
async function openCompose(){
if(SEL.size<2){toast('Select 2+ assets to compose');return;}
const paths=[...SEL]; $('compn').textContent=paths.length;
$('compthumbs').innerHTML=paths.map((p,i)=>`<div style="text-align:center"><img src="/v1/library/file?thumb=1&path=${encodeURIComponent(p)}" style="width:64px;height:96px;object-fit:cover;border-radius:6px;border:1px solid var(--line)"><div style="font-size:.62rem;color:var(--dim)">#${i+1}</div></div>`).join('');
$('comptext').value='';
$('gpuwarn2').style.display=(await gpuOnline())?'none':'block';
$('gpuwarn2').textContent='⚠ No GPU worker online — this queues but renders only when a BYO-GPU (e.g. spark) is connected.';
$('compdlg').showModal();
}
async function sendCompose(){
const t=$('comptext').value.trim(); if(t.length<3){toast('Describe how to combine them');return;}
const paths=[...SEL]; toast('Composing from '+paths.length+' assets…');
const r=await fetch('/v1/library/compose',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths,instruction:t})});
const j=await r.json().catch(()=>({}));
if(r.ok){ $('compdlg').close(); toast(`Compose queued (${j.refs} refs) → see Queue & History`); clearSel(); }
else{ $('gpuwarn2').style.display='block'; $('gpuwarn2').textContent='Couldnt queue: '+(errMsg(j)||('error '+r.status)); }
}
function pipes(){ $('wfgrid').innerHTML=(DATA.workflows||[]).map(w=>`<div class="card"><div class="doc">⚙︎ ${w}</div><div class="meta"><div class="row"><a class="mini" href="/?advanced=1&workflow=${encodeURIComponent(w)}">Edit</a><a class="mini hot" href="/?advanced=1&workflow=${encodeURIComponent(w)}&run=1">Run</a></div></div></div>`).join('')||'<div class="empty">No saved workflows for this org yet.</div>'; }
// ── YouTube-style Queue & History ───────────────────────────────────────────
let RATINGS={};
const vURL=im=>`/view?filename=${encodeURIComponent(im.filename)}&subfolder=${encodeURIComponent(im.subfolder||'')}&type=${im.type||'output'}`;
const promptTitle=v=>{ // pull the SaveImage prefix / positive text from the run's graph
const g=(v.prompt&&v.prompt[2])||{};
for(const n of Object.values(g)){if(n&&n.class_type==='SaveImage')return (n.inputs.filename_prefix||'render').split('/').pop();}
return 'render';
};
// ── Work log: the org's dispatched renders with real status + full context ────
let WL=[], CTX=null;
// live queue status (positions/ETAs/node) computed SERVER-SIDE; QLOCAL seeds the
// client-side elapsed tick between polls. NODES = the org's GPU nodes for badges.
let QST={items:{},medians:{},lanes:{},now:0}, QLOCAL=Date.now(), NODES=[];
const WSTATUS={queued:['⏳','queued'],running:['●','running'],done:['✓','done'],failed:['⚠','failed'],cancelled:['✕','cancelled']};
const fmtDur=s=>{ s=Math.max(0,Math.round(s)); if(s<60)return s+'s'; const m=Math.floor(s/60),r=s%60; if(s<3600)return m+'m'+(r?(' '+r+'s'):''); return Math.floor(s/3600)+'h '+Math.floor((s%3600)/60)+'m'; };
const fmtEta=s=>{ s=Math.max(0,Math.round(s)); return s<60?('~'+s+'s est'):('~'+Math.round(s/60)+' min est'); };
function wlThumb(it){ if(it.output)return thumbURL({path:it.output});
if(it.refs&&it.refs[0])return inputThumb(it.refs[0]);
if(it.parents&&it.parents[0])return thumbURL({path:it.parents[0]}); return ''; }
function qmetaHTML(it,e){
if(!e)return '';
const node=`<span class="wnode">⚙ ${(e.node||it.node||'gpu')}</span>`;
if(e.status==='running'){
const el=(e.elapsed_seconds||0)+(Date.now()-QLOCAL)/1000;
return `<div class="wmeta">${node} · #${e.position}/${e.of} · running <span id="el-${it.id}">${fmtDur(el)}</span>${e.eta_seconds!=null?(' · ~'+fmtDur(e.eta_seconds)+' left'):''}</div>`;
}
return `<div class="wmeta">${node} · #${e.position}/${e.of} in line · ${fmtEta(e.eta_seconds||0)}</div>`;
}
function wlRow(it){
const e=QST.items[it.id]; // live status wins over the stored one
const status=(e&&e.status)||it.status;
const [ic,lbl]=WSTATUS[status]||['·',status||''];
const th=wlThumb(it), when=it.ts?new Date(it.ts*1000).toLocaleString():'';
const active=status==='queued'||status==='running';
return `<div class="qrow" style="cursor:pointer" onclick="openContext('${it.id}')">
<div class="qthumb" style="${th?`background-image:url('${th}');background-size:cover;background-position:center`:'display:flex;align-items:center;justify-content:center;color:var(--dim)'}">${th?'':'🗂'}</div>
<div class="qtitle">${it.kind} · <span class="wst ${status}">${ic} ${lbl}</span>
<div class="qdesc">${(it.prompt||'(no prompt)').replace(/</g,'&lt;')}</div>
${active&&e?qmetaHTML(it,e):`<div class="qsub">${when}${it.node?(' · ⚙ '+it.node):''}${it.error?` · <span style="color:#ff8f8f">${it.error.replace(/</g,'&lt;')}</span>`:''}</div>`}</div>
${active?`<button class="mini" onclick="event.stopPropagation();openAmend('${it.id}')" title="Amend prompt/refs (re-queues)">Edit</button><button class="mini" onclick="event.stopPropagation();cancelJob('${it.id}')">Cancel</button>`:''}
<button class="mini" onclick="event.stopPropagation();openContext('${it.id}')">Open</button></div>`;
}
// server does the position/ETA math over the org's OWN jobs; the client only renders.
async function loadQueueStatus(){ let d; try{d=await (await fetch('/v1/queue/status')).json();}catch(_){d=null;} if(d){QST=d;QLOCAL=Date.now();} }
async function loadNodes(){ try{const d=await (await fetch('/v1/nodes')).json(); NODES=d.nodes||[];}catch(_){NODES=[];} renderQhead(); }
function renderQhead(){
if(!$('qhead'))return;
const badges=(NODES||[]).map(n=>`<span class="nodebadge ${n.online?'on':'off'}"><span class="dot"></span>${n.name}${n.online?'':' · offline'}</span>`).join('')
+`<span class="nodebadge on"><span class="dot"></span>studio pod</span>`;
const m=QST.medians||{}, ks=Object.keys(m);
const ms=ks.length?('median '+ks.map(k=>k+' ~'+fmtDur(m[k])).join(' · ')):'';
$('qhead').innerHTML=badges+(ms?`<span class="mstat">${ms}</span>`:'');
}
async function refreshQueue(){
const before=Object.keys(QST.items||{});
await loadQueueStatus(); await worklog(); renderQhead();
let fin=0; before.forEach(id=>{ if(!QST.items[id]){ const w=WL.find(x=>x.id===id); if(w&&w.status==='done')fin++; } });
if(fin>0)toast('✓ '+fin+' render'+(fin>1?'s':'')+' finished — in your library');
}
async function worklog(){
const q=($('wlq')&&$('wlq').value||'').trim();
let d={items:[]}; try{d=await (await fetch('/v1/worklog'+(q?('?q='+encodeURIComponent(q)):''))).json();}catch(_){}
WL=d.items||[];
$('wllist').innerHTML=WL.map(wlRow).join('')||'<div class="qsub" style="padding:6px 2px">No work yet — your renders will appear here with full context.</div>';
}
function pv(url,label,click){ return `<div style="text-align:center">
<img src="${url}" ${click?`onclick="${click}" style="cursor:pointer;`:'style="'}width:60px;height:88px;object-fit:cover;border-radius:6px;border:1px solid var(--line)">
<div style="font-size:.58rem;color:var(--dim);margin-top:2px">${label}</div></div>`; }
function ctxHTML(it,lin){
const parents=(it.parents||[]).map(p=>pv(thumbURL({path:p}),'from',`openLibItem('${p.replace(/'/g,"\\'")}')`)).join('');
const refs=(it.refs||[]).map(n=>pv(inputThumb(n),(it.uploads||[]).includes(n)?'upload':'ref')).join('');
const result=it.output?pv(thumbURL({path:it.output}),'result',`openLibItem('${it.output.replace(/'/g,"\\'")}')`):'';
const params=it.params&&Object.keys(it.params).length?Object.entries(it.params).map(([k,v])=>k+':'+v).join(' · '):'';
const trail=(it.events||[]).map(e=>`${e.s}${e.ts?(' '+new Date(e.ts*1000).toLocaleTimeString()):''}${e.note?(' — '+e.note.replace(/</g,'&lt;')):''}`).join(' → ');
const chain=[...lin.ancestors.map(a=>[a,a.kind||'ancestor']),...(it.output?[[{path:it.output},'● this']]:[]),...lin.descendants.map(d=>[d,d.kind||'later'])];
const chainHTML=chain.length>1?`<div class="ctxlabel">Versions</div><div style="display:flex;gap:8px;overflow-x:auto;padding-bottom:4px">${chain.map(([n,role])=>pv(thumbURL({path:n.path}),role,role==='● this'?'':`openLibItem('${(n.path||'').replace(/'/g,"\\'")}')`)).join('')}</div>`:'';
return `${it.prompt?`<div class="ctxlabel">Prompt</div><div style="font-size:.86rem;background:#0f0f12;border:1px solid var(--line);border-radius:8px;padding:9px 11px">${it.prompt.replace(/</g,'&lt;')}</div>`:''}
${(parents||refs||result)?`<div class="ctxlabel">References &amp; result</div><div class="row" style="flex-wrap:wrap">${parents}${refs}${result}</div>`:''}
<div class="ctxlabel">Workflow</div><div style="font-size:.82rem;color:var(--dim)">${it.kind} · ${it.status||''}${it.lane&&it.lane!=='local'?(' · '+it.lane):''}${params?(' · '+params):''}</div>
${trail?`<div class="ctxlabel">Process</div><div style="font-size:.78rem;color:var(--dim)">${trail}</div>`:''}
${it.error?`<div class="dlgerr" style="display:block">${it.error.replace(/</g,'&lt;')}</div>`:''}${chainHTML}`;
}
async function lineageFor(path){ if(!path)return {ancestors:[],descendants:[],node:''}; try{return await (await fetch('/v1/worklog/lineage?path='+encodeURIComponent(path))).json();}catch(_){return {ancestors:[],descendants:[],node:path};} }
async function openContext(id){
const it=WL.find(x=>x.id===id); if(!it)return; CTX=it;
$('ctxtitle').textContent=it.kind+' '+(it.status||'');
$('ctxbody').innerHTML=ctxHTML(it, await lineageFor(it.output));
const hasBase=!!(it.output||(it.parents&&it.parents[0]));
const live=it.status==='queued'||it.status==='running';
$('ctxreuse').style.display=hasBase?'':'none';
$('ctxfix').style.display=hasBase?'':'none';
$('ctxcancel').style.display=live?'':'none';
$('ctxedit').style.display=live?'':'none';
$('ctxdlg').showModal();
}
function cancelCtx(){ if(!CTX)return; ctxdlg.close(); cancelJob(CTX.id); }
function editCtx(){ if(!CTX)return; ctxdlg.close(); openAmend(CTX.id); }
async function openLineage(path){
CTX={output:path,parents:[],refs:[],uploads:[],prompt:'',kind:'asset',status:''};
$('ctxtitle').textContent='Versions'; $('ctxbody').innerHTML=ctxHTML(CTX, await lineageFor(path));
$('ctxreuse').style.display='none'; $('ctxfix').style.display=''; $('ctxdlg').showModal();
}
function openLibItem(path){ ctxdlg.close(); zoom(imgURL({path})); }
async function primeFix(base,prompt,libRefs,upRefs){
await openFixPath(base,prompt);
(libRefs||[]).filter(p=>p&&p!==base).forEach(p=>{ if(FIXREFS.length<MAXFIXREFS)FIXREFS.push({kind:'lib',ref:p,thumb:thumbURL({path:p})}); });
(upRefs||[]).forEach(n=>{ if(FIXREFS.length<MAXFIXREFS)FIXREFS.push({kind:'up',ref:n,thumb:inputThumb(n)}); });
drawFixRefs();
}
function reuseCtx(){ if(!CTX)return; const base=CTX.output||(CTX.parents&&CTX.parents[0]); if(!base){toast('No base image to reuse');return;} ctxdlg.close(); primeFix(base,CTX.prompt||'',CTX.parents||[],CTX.uploads||[]); }
function fixCtx(){ if(!CTX)return; const base=CTX.output||(CTX.parents&&CTX.parents[0]); if(!base){toast('No result to fix yet');return;} ctxdlg.close(); openFixPath(base,''); }
async function runs(){
const h=await (await fetch('/history?max_items=24')).json();
const items=Object.entries(h).reverse().map(([id,v])=>{
const ims=[];for(const o of Object.values(v.outputs||{}))for(const im of (o.images||[]))ims.push(im);
const ok=(v.status||{}).status_str||'?';
return {id,ok,im:ims[0]};
});
$('rgrid').innerHTML=items.map(r=>`<div class="card">
${r.im?`<div class="im" style="background-image:url('${viewURL(r.im)}')" onclick="zoom('${viewURL(r.im)}')"></div>`:`<div class="doc">no output</div>`}
<div class="meta"><span class="t">${r.im?r.im.filename:r.id.slice(0,8)}</span>
<div class="row"><span class="st ${r.ok==='success'?'approved':'draft'}">${r.ok}</span></div></div></div>`).join('')
||'<div class="empty">No runs yet.</div>';
refreshQueue(); loadNodes();
RATINGS=await (await fetch('/v1/library/ratings')).json().catch(()=>({}));
// live queue
let q={queue_running:[],queue_pending:[]}; try{q=await (await fetch('/queue')).json();}catch(_){}
const qr=q.queue_running||[], qp=q.queue_pending||[];
$('qrunN').textContent=qr.length; $('qpendN').textContent=qp.length;
// YouTube-style up-next row: reference thumbnail + title + brief description.
const qitem=(it,running,pos)=>{
const id=it[1], g=it[2]||{};
const nodes=Object.values(g);
const title=(nodes.find(n=>n.class_type==='SaveImage')?.inputs?.filename_prefix||id+'').split('/').pop();
// reference image(s) attached to this job (fix/compose sources live in input dir)
const refs=nodes.filter(n=>n.class_type==='LoadImage').map(n=>n.inputs?.image).filter(Boolean);
const thumb=refs.length?`/v1/library/file?thumb=1&input=1&path=${encodeURIComponent(refs[0])}`:'';
// brief description = the positive prompt / instruction
let desc='';
for(const n of nodes){ if(n.class_type&&n.class_type.indexOf('TextEncode')>=0){ const t=n.inputs?.prompt||n.inputs?.text||''; if(t&&!/blurry|deformed|watermark|ghosting/i.test(t.slice(0,40))){desc=t;break;} } }
desc=(desc||'').replace(/\.\s*(Photorealistic|Combine|keep the same).*$/i,'').trim().slice(0,140);
const eta=running?'rendering now':(pos!=null?`~${(pos+1)*ETA_MIN} min · #${pos+1} in line`:'queued');
return `<div class="qrow">
<div class="qthumb" style="${thumb?`background-image:url('${thumb}');background-size:cover;background-position:center`:'display:flex;align-items:center;justify-content:center;color:var(--dim)'}">${thumb?'':(running?'':'')}</div>
<div class="qtitle">${title}<div class="qdesc">${desc||'(no description)'}</div><div class="qsub">${running?'<span style=color:var(--ok)> </span>':''}${eta}${refs.length>1?` · ${refs.length} refs`:''}</div></div>
${running?'':`<button class="mini" onclick="cancelJob('${id}')">Remove</button>`}</div>`;};
// BYO-GPU renders (fix/compose on a connected GPU) — rendered on the worker,
// shown here so a dispatched fix is visible (was invisible before).
let gjobs=[]; try{gjobs=(await (await fetch('/v1/render-queue')).json()).jobs||[];}catch(_){}
const gjRow=j=>`<div class="qrow">
<div class="qthumb" style="${j.refs&&j.refs[0]?`background-image:url('/v1/library/file?thumb=1&input=1&path=${encodeURIComponent(j.refs[0])}');background-size:cover;background-position:center`:'display:flex;align-items:center;justify-content:center;color:var(--ok)'}">${j.refs&&j.refs[0]?'':'▶'}</div>
<div class="qtitle">${j.prefix||'render'}<div class="qsub"><span style=color:var(--ok)>● </span>rendering on ${j.node||'your GPU'} · ${Math.max(0,Math.round((j.age||0)/60))}m elapsed${QST.items[j.id]&&QST.items[j.id].eta_seconds!=null?(' · ~'+fmtDur(QST.items[j.id].eta_seconds)+' left'):''}</div></div>
${j.id?`<button class="mini" onclick="cancelJob('${j.id}')">Cancel</button>`:''}</div>`;
const gjHtml=gjobs.map(gjRow).join('');
$('qrun').innerHTML=gjHtml + qr.map(it=>qitem(it,true,null)).join('') || '<div class="qsub" style="padding:6px 2px">Idle — nothing rendering.</div>';
$('qpend').innerHTML=qp.map((it,i)=>qitem(it,false,i)).join('')||'<div class="qsub" style="padding:6px 2px">Nothing queued.</div>';
// history
const h=await (await fetch('/history?max_items=40')).json();
const items=Object.entries(h).reverse().map(([id,v])=>{const ims=[];for(const o of Object.values(v.outputs||{}))for(const im of (o.images||[]))if(im.type==='output')ims.push(im);
return {id,ok:(v.status||{}).status_str||'?',im:ims[0],title:promptTitle(v)};});
$('rgrid').innerHTML=items.map(r=>{
const rt=RATINGS[r.id]||{}; const st=rt.stars||0;
const stars=[1,2,3,4,5].map(n=>`<span class="${n<=st?'on':''}" onclick="rate('${r.id}',${n})">★</span>`).join('');
return `<div class="card">
${r.im?`<div class="im" style="background-image:url('${vURL(r.im)}')" onclick="zoom('${vURL(r.im)}')"></div>`:`<div class="doc">no output</div>`}
<div class="meta">
<span class="t">${r.title}</span>
<div class="row" style="justify-content:space-between">
<span class="stars">${stars}</span>
<span class="fav ${rt.favorite?'on':''}" onclick="toggleFav('${r.id}','${r.title}')" title="Favorite → save as template">★fav</span>
</div>
<textarea class="notebox" placeholder="Notes for the AI to improve next time…" onblur="rateNote('${r.id}',this.value)">${(rt.notes||'').replace(/</g,'&lt;')}</textarea>
</div></div>`;}).join('')||'<div class="empty">No runs yet.</div>';
}
async function setSt(i,status){
const a=DATA.assets[i];
const r=await fetch('/v1/library/status',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({path:a.path,status})});
if(r.ok){a.status=status;render();toast(`${a.path.split('/').pop()}${status}`);}
else toast('Failed: '+(await r.json()).error);
async function clearQueue(){if(!confirm('Clear all queued jobs?'))return;await fetch('/queue',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clear:true})});toast('Queue cleared');runs();}
async function rate(key,stars){await fetch('/v1/library/rate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key,stars})});RATINGS[key]={...(RATINGS[key]||{}),stars};runs();}
async function rateNote(key,notes){await fetch('/v1/library/rate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key,notes})});toast('Note saved — the AI will use it next time');}
let FAVKEY=null,FAVTITLE=null;
async function toggleFav(key,title){const cur=RATINGS[key]||{};const nv=!cur.favorite;
await fetch('/v1/library/rate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key,favorite:nv})});
RATINGS[key]={...cur,favorite:nv};
if(nv){FAVKEY=key;FAVTITLE=title;$('tmplname').value=title;$('tmpldesc').value='';
$('tmplsteps').innerHTML='This favorited run has an embedded workflow. Saving turns it into a reusable KIND:<br>1 · name it &amp; describe what it makes<br>2 · it appears under Templates for your org<br>3 · run it anytime with new inputs';
$('tmpldlg').showModal();}
else runs();
}
async function rerun(i){
const a=DATA.assets[i];toast('Queueing re-run…');
const r=await fetch('/v1/library/rerun',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({path:a.path})});
const j=await r.json();
toast(r.ok?`Re-run queued (${(j.prompt_ids||[]).length}) — watch Runs tab`:'Failed: '+j.error);
async function saveTemplate(){const nm=$('tmplname').value.trim();if(!nm){toast('Name the template');return;}
// Real save lands with the studio-app template store (HIP-0506 phase 3); for now
// persist intent on the rating entry so it's not lost and surfaces the guided flow.
await fetch('/v1/library/rate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key:FAVKEY,notes:'TEMPLATE: '+nm+' — '+$('tmpldesc').value})});
$('tmpldlg').close();toast('★ Saved "'+nm+'" — it will appear under Templates');runs();
}
function openFix(i){FIXPATH=DATA.assets[i].path;$('fixtext').value='';$('fixdlg').showModal();}
async function sendFix(){
const txt=$('fixtext').value.trim();if(!txt)return;
$('fixdlg').close();toast('Queueing fix…');
const r=await fetch('/v1/library/fix',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({path:FIXPATH,instruction:txt})});
const j=await r.json();
toast(r.ok?`Fix queued → ${j.output_prefix} — watch Runs tab`:'Failed: '+j.error);
async function setSt(i,status){const a=DATA.assets[i];const r=await fetch('/v1/library/status',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:a.path,status})});if(r.ok){a.status=status;render();toast(`${a.path.split('/').pop()}${status}`);}else toast('Failed');}
// Optimistic trash: mark deleted locally + re-render immediately (feels instant),
// then persist. On failure, revert. This is the always-visible 🗑 on each card.
async function quickDel(path){
const a=DATA.assets.find(x=>x.path===path); if(!a)return;
const prev=a.status; a.status='deleted'; render(); toast('Moved to Deleted');
try{const r=await fetch('/v1/library/status',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path,status:'deleted'})});
if(!r.ok)throw 0;}catch(_){a.status=prev;render();toast('Delete failed — restored');}
}
async function rerun(i){const a=DATA.assets[i];toast('Queueing re-run…');const r=await fetch('/v1/library/rerun',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:a.path})});const j=await r.json();toast(r.ok?`Re-run queued — see Runs`:'Failed');}
// ── Fix surface: base image + up to 4 references (history picks + drag/drop
// uploads). No references → the single-image edit (/v1/library/fix); any
// reference → the multi-input compose (/v1/library/compose) — same proven Qwen
// graph, base image first. 5-ref cap = base + 4. ─────────────────────────────
let FIXPATH=null; // the base image (library asset path)
let FIXREFS=[]; // [{kind:'lib'|'up', ref, thumb}] added references
let FIXMODE='fix'; // 'fix' (new render) | 'amend' (swap a queued job)
let AMENDID=null; // the queued prompt_id when FIXMODE==='amend'
let ORIGREFS=[]; // amend: the job's current refs (input-dir names), locked base
let MAXFIXREFS=4; // added-ref cap = 5 base count (set per open)
function drawFixRefs(){
let base;
if(FIXMODE==='amend'){
base=(ORIGREFS||[]).map(n=>`<div style="text-align:center"><img src="${inputThumb(n)}" style="width:56px;height:84px;object-fit:cover;border-radius:6px;border:2px solid var(--brand)"><div style="font-size:.6rem;color:var(--dim);margin-top:3px">current</div></div>`).join('')
||`<div style="font-size:.72rem;color:var(--dim)">Amending queued job</div>`;
}else{
base=`<div style="text-align:center"><img src="${thumbURL({path:FIXPATH})}" style="width:56px;height:84px;object-fit:cover;border-radius:6px;border:2px solid var(--brand)"><div style="font-size:.6rem;color:var(--dim);margin-top:3px">base</div></div>`;
}
const refs=FIXREFS.map((r,i)=>`<div style="position:relative;text-align:center">
<img src="${r.thumb}" style="width:56px;height:84px;object-fit:cover;border-radius:6px;border:1px solid var(--line)">
<button onclick="removeFixRef(${i})" title="Remove" style="position:absolute;top:-6px;right:-6px;width:18px;height:18px;border-radius:50%;border:none;background:#c0392b;color:#fff;font-size:.72rem;line-height:1;cursor:pointer">×</button>
<div style="font-size:.6rem;color:var(--dim);margin-top:3px">${r.kind==='up'?'upload':'ref'}</div></div>`).join('');
$('fixrefs').innerHTML=base+refs;
}
function removeFixRef(i){FIXREFS.splice(i,1);drawFixRefs();if($('fixhist').style.display!=='none')renderFixHist();}
function toggleFixHistory(){const h=$('fixhist');const show=h.style.display==='none';h.style.display=show?'block':'none';if(show)renderFixHist();}
function renderFixHist(){
const imgs=DATA.assets.filter(a=>/\.(png|jpe?g|webp)$/i.test(a.path||'')&&a.status!=='deleted'&&a.path!==FIXPATH);
$('fixhist').innerHTML=`<div style="font-size:.72rem;color:var(--dim);margin-bottom:8px">Tap a past generation to add it as a reference (max ${MAXFIXREFS}).</div>`
+`<div class="grid" style="grid-template-columns:repeat(auto-fill,minmax(70px,1fr));gap:8px">`
+imgs.map(a=>{const on=FIXREFS.some(r=>r.kind==='lib'&&r.ref===a.path);
return `<img src="${thumbURL(a)}" onclick="addFixHistory('${a.path.replace(/'/g,"\\'")}')" title="${a.path}" style="width:100%;aspect-ratio:2/3;object-fit:cover;border-radius:6px;cursor:pointer;border:2px solid ${on?'var(--brand)':'transparent'}">`;
}).join('')+`</div>`;
}
function addFixHistory(path){
const at=FIXREFS.findIndex(r=>r.kind==='lib'&&r.ref===path);
if(at>=0){FIXREFS.splice(at,1);}
else{ if(FIXREFS.length>=MAXFIXREFS){toast('Up to '+MAXFIXREFS+' references');return;} FIXREFS.push({kind:'lib',ref:path,thumb:thumbURL({path})}); }
drawFixRefs(); renderFixHist();
}
function showFixErr(m){ const e=$('fixerr'); e.textContent=m||''; e.style.display=m?'block':'none'; }
async function uploadFixFiles(files){
for(const f of [...files]){
if(FIXREFS.length>=MAXFIXREFS){toast('Up to '+MAXFIXREFS+' references');break;}
const r=await uploadImage(f);
if(r.name){FIXREFS.push({kind:'up',ref:r.name,thumb:inputThumb(r.name)}); drawFixRefs(); showFixErr('');}
else showFixErr('Upload failed: '+r.error);
}
}
async function openFixPath(path,prompt){FIXMODE='fix';AMENDID=null;ORIGREFS=[];MAXFIXREFS=4;
FIXPATH=path;FIXREFS=[];$('fixtext').value=prompt||'';showFixErr('');
$('fixtitle').textContent='Fix with chat';
$('fixdesc').textContent='Describe the change — a new render is queued from this image. Add reference photos (drag in, or pick from your history) to guide it.';
$('fixhist').style.display='none'; drawFixRefs();
$('gpuwarn').style.display=(await gpuOnline())?'none':'block';
$('gpuwarn').textContent='⚠ No GPU worker online — this queues but renders only when a BYO-GPU (e.g. spark) is connected.';
$('fixdlg').showModal();}
async function openFix(i){ return openFixPath(DATA.assets[i].path,''); }
async function openAmend(id){
const it=WL.find(x=>x.id===id); if(!it){toast('Job not found');return;}
FIXMODE='amend';AMENDID=id;FIXPATH=null;ORIGREFS=it.refs||[];FIXREFS=[];MAXFIXREFS=Math.max(0,5-ORIGREFS.length);
$('fixtext').value=it.prompt||'';showFixErr('');
$('fixtitle').textContent='Edit queued job';
$('fixdesc').textContent='Change the prompt and/or add references. Saving cancels the queued job and re-queues it with the amendment — it re-enters the queue, so its position resets.';
$('fixhist').style.display='none'; drawFixRefs();
$('gpuwarn').style.display='none';
$('fixdlg').showModal();
}
async function sendFix(){const t=$('fixtext').value.trim();if(t.length<3){showFixErr('Describe the change (3+ characters).');return;}
showFixErr(''); toast(FIXMODE==='amend'?'Amending…':'Queueing fix…');
const paths=FIXREFS.filter(x=>x.kind==='lib').map(x=>x.ref);
const uploads=FIXREFS.filter(x=>x.kind==='up').map(x=>x.ref);
let r;
if(FIXMODE==='amend'){ // atomic swap: cancel the queued job + re-dispatch amended
r=await fetch('/v1/queue/amend',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt_id:AMENDID,instruction:t,paths,uploads})});
}else if(!FIXREFS.length){ // single base image → the proven edit path
r=await fetch('/v1/library/fix',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:FIXPATH,instruction:t})});
}else{ // base + references → multi-reference compose
r=await fetch('/v1/library/compose',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths:[FIXPATH,...paths],instruction:t,uploads})});
}
const j=await r.json().catch(()=>({}));
if(r.ok){ $('fixdlg').close(); toast(FIXMODE==='amend'?'Amended — re-queued (position reset)':'Fix queued → see Queue & History'); if(typeof runs==='function')runs(); }
else showFixErr('Couldnt '+(FIXMODE==='amend'?'amend':'queue')+': '+(errMsg(j)||('error '+r.status))); // stay open so the reason is visible
}
async function cancelJob(id){
if(!confirm('Cancel this job?'))return;
const r=await fetch('/v1/queue/cancel',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt_id:id})});
const j=await r.json().catch(()=>({}));
if(r.ok)toast('Cancelled'+(j.canceled==='gpu'?' — if it was already rendering on your GPU it may still finish':''));
else toast('Cancel failed: '+(errMsg(j)||('error '+r.status)));
if(typeof runs==='function')runs();
}
// One shared drag/drop/paste + click-to-upload wiring, applied to every add-photo
// surface: the Fix dialog dropzone AND the composer prompt box + "+" button.
(function(){
const fdrop=$('fixdrop'),ffile=$('fixfile');
fdrop.addEventListener('click',()=>ffile.click());
ffile.addEventListener('change',e=>{uploadFixFiles(e.target.files);e.target.value='';});
wireDrop($('fixdlg'),uploadFixFiles); wirePaste($('fixdlg'),uploadFixFiles);
const af=$('attachfile');
af.addEventListener('change',e=>{attachFiles(e.target.files);e.target.value='';});
const box=document.querySelector('.prompt'); if(box){ wireDrop(box,attachFiles); wirePaste(box,attachFiles); }
})();
function zoom(u){$('zoomimg').src=u;$('zoom').style.display='flex';}
load();
const ta=$('pin'); ta.addEventListener('input',()=>{ta.style.height='auto';ta.style.height=Math.min(ta.scrollHeight,180)+'px';});
async function loadSession(){
let s={}; try{ s=await (await fetch('/v1/session')).json(); }catch(e){ return; }
const nm=(s.user||s.email||'').trim(); $('av').textContent=(nm[0]||'·').toUpperCase(); if(s.org)$('org').textContent=s.org;
$('user').onclick=()=>{ const ex=$('omenu'); if(ex){ex.remove();return;}
const orgs=(s.orgs&&s.orgs.length?s.orgs:[s.org]).filter(Boolean);
const m=document.createElement('div'); m.id='omenu';
m.style.cssText='position:fixed;top:54px;right:24px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:6px;min-width:190px;z-index:30;box-shadow:0 12px 34px #000b';
m.innerHTML=orgs.map(o=>`<div class="omi" data-o="${o}" style="padding:8px 11px;border-radius:8px;cursor:pointer;font-size:.85rem;color:${o===s.org?'var(--txt)':'var(--dim)'}">${o===s.org?'● ':'○ '}${o}</div>`).join('')
+`<div style="height:1px;background:var(--line);margin:5px 4px"></div>`+(s.console_url?`<a href="${s.console_url}" style="display:block;padding:8px 11px;color:var(--dim);text-decoration:none;font-size:.85rem">Console ↗</a>`:'')+`<a href="/logout" style="display:block;padding:8px 11px;color:var(--dim);text-decoration:none;font-size:.85rem">Sign out</a>`;
document.body.appendChild(m);
m.querySelectorAll('.omi').forEach(el=>el.onclick=async()=>{try{await fetch('/v1/session/org',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({org:el.dataset.o})});}catch(e){} location.reload();});
setTimeout(()=>document.addEventListener('click',function h(e){if(!m.contains(e.target)&&!$('user').contains(e.target)){m.remove();document.removeEventListener('click',h);}}),0);
};
}
// ── persistent live-queue bar: always shows what's generating + ETA ──────────
const ETA_MIN=9; // ~min per render on a warm BYO-GPU
async function updateLivebar(){
// Two queues: the pod's local /queue (in-pod renders) AND the org's BYO-GPU
// renders on gpu-jobs (fix/compose/rerun dispatched to a connected GPU). The
// latter is why a queued fix "never showed" — it renders on the worker, not
// the pod. Merge both.
let q={queue_running:[],queue_pending:[]}; try{q=await (await fetch('/queue')).json();}catch(_){}
let rq={jobs:[]}; try{rq=await (await fetch('/v1/render-queue')).json();}catch(_){}
const run=q.queue_running||[], pend=q.queue_pending||[];
const gpuJobs=rq.jobs||[];
const lb=$('livebar'), st=$('lbstatus');
const name=it=>{try{return (Object.values(it[2]).find(n=>n.class_type==='SaveImage')?.inputs?.filename_prefix||'render').split('/').pop();}catch(_){return 'render';}};
if(gpuJobs.length){
lb.classList.remove('idle');
const first=gpuJobs[0], more=gpuJobs.length-1;
st.innerHTML=`<span class="lb-run"><span class="spin"></span>Generating on your GPU: <b>${first.prefix||'render'}</b></span>`
+ (more>0?` <span class="lb-q">· ${more} more</span>`:'')
+ ` <span class="lb-eta">· ~${ETA_MIN} min each</span>`;
} else if(run.length){
lb.classList.remove('idle');
const q1=pend.length;
st.innerHTML=`<span class="lb-run"><span class="spin"></span>Generating: <b>${name(run[0])}</b></span>`
+ (q1?` <span class="lb-q">· ${q1} queued</span>`:'')
+ ` <span class="lb-eta">· ~${ETA_MIN}${q1?`${(q1+1)*ETA_MIN}`:''} min</span>`;
} else if(pend.length){
lb.classList.remove('idle');
st.innerHTML=`<span class="lb-q">⏳ ${pend.length} queued · ~${pend.length*ETA_MIN} min · waiting for a GPU</span>`;
} else {
lb.classList.add('idle');
const gpu=await gpuOnline();
st.innerHTML=`<span class="lb-q">Idle — nothing generating.</span>${gpu?'':' <span class="lb-eta">· connect a GPU to generate</span>'}`;
}
}
// header auto-hide (immersive) — reveal on top-hover / mouse near top
document.getElementById('tophover').addEventListener('mouseenter',()=>document.querySelector('header').classList.add('reveal'));
document.addEventListener('mousemove',e=>{if(e.clientY<8)document.querySelector('header').classList.add('reveal');else if(e.clientY>90&&document.body.classList.contains('immersive'))document.querySelector('header').classList.remove('reveal');});
load(); loadSession(); updateLivebar(); setInterval(updateLivebar, 5000);
// Queue transparency: refresh positions/ETAs while the queue view is open (sane
// cadence, no hammering); node badges less often; tick the running item's elapsed.
setInterval(()=>{ if(!$('runs').hidden)refreshQueue(); }, 6000);
setInterval(()=>{ if(!$('runs').hidden)loadNodes(); }, 20000);
setInterval(()=>{ for(const id in (QST.items||{})){ const e=QST.items[id]; if(e.status==='running'){ const el=$('el-'+id); if(el)el.textContent=fmtDur((e.elapsed_seconds||0)+(Date.now()-QLOCAL)/1000); } } }, 1000);
</script>
+688 -28
View File
@@ -12,9 +12,11 @@ storage or lifecycle:
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)
One source of truth per concern, all pre-existing:
* assets + curation state -> orgs/<org>/output/library.json (karma_manifest.py)
* 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)
@@ -42,11 +44,12 @@ import aiohttp
from aiohttp import web
import folder_paths
from middleware import worklog
log = logging.getLogger("studio.home")
_HTML = Path(__file__).with_name("studio_home.html")
_STATUSES = ("draft", "approved", "queued", "published")
_STATUSES = ("draft", "approved", "queued", "published", "deleted")
_PROV_CACHE: dict[str, tuple[float, dict]] = {} # abspath -> (mtime, provenance)
_NEG = ("ghosting, double exposure, duplicated body, transparent fabric, sheer "
@@ -142,6 +145,31 @@ def _safe_asset(root: Path, rel: str) -> Path | None:
return p if p.is_file() and str(p).startswith(str(root.resolve())) else None
_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", {})
@@ -177,20 +205,362 @@ def _fix_graph(ref_name: str, instruction: str, prefix: str) -> dict:
}
def _compose_graph(ref_names: list[str], instruction: str, prefix: str) -> 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.")
g: dict = {
"3": {"class_type": "CLIPLoader", "inputs": {"clip_name": "qwen2.5-vl-7b.safetensors", "type": "qwen_image", "device": "default"}},
"4": {"class_type": "UNETLoader", "inputs": {"unet_name": "qwen-image-edit-2511.safetensors", "weight_dtype": "default"}},
"5": {"class_type": "VAELoader", "inputs": {"vae_name": "qwen-image-vae.safetensors"}},
}
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}
g["7"] = {"class_type": "TextEncodeQwenImageEditPlus", "inputs": {"clip": ["3", 0], "prompt": _NEG}}
g["8"] = {"class_type": "FluxKontextMultiReferenceLatentMethod", "inputs": {"conditioning": ["6", 0], "reference_latents_method": "index_timestep_zero"}}
g["9"] = {"class_type": "FluxKontextMultiReferenceLatentMethod", "inputs": {"conditioning": ["7", 0], "reference_latents_method": "index_timestep_zero"}}
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": ["8", 0], "negative": ["9", 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."""
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"}
for h in ("Authorization", "Cookie"):
if request.headers.get(h):
headers[h] = request.headers[h]
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:
body = await r.json(content_type=None)
if r.status != 200:
raise web.HTTPBadGateway(text=json.dumps(body)[:500])
return body.get("prompt_id", "")
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"))
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"
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 SaveImage's exact "<prefix>_<counter>_.png" — 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$")
hits = [p for p in parent.glob(stem + "_*_.png") 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
async def _dispatch(request, server, org: str, graph: dict, *, kind: str, prompt: str,
refs: list, uploads: list, parents: list, output_prefix: str) -> 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."""
common = dict(kind=kind, prompt=prompt, refs=refs, uploads=uploads,
parents=parents, output_prefix=output_prefix, params=_graph_params(graph))
try:
pid = await _queue_prompt(request, server, graph)
except DispatchError as e:
worklog.record(org, status="failed", error=str(e), **common)
raise
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)
return pid
async def dispatch_fix(request: web.Request, server, org: str, rel: str,
instruction: str, upload: str | None = None) -> dict:
"""Run the proven Qwen single-image edit. The base image is EITHER a library
asset (``rel``, staged into the org input dir) OR an image the caller just
uploaded via ``/upload/image`` (``upload``, already resident there) — so a fresh
photo can be edited without first landing it in the library. The ONE fix path:
the /v1/library/fix route and the chat `create` capability both call this."""
instruction = (instruction or "").strip()
if not 3 <= len(instruction) <= 500:
raise DispatchError("instruction required (3-500 chars)")
in_dir = Path(folder_paths.get_org_input_directory(org))
in_dir.mkdir(parents=True, exist_ok=True)
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)
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 = f"fix_src_{abs(hash((str(p), p.stat().st_mtime))) % 10**10}.png"
(in_dir / ref_name).write_bytes(p.read_bytes())
stem = re.sub(r"[^a-zA-Z0-9_-]", "_", p.stem)[:48]
pid = await _dispatch(request, server, org, _fix_graph(ref_name, instruction, f"fixes/{stem}"),
kind="fix", prompt=instruction, refs=[ref_name],
uploads=[upload] if upload else [], parents=[] if upload else [rel],
output_prefix=f"fixes/{stem}")
return {"ok": True, "prompt_id": pid, "output_prefix": f"fixes/{stem}"}
async def dispatch_compose(request: web.Request, server, org: str, paths: list,
instruction: str, uploads: list | 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 = f"compose_src_{abs(hash((str(p), p.stat().st_mtime))) % 10**10}.png"
(in_dir / nm).write_bytes(p.read_bytes())
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])
pid = await _dispatch(request, server, org, _compose_graph(ref_names, instruction, f"composes/{stem}"),
kind="compose", prompt=instruction, refs=ref_names,
uploads=uploaded, parents=list(paths), output_prefix=f"composes/{stem}")
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) -> 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 = f"amend_src_{abs(hash((str(p), p.stat().st_mtime))) % 10**10}.png"
(in_dir / nm).write_bytes(p.read_bytes())
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] + "_amended"
if len(refs) == 1:
graph, kind, out = _fix_graph(refs[0], instruction, f"fixes/{stem}"), "fix", f"fixes/{stem}"
else:
graph, kind, out = _compose_graph(refs, instruction, f"composes/{stem}"), "compose", f"composes/{stem}"
all_uploads = list(dict.fromkeys(list(orig.get("uploads") or []) + new_uploads))
pid = await _dispatch(request, server, org, graph, kind=kind, prompt=instruction,
refs=refs, uploads=all_uploads, parents=parents, output_prefix=out)
return {"ok": True, "prompt_id": pid, "amended_from": prompt_id,
"output_prefix": out, "refs": len(refs)}
def _workflows(org: str) -> list[str]:
@@ -223,7 +593,13 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
@routes.get("/studio")
async def studio_home(request: web.Request):
return web.Response(text=_HTML.read_text(), content_type="text/html")
# no-store: the SPA 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 (e.g. delete) appeared "not working" to
# a user still running yesterday's cached studio_home.html. The page is
# tiny; always serve fresh. Static assets keep their own long cache.
return web.Response(text=_HTML.read_text(), content_type="text/html",
headers={"Cache-Control": "no-store, must-revalidate"})
@routes.get("/v1/library")
async def get_library(request: web.Request):
@@ -246,6 +622,103 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
assets.append(row)
return web.json_response({"org": org, "assets": assets, "workflows": _workflows(org)})
@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)
if request.query.get("thumb") and p.suffix.lower() in (".png", ".jpg", ".jpeg", ".webp"):
try:
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:
from PIL import Image
im = Image.open(p)
im.thumbnail((480, 720)) # grid-sized; keeps aspect
im.convert("RGB").save(tp, "JPEG", quality=82)
return web.FileResponse(tp, headers={"Cache-Control": "public, max-age=86400"})
except Exception:
pass # fall through to full-res on any thumbnail failure
return web.FileResponse(p)
@routes.get("/v1/gpu-status")
async def gpu_status(request: web.Request):
"""Does the caller's org have an online GPU to render on? The UI showed
'connect a GPU' even with a GPU connected because it checked /v1/engines
(the worker-federation) — but `hanzo gpu connect` registers with the FLEET
(gpu-jobs), which is exactly what dispatch uses. Report the SAME source of
truth as dispatch (_has_online_gpu) so the UI matches what actually works."""
try:
from middleware.gpu_dispatch import _bearer, _has_online_gpu
except ImportError:
from .middleware.gpu_dispatch import _bearer, _has_online_gpu
try:
tok = _bearer(request)
online = bool(tok and _has_online_gpu(tok))
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 (fix/compose/rerun dispatched to a
connected GPU). These run on the worker's gpu-jobs queue, NOT the pod's
local /queue, so the UI could not see them — this is what makes a queued
fix visible. Drops entries whose output has landed in the library or that
have aged out (~25 min). Cheap: reads one small per-org JSON."""
org = _org_of(request)
root = _library_root(org)
if root is None:
return web.json_response({"jobs": []})
jf = root / "render_jobs.json"
try:
jobs = json.loads(jf.read_text())
except Exception:
jobs = []
now = int(time.time())
live = []
for j in jobs:
age = now - int(j.get("ts", 0))
if age > 1500: # aged out (worker deadline is 1200s)
continue
# Completed only when the ACTUAL output for THIS job exists: the render
# saves to "<outPrefix>_NNNNN_.png". Check precisely (glob the exact
# output prefix) — not a fuzzy base match, which collided with the source
# image and made the job disappear from the queue after 30s.
out_prefix = j.get("outPrefix") or j.get("prefix") or ""
landed = False
if out_prefix:
try:
parent = root / os.path.dirname(out_prefix)
stem = os.path.basename(out_prefix)
landed = any(parent.glob(stem + "*.png"))
except Exception:
landed = False
if landed and age > 20:
continue
j["age"] = age
live.append(j)
return web.json_response({"jobs": live})
@routes.post("/v1/library/status")
async def set_status(request: web.Request):
body = await request.json()
@@ -280,27 +753,214 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
graph = None
if not isinstance(graph, dict):
return web.json_response({"error": "no embedded workflow in this image"}, 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") == "SaveImage":
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:
instr = t
ids = []
for _ in range(max(1, min(int(body.get("count", 1)), 4))):
ids.append(await _queue_prompt(request, server, _reseed(json.loads(json.dumps(graph)))))
try:
for _ in range(max(1, min(int(body.get("count", 1)), 4))):
ids.append(await _dispatch(
request, server, org, _reseed(json.loads(json.dumps(graph))),
kind="rerun", prompt=instr, refs=[], uploads=[], parents=[rel],
output_prefix=out_prefix))
except DispatchError as e:
return web.json_response({"error": str(e)}, status=400)
return web.json_response({"ok": True, "prompt_ids": ids})
@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")))
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 (real finish time = file mtime)
for r in rows:
if r.get("status") in ("done", "failed", "cancelled"):
continue
landed = _landed_output(root, r.get("output_prefix"))
if landed:
mt = _output_mtime(root, landed) or now
worklog.mark_finished(org, r["id"], mt)
r["status"], r["finished_at"] = "done", mt # mirror in-memory
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. The fleet is token-scoped, so only the caller's org's
nodes are ever returned."""
try:
from middleware.gpu_dispatch import _bearer, _fleet_machines, _node_label
except ImportError:
from .middleware.gpu_dispatch import _bearer, _fleet_machines, _node_label
nodes = []
try:
tok = _bearer(request)
for m in (_fleet_machines(tok) if tok else []):
if not m.get("gpu"):
continue
nodes.append({"name": _node_label(m), "online": m.get("status") == "online"})
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)
out = []
for r in reversed(rows):
row = dict(r)
landed = _landed_output(root, r.get("output_prefix"))
row["output"] = landed
if landed and r.get("status") not in ("failed", "cancelled"):
row["status"] = "done"
out.append(row)
q = (request.query.get("q") or "").lower().strip()
if q:
out = [r for r in out if q in (r.get("prompt", "") or "").lower() or q in (r.get("kind", "") or "")]
return web.json_response({"org": org, "items": out[:200]})
@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)
for r in rows:
r["output"] = _landed_output(root, r.get("output_prefix"))
return web.json_response(worklog.build_lineage(rows, target))
@routes.post("/v1/library/fix")
async def fix(request: web.Request):
body = await request.json()
instruction = (body.get("instruction") or "").strip()
if not 3 <= len(instruction) <= 500:
return web.json_response({"error": "instruction required (3-500 chars)"}, status=400)
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() != ".png":
return web.json_response({"error": "unknown asset"}, status=404)
# LoadImage reads from the input dir: stage the chosen output there.
in_dir = Path(folder_paths.get_org_input_directory(org))
in_dir.mkdir(parents=True, exist_ok=True)
ref_name = f"fix_src_{abs(hash((str(p), p.stat().st_mtime))) % 10**10}.png"
(in_dir / ref_name).write_bytes(p.read_bytes())
stem = re.sub(r"[^a-zA-Z0-9_-]", "_", p.stem)[:48]
pid = await _queue_prompt(request, server, _fix_graph(ref_name, instruction, f"fixes/{stem}"))
return web.json_response({"ok": True, "prompt_id": pid, "output_prefix": f"fixes/{stem}"})
try:
return web.json_response(await dispatch_fix(
request, server, _org_of(request), body.get("path", ""),
body.get("instruction", ""), body.get("upload")))
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")))
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)
root = _library_root(_org_of(request))
if root is None:
return web.json_response({"error": "no library for org"}, status=404)
rf = root / "ratings.json"
try:
data = json.loads(rf.read_text())
except Exception:
data = {}
entry = data.get(key, {})
if stars is not None:
entry["stars"] = stars
if "notes" in body:
entry["notes"] = str(body.get("notes") or "")[:2000]
if body.get("favorite") is not None:
entry["favorite"] = bool(body.get("favorite"))
entry["updatedAt"] = int(time.time())
data[key] = entry
tmp = root / "ratings.json.tmp"
tmp.write_text(json.dumps(data, indent=1))
tmp.replace(rf)
return web.json_response({"ok": True, "key": key, "entry": entry})
+283
View File
@@ -0,0 +1,283 @@
"""Per-org work log — the ONE record of every render this org dispatched.
The single dispatch funnel (``studio_home.dispatch_fix`` / ``dispatch_compose`` /
``rerun``) writes one row here at dispatch time, so the studio surface can show:
* the queue/history with REAL status — queued · running · done · failed ·
cancelled — including gpu-lane jobs the local /queue can't see (that was the
'nothing showed as queued' report) and dispatches that failed before queuing;
* the full context that produced each item — prompt, reference images/uploads,
the workflow family (fix / compose / …), params, and the process trail;
* parent → child lineage: each derived asset records the library asset(s) it was
derived from, so any item's versions (ancestors + later fixes) form a chain.
Storage is one JSON array per org at ``orgs/<org>/output/worklog.json`` — the same
per-org metadata pattern as ``library.json`` / ``render_jobs.json`` / ``ratings.json``
(atomic tmp+replace). No new database. No backfill: history starts when this ships;
items generated earlier have no recorded row (and derived items no recorded parent).
Tenant isolation: every function takes an already-resolved ``org`` (the caller's
IAM org, resolved server-side by ``studio_home._org_of``). A row lives only in its
org's file, so one org can never read or mutate another's log.
"""
from __future__ import annotations
import json
import time
import uuid
from pathlib import Path
import folder_paths
# Cap the file so a busy org can't grow it without bound; oldest rows drop off.
_CAP = 1000
_TERMINAL = ("done", "failed", "cancelled")
def _dir(org: str) -> Path:
return Path(folder_paths.get_org_output_directory(org))
def _path(org: str) -> Path:
return _dir(org) / "worklog.json"
def load(org: str) -> list[dict]:
try:
rows = json.loads(_path(org).read_text())
return rows if isinstance(rows, list) else []
except Exception:
return []
def _save(org: str, rows: list[dict]) -> None:
d = _dir(org)
d.mkdir(parents=True, exist_ok=True)
p = _path(org)
tmp = p.with_name(p.name + ".tmp")
tmp.write_text(json.dumps(rows[-_CAP:]))
tmp.replace(p)
def record(org: str, *, kind: str, prompt: str, refs: list, uploads: list,
parents: list, output_prefix: str, params: dict | None = None,
pid: str | None = None, lane: str | None = None, node: str | None = None,
status: str = "queued", error: str | None = None) -> dict:
"""Append one dispatch row. ``pid`` is the /prompt id on success (used to match
live queue/history); a failed dispatch has no id, so a synthetic one is minted
and the row is marked failed with the reason so it STILL shows (never silent)."""
now = int(time.time())
row: dict = {
"id": pid or f"failed-{uuid.uuid4().hex[:12]}",
"ts": now,
"kind": kind,
"prompt": (prompt or "")[:800],
"refs": [str(x) for x in (refs or [])][:8],
"uploads": [str(x) for x in (uploads or [])][:8],
"parents": [str(x) for x in (parents or [])][:8],
"output_prefix": output_prefix or "",
"params": params or {},
"lane": lane or ("failed" if status == "failed" else "local"),
"node": node or ("studio pod" if (lane in (None, "local")) else "gpu"),
"status": status,
"updatedAt": now,
"events": [{"s": "queued", "ts": now}],
}
if error:
row["error"] = str(error)[:400]
row["events"].append({"s": "failed", "ts": now, "note": row["error"]})
rows = load(org)
rows.append(row)
_save(org, rows)
return row
def set_status(org: str, pid: str, status: str, note: str | None = None) -> bool:
"""Advance a row's status by prompt_id (running/done/failed/cancelled). Returns
True if a row was updated. Idempotent-safe; terminal rows are left untouched."""
if not pid:
return False
rows = load(org)
hit = False
now = int(time.time())
for r in rows:
if r.get("id") == pid and r.get("status") not in _TERMINAL:
r["status"] = status
r["updatedAt"] = now
r.setdefault("events", []).append(
{"s": status, "ts": now, **({"note": note} if note else {})})
hit = True
if hit:
_save(org, rows)
return hit
def mark_started(org: str, pid: str, at: int) -> bool:
"""Stamp when a job first begins rendering (first seen at the head of its lane).
Idempotent: only the first observation sets started_at. Feeds duration = finished
started, the basis of the ETA median."""
if not pid:
return False
rows = load(org)
hit = False
for r in rows:
if r.get("id") == pid and not r.get("started_at") and r.get("status") not in _TERMINAL:
r["started_at"] = int(at)
r["status"] = "running"
r["updatedAt"] = int(time.time())
r.setdefault("events", []).append({"s": "running", "ts": int(at)})
hit = True
if hit:
_save(org, rows)
return hit
def mark_finished(org: str, pid: str, at: int) -> bool:
"""Stamp real completion (the output file's mtime) and close the row as done."""
if not pid:
return False
rows = load(org)
hit = False
for r in rows:
if r.get("id") == pid and r.get("status") not in _TERMINAL:
r["finished_at"] = int(at)
r["status"] = "done"
r["updatedAt"] = int(time.time())
r.setdefault("events", []).append({"s": "done", "ts": int(at)})
hit = True
if hit:
_save(org, rows)
return hit
def _duration(r: dict) -> float | None:
"""Measured render seconds for a completed row: finished started when both
exist (true render time), else finished queued (turnaround, an upper bound)."""
fin = r.get("finished_at")
if fin is None:
return None
start = r.get("started_at")
if start is None:
start = r.get("ts")
if start is None:
return None
return float(fin - start) if fin > start else None
def median_durations(rows: list[dict]) -> dict:
"""Rolling median render seconds per kind (fix/compose/rerun) over completed
rows — the ETA basis. Pure. Only kinds with a measured sample appear."""
buckets: dict[str, list[float]] = {}
for r in rows:
d = _duration(r)
if d and d > 0:
buckets.setdefault(r.get("kind", ""), []).append(d)
out: dict[str, int] = {}
for k, v in buckets.items():
v.sort()
n = len(v)
out[k] = int(v[n // 2] if n % 2 else (v[n // 2 - 1] + v[n // 2]) / 2)
return out
def estimate_lanes(active: list[dict], medians: dict, now: int,
default_median: int = 180) -> tuple[dict, dict]:
"""Pure position + ETA math, per lane. ``active`` items are
``{id, kind, lane, ts, status, started_at?}``. Within a lane the running item
(head) is position 1; each queued item's wait ≈ jobs-ahead × their medians +
the running item's remaining time (median elapsed). ETA to done = wait +
its own median. Returns (per-id info, per-lane count). No client math over
another tenant's data — the caller passes only THIS org's active jobs."""
lanes: dict[str, list[dict]] = {}
for j in active:
lanes.setdefault(j.get("lane") or "local", []).append(j)
info: dict[str, dict] = {}
counts: dict[str, int] = {}
for lane, jobs in lanes.items():
jobs.sort(key=lambda j: (0 if j.get("status") == "running" else 1, j.get("ts", 0)))
counts[lane] = len(jobs)
cum = 0.0 # seconds until the START of the current item
for idx, j in enumerate(jobs):
m = medians.get(j.get("kind", ""), default_median)
row = {"lane": lane, "position": idx + 1, "of": len(jobs),
"node": j.get("node") or ("studio pod" if lane == "local" else "gpu")}
if idx == 0 and j.get("status") == "running":
started = j.get("started_at") or j.get("ts") or now
elapsed = max(0, now - started)
remaining = max(0.0, m - elapsed)
row.update(elapsed_seconds=int(elapsed), wait_seconds=0,
remaining_seconds=int(remaining), eta_seconds=int(remaining))
cum = remaining
else:
row.update(elapsed_seconds=0, wait_seconds=int(cum),
remaining_seconds=None, eta_seconds=int(cum + m))
cum += m
info[j["id"]] = row
return info, counts
def build_lineage(rows: list[dict], target: str) -> dict:
"""Pure: given work-log rows (each with ``parents`` = source library paths and
``output`` = the landed library path, resolved by the caller) and a target
library asset path, return its version chain:
{"ancestors": [older … immediate parent], "node": <target>,
"descendants": [later derivations, chronological]}
Each entry is ``{path, prompt, kind, ts}``. Ancestors walk the first-parent edge
up from the row that PRODUCED the target; descendants are rows that name the
target among their parents (and, transitively, their outputs). Cycles and
missing rows terminate the walk — never loops."""
by_output: dict[str, dict] = {}
for r in rows:
out = r.get("output")
if out and out not in by_output:
by_output[out] = r
def entry(path: str, r: dict | None) -> dict:
return {
"path": path,
"prompt": (r or {}).get("prompt", ""),
"kind": (r or {}).get("kind", ""),
"ts": (r or {}).get("ts", 0),
}
# Ancestors: from the row that produced `target`, follow its first parent up.
ancestors: list[dict] = []
seen = {target}
cur = target
while True:
producer = by_output.get(cur)
if not producer:
break
parents = producer.get("parents") or []
if not parents:
break
parent = parents[0]
if parent in seen:
break
seen.add(parent)
ancestors.append(entry(parent, by_output.get(parent)))
cur = parent
ancestors.reverse() # oldest first
# Descendants: rows that derive FROM target, then rows deriving from those, …
descendants: list[dict] = []
frontier = [target]
seen_out = {target}
while frontier:
nxt: list[str] = []
kids = [r for r in rows if any(p in frontier for p in (r.get("parents") or []))]
kids.sort(key=lambda r: r.get("ts", 0))
for r in kids:
out = r.get("output")
key = out or r.get("id")
if key in seen_out:
continue
seen_out.add(key)
descendants.append(entry(out or "", r))
if out:
nxt.append(out)
frontier = nxt
return {"ancestors": ancestors, "node": target, "descendants": descendants}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "hanzo-studio"
version = "0.15.2"
version = "0.17.3"
readme = "README.md"
license = { file = "LICENSE" }
requires-python = ">=3.10"
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
"""karma_manifest.py — regenerate the ONE canonical asset-library index.
"""library_manifest.py — regenerate the ONE canonical asset-library index.
The Karma content pipeline has a single source of truth: a `library.json`
manifest that sits at the root of the karma org's studio-output subtree and
manifest that sits at the root of an org's studio-output subtree (any tenant; pass --root) and
indexes every asset Antje has rendered. The storefront (karma.style) and the
socials/blog queue both read THIS file there is no second index.
@@ -159,7 +159,7 @@ def build(root: str) -> dict:
return {
"_meta": {
"note": "Canonical Karma asset library. ONE index the storefront + "
"socials queue read. Regenerated by karma_manifest.py; edit "
"socials queue read. Regenerated by library_manifest.py; edit "
"status/caption/tags with karma-queue.py (they are preserved).",
"prefix": "orgs/karma/output",
"generatedAt": iso(time.time()),
@@ -183,7 +183,7 @@ def write_atomic(root: str, doc: dict) -> str:
def run_once(root: str, quiet: bool = False, strict: bool = True) -> dict | None:
if not os.path.isdir(root):
msg = f"karma_manifest: root does not exist yet: {root}"
msg = f"library_manifest: root does not exist yet: {root}"
if strict:
sys.exit(msg)
if not quiet:
@@ -193,7 +193,7 @@ def run_once(root: str, quiet: bool = False, strict: bool = True) -> dict | None
path = write_atomic(root, doc)
if not quiet:
m = doc["_meta"]
print(f"karma_manifest: {m['count']} assets -> {path} {m['byStatus']}")
print(f"library_manifest: {m['count']} assets -> {path} {m['byStatus']}")
return doc
@@ -210,12 +210,12 @@ def main() -> None:
run_once(args.root)
return
print(f"karma_manifest: watching {args.root} every {args.interval}s", flush=True)
print(f"library_manifest: watching {args.root} every {args.interval}s", flush=True)
while True:
try:
run_once(args.root, quiet=True, strict=False)
except Exception as e: # a bad cycle must never kill the sidecar
print(f"karma_manifest: cycle error: {e}", file=sys.stderr, flush=True)
print(f"library_manifest: cycle error: {e}", file=sys.stderr, flush=True)
time.sleep(args.interval)
+80 -11
View File
@@ -64,6 +64,41 @@ def _remove_sensitive_from_queue(queue: list) -> list:
return [item[:5] for item in queue]
# ── Multi-tenant scoping for the inherited core endpoints (HIP-0506 security) ──
# /history and /queue are process-global in upstream ComfyUI. In a multi-tenant
# deployment that is cross-tenant disclosure (one org reads another's prompts,
# graphs, seeds) and a cross-tenant DoS (one org's "clear queue" wipes another's).
# Every item carries its owner in extra_data["org_id"] (bound at enqueue). These
# filter each list/dict to the caller's org. `org is None` means auth is OFF
# (single-tenant/local dev) → return everything unchanged (no behavior change).
def _item_org(item) -> str | None:
"""org_id from a queue-item tuple (index 3 = extra_data)."""
try:
return item[3].get("org_id") if len(item) > 3 and isinstance(item[3], dict) else None
except Exception:
return None
def _scope_queue_to_org(items: list, org: str | None) -> list:
if not org:
return items
return [it for it in items if _item_org(it) == org]
def _scope_history_to_org(hist: dict, org: str | None) -> dict:
if not org:
return hist
out = {}
for k, entry in (hist or {}).items():
try:
ed = entry.get("prompt", [None, None, None, {}])[3]
if isinstance(ed, dict) and ed.get("org_id") == org:
out[k] = entry
except Exception:
continue # malformed/legacy entry with no org → excluded (fail-closed)
return out
async def send_socket_catch_exception(function, message):
try:
await function(message)
@@ -969,19 +1004,24 @@ class PromptServer():
else:
offset = -1
return web.json_response(self.prompt_queue.get_history(max_items=max_items, offset=offset))
hist = self.prompt_queue.get_history(max_items=max_items, offset=offset)
hist = _scope_history_to_org(hist, self._caller_org(request))
return web.json_response(hist)
@routes.get("/history/{prompt_id}")
async def get_history_prompt_id(request):
prompt_id = request.match_info.get("prompt_id", None)
return web.json_response(self.prompt_queue.get_history(prompt_id=prompt_id))
hist = self.prompt_queue.get_history(prompt_id=prompt_id)
hist = _scope_history_to_org(hist, self._caller_org(request))
return web.json_response(hist)
@routes.get("/queue")
async def get_queue(request):
org = self._caller_org(request)
queue_info = {}
current_queue = self.prompt_queue.get_current_queue_volatile()
queue_info['queue_running'] = _remove_sensitive_from_queue(current_queue[0])
queue_info['queue_pending'] = _remove_sensitive_from_queue(current_queue[1])
queue_info['queue_running'] = _scope_queue_to_org(_remove_sensitive_from_queue(current_queue[0]), org)
queue_info['queue_pending'] = _scope_queue_to_org(_remove_sensitive_from_queue(current_queue[1]), org)
return web.json_response(queue_info)
@routes.post("/prompt")
@@ -1106,13 +1146,20 @@ class PromptServer():
@routes.post("/queue")
async def post_queue(request):
json_data = await request.json()
org = self._caller_org(request) # None = auth off → upstream behavior
if "clear" in json_data:
if json_data["clear"]:
self.prompt_queue.wipe_queue()
if org is None:
self.prompt_queue.wipe_queue()
else:
# SCOPED clear: only THIS org's pending items, never others'.
self.prompt_queue.delete_queue_item(lambda a: _item_org(a) == org)
if "delete" in json_data:
to_delete = json_data['delete']
for id_to_delete in to_delete:
delete_func = lambda a: a[1] == id_to_delete
# Only delete an item the caller owns (org must match when auth on).
delete_func = (lambda a, i=id_to_delete: a[1] == i) if org is None \
else (lambda a, i=id_to_delete: a[1] == i and _item_org(a) == org)
self.prompt_queue.delete_queue_item(delete_func)
return web.Response(status=200)
@@ -1124,16 +1171,19 @@ class PromptServer():
except json.JSONDecodeError:
json_data = {}
org = self._caller_org(request) # None = auth off → upstream behavior
# Check if a specific prompt_id was provided for targeted interruption
prompt_id = json_data.get('prompt_id')
if prompt_id:
currently_running, _ = self.prompt_queue.get_current_queue()
# Check if the prompt_id matches any currently running prompt
# Check if the prompt_id matches any currently running prompt — and
# (multi-tenant) that it belongs to the caller's org, so one tenant
# cannot interrupt another tenant's render.
should_interrupt = False
for item in currently_running:
# item structure: (number, prompt_id, prompt, extra_data, outputs_to_execute)
if item[1] == prompt_id:
if item[1] == prompt_id and (org is None or _item_org(item) == org):
logging.info(f"Interrupting prompt {prompt_id}")
should_interrupt = True
break
@@ -1141,11 +1191,18 @@ class PromptServer():
if should_interrupt:
nodes.interrupt_processing()
else:
logging.info(f"Prompt {prompt_id} is not currently running, skipping interrupt")
else:
# No prompt_id provided, do a global interrupt
logging.info(f"Prompt {prompt_id} is not currently running / not owned by caller, skipping interrupt")
elif org is None:
# No prompt_id + auth off → upstream global interrupt.
logging.info("Global interrupt (no prompt_id specified)")
nodes.interrupt_processing()
else:
# Multi-tenant: a global interrupt would kill another tenant's render.
# Only interrupt if the running job belongs to the caller's org.
currently_running, _ = self.prompt_queue.get_current_queue()
if any(_item_org(item) == org for item in currently_running):
logging.info(f"Interrupting current render for org {org}")
nodes.interrupt_processing()
return web.Response(status=200)
@@ -1322,6 +1379,16 @@ class PromptServer():
return iam_user.get("org_id", "default")
return os.environ.get("STUDIO_ORG_ID", "default")
def _caller_org(self, request) -> str | None:
"""The verified caller org to SCOPE cross-tenant reads by — or None when
multi-tenant auth is OFF (single-tenant/local dev) so /history + /queue
behave exactly as upstream. Only returns a value when there is a real IAM
user, so scoping never hides another tenant's data by accident."""
iam_user = request.get("iam_user")
if iam_user and iam_user.get("org_id"):
return iam_user.get("org_id")
return None
async def setup(self):
timeout = aiohttp.ClientTimeout(total=None) # no timeout
self.client_session = aiohttp.ClientSession(timeout=timeout)
@@ -1351,6 +1418,8 @@ class PromptServer():
add_session_routes(self.routes, self)
from middleware.studio_home import add_studio_home_routes
add_studio_home_routes(self.routes, self)
from middleware.mcp import add_mcp_routes
add_mcp_routes(self.routes, self)
self.app.add_subapp('/internal', self.internal_routes.get_app())
# Prefix every route with /api for easier matching for delegation.
+1 -1
View File
@@ -1,3 +1,3 @@
# This file is automatically generated by the build process when version is
# updated in pyproject.toml.
__version__ = "0.14.1"
__version__ = "0.17.3"
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Studio watchdog: keep :8188 alive through the overnight karma render run.
# Emits a line ONLY on a restart event (so it's a signal, not a firehose).
# Detects: process gone, port unresponsive, or output-log silent >12min while a
# render is supposedly in flight (the 6h-hang failure mode from the QC log).
set -u
STUDIO=/home/z/work/hanzo/studio
LOG=$STUDIO/studio-local.log
launch() {
cd "$STUDIO"
# Free the port first: a stale/duplicate main.py holding :8188 makes the new
# one crash on bind (EADDRINUSE). Kill any existing listener, wait for release.
pkill -f 'main.py --listen' 2>/dev/null || true
for _ in $(seq 1 15); do
ss -ltn 2>/dev/null | grep -q ':8188 ' || break
sleep 1
done
HF_HOME=/home/z/.cache/huggingface \
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \
STUDIO_PERSIST_QUEUE=1 \
nohup .venv/bin/python main.py --listen 0.0.0.0 --port 8188 --normalvram \
--disable-auto-launch --output-directory "$STUDIO/output" >> "$LOG" 2>&1 &
sleep 1
local pid
pid=$(pgrep -f 'main.py --listen' | head -1)
[ -n "$pid" ] && sudo -n sh -c "echo -700 > /proc/$pid/oom_score_adj" 2>/dev/null || true
echo "$pid"
}
while true; do
code=$(curl -s -m 5 http://127.0.0.1:8188/system_stats -o /dev/null -w "%{http_code}" 2>/dev/null || echo 000)
if [ "$code" != "200" ]; then
# give it one grace re-check (could be mid-render, momentarily busy)
sleep 8
code=$(curl -s -m 5 http://127.0.0.1:8188/system_stats -o /dev/null -w "%{http_code}" 2>/dev/null || echo 000)
if [ "$code" != "200" ]; then
pid=$(launch)
# wait for it to answer
for i in $(seq 1 45); do
c=$(curl -s -m 4 http://127.0.0.1:8188/system_stats -o /dev/null -w "%{http_code}" 2>/dev/null || echo 000)
[ "$c" = "200" ] && break
sleep 4
done
echo "STUDIO_RESTARTED pid=$pid health=$c"
fi
fi
sleep 45
done
@@ -0,0 +1,33 @@
"""Unit tests for BYO-GPU dispatch node identity (middleware/gpu_dispatch.py)."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from middleware import gpu_dispatch as g
def test_node_label_prefers_human_name():
assert g._node_label({"name": "spark", "hostname": "h", "gpu": True}) == "spark"
assert g._node_label({"hostname": "dbc-01", "gpu": True}) == "dbc-01"
assert g._node_label({"worker_id": "gpu-acme-123"}) == "gpu-acme-123"
assert g._node_label({"gpu_model": "RTX 4090"}) == "RTX 4090"
assert g._node_label({}) == "gpu" # never blank
def test_online_gpu_nodes_filters_offline_and_non_gpu(monkeypatch):
monkeypatch.setattr(g, "_fleet_machines", lambda tok: [
{"name": "spark", "status": "online", "gpu": True},
{"name": "idle", "status": "offline", "gpu": True},
{"name": "cpu-box", "status": "online", "gpu": False},
])
assert [m["name"] for m in g._online_gpu_nodes("tok")] == ["spark"]
assert g._has_online_gpu("tok") is True
def test_no_online_gpu_when_all_offline(monkeypatch):
monkeypatch.setattr(g, "_fleet_machines", lambda tok: [
{"name": "spark", "status": "offline", "gpu": True},
])
assert g._online_gpu_nodes("tok") == []
assert g._has_online_gpu("tok") is False
+204
View File
@@ -0,0 +1,204 @@
"""Tests for the /v1/mcp MCP-over-HTTP endpoint: initialize, tools/list, the
tools/call dispatch, DispatchError -> isError result, and unknown-method error.
Mirrors session_test.py: a real aiohttp app with a middleware that injects the
IAM user the auth layer would have set, so org resolution and the JSON-RPC
envelopes are the real thing. The render dispatch (studio_home.dispatch_fix /
dispatch_compose) is monkeypatched, so no GPU / self-POST is needed.
"""
import asyncio
import json
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from middleware import mcp
from middleware import studio_home
ORG_USER = {"sub": "hanzo/antje", "org_id": "hanzo", "orgs": ["hanzo"]}
def _app(iam_user=ORG_USER):
app = web.Application()
@web.middleware
async def inject(request, handler):
if iam_user is not None:
request["iam_user"] = iam_user
return await handler(request)
app.middlewares.append(inject)
routes = web.RouteTableDef()
mcp.add_mcp_routes(routes, None)
app.add_routes(routes)
return app
def _rpc(client, method, params=None, id_=1):
payload = {"jsonrpc": "2.0", "id": id_, "method": method}
if params is not None:
payload["params"] = params
return client.post("/v1/mcp", json=payload)
def _run(coro):
return asyncio.run(coro)
# --- initialize ----------------------------------------------------------
def test_initialize_reports_server_and_tools_capability():
async def go():
async with TestClient(TestServer(_app())) as c:
r = await _rpc(c, "initialize")
assert r.status == 200
return await r.json()
body = _run(go())
assert body["jsonrpc"] == "2.0"
assert body["id"] == 1
assert body["result"]["serverInfo"]["name"] == "hanzo-studio"
assert body["result"]["protocolVersion"]
assert "tools" in body["result"]["capabilities"]
# --- tools/list ----------------------------------------------------------
def test_tools_list_returns_the_three_render_tools():
async def go():
async with TestClient(TestServer(_app())) as c:
r = await _rpc(c, "tools/list")
return await r.json()
body = _run(go())
tools = body["result"]["tools"]
assert {t["name"] for t in tools} == {"fix", "compose", "ask"}
for t in tools:
assert t["inputSchema"]["type"] == "object"
compose = next(t for t in tools if t["name"] == "compose")
assert compose["inputSchema"]["properties"]["paths"]["minItems"] == 2
assert compose["inputSchema"]["properties"]["paths"]["maxItems"] == 5
# --- tools/call ----------------------------------------------------------
def test_tools_call_fix_dispatches(monkeypatch):
seen = {}
async def fake_fix(request, server, org, path, instruction):
seen.update(org=org, path=path, instruction=instruction)
return {"ok": True, "prompt_id": "p1", "output_prefix": "fixes/x"}
monkeypatch.setattr(studio_home, "dispatch_fix", fake_fix)
async def go():
async with TestClient(TestServer(_app())) as c:
r = await _rpc(c, "tools/call", {
"name": "fix",
"arguments": {"path": "a/b.png", "instruction": "make it red"},
})
assert r.status == 200
return await r.json()
body = _run(go())
assert seen == {"org": "hanzo", "path": "a/b.png", "instruction": "make it red"}
result = body["result"]
assert result.get("isError") is not True
payload = json.loads(result["content"][0]["text"])
assert payload["prompt_id"] == "p1"
assert payload["output_prefix"] == "fixes/x"
def test_tools_call_compose_dispatches(monkeypatch):
seen = {}
async def fake_compose(request, server, org, paths, instruction):
seen.update(org=org, paths=paths, instruction=instruction)
return {"ok": True, "prompt_id": "p2", "output_prefix": "composes/y", "refs": len(paths)}
monkeypatch.setattr(studio_home, "dispatch_compose", fake_compose)
async def go():
async with TestClient(TestServer(_app())) as c:
r = await _rpc(c, "tools/call", {
"name": "compose",
"arguments": {"paths": ["a.png", "b.png"], "instruction": "merge them"},
})
return await r.json()
body = _run(go())
assert seen["paths"] == ["a.png", "b.png"]
payload = json.loads(body["result"]["content"][0]["text"])
assert payload["prompt_id"] == "p2"
assert payload["refs"] == 2
def test_tools_call_ask_echoes_question():
async def go():
async with TestClient(TestServer(_app())) as c:
r = await _rpc(c, "tools/call", {
"name": "ask", "arguments": {"question": "which asset?"},
})
return await r.json()
body = _run(go())
payload = json.loads(body["result"]["content"][0]["text"])
assert payload["question"] == "which asset?"
assert body["result"].get("isError") is not True
def test_dispatch_error_becomes_iserror_result(monkeypatch):
async def bad_fix(request, server, org, path, instruction):
raise studio_home.DispatchError("unknown asset: nope.png")
monkeypatch.setattr(studio_home, "dispatch_fix", bad_fix)
async def go():
async with TestClient(TestServer(_app())) as c:
r = await _rpc(c, "tools/call", {
"name": "fix", "arguments": {"path": "nope.png", "instruction": "x"},
})
assert r.status == 200 # never a 500
return await r.json()
body = _run(go())
assert "error" not in body # not a JSON-RPC error
assert body["result"]["isError"] is True
assert "unknown asset" in body["result"]["content"][0]["text"]
def test_unknown_tool_is_iserror_result():
async def go():
async with TestClient(TestServer(_app())) as c:
r = await _rpc(c, "tools/call", {"name": "frobnicate", "arguments": {}})
return await r.json()
body = _run(go())
assert body["result"]["isError"] is True
# --- protocol edges ------------------------------------------------------
def test_unknown_method_is_jsonrpc_error():
async def go():
async with TestClient(TestServer(_app())) as c:
r = await _rpc(c, "tools/frobnicate")
assert r.status == 200
return await r.json()
body = _run(go())
assert "result" not in body
assert body["error"]["code"] == -32601
assert body["id"] == 1
def test_notification_gets_no_body():
async def go():
async with TestClient(TestServer(_app())) as c:
r = await c.post("/v1/mcp", json={"jsonrpc": "2.0", "method": "notifications/initialized"})
return r.status, await r.text()
status, text = _run(go())
assert status == 202
assert text == ""
def test_bad_json_is_parse_error():
async def go():
async with TestClient(TestServer(_app())) as c:
r = await c.post("/v1/mcp", data="not json", headers={"Content-Type": "application/json"})
return r.status, await r.json()
status, body = _run(go())
assert status == 400
assert body["error"]["code"] == -32700
+162
View File
@@ -0,0 +1,162 @@
"""Unit tests for the per-org work log (middleware/worklog.py)."""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from middleware import worklog
@pytest.fixture
def orgdirs(tmp_path, monkeypatch):
"""Map each org to its own output dir, exactly as folder_paths does per-org, so
the tests exercise real cross-tenant isolation (one file per org)."""
def out_dir(org=None):
d = tmp_path / "orgs" / (org or "default") / "output"
d.mkdir(parents=True, exist_ok=True)
return str(d)
monkeypatch.setattr(worklog.folder_paths, "get_org_output_directory", out_dir)
return out_dir
def test_record_and_load_roundtrip(orgdirs):
row = worklog.record("acme", kind="fix", prompt="brighten", refs=["r.png"],
uploads=[], parents=["a.png"], output_prefix="fixes/a",
pid="pid-1", lane="gpu")
assert row["id"] == "pid-1" and row["status"] == "queued" and row["lane"] == "gpu"
rows = worklog.load("acme")
assert len(rows) == 1 and rows[0]["parents"] == ["a.png"] and rows[0]["kind"] == "fix"
def test_failed_dispatch_still_recorded_with_reason(orgdirs):
row = worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[],
parents=["a.png"], output_prefix="fixes/a",
status="failed", error="no GPU worker online for this org")
assert row["status"] == "failed" and row["id"].startswith("failed-")
assert "no GPU worker" in row["error"]
assert any(e["s"] == "failed" for e in row["events"]) # visible, not swallowed
def test_set_status_advances_but_respects_terminal(orgdirs):
worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
output_prefix="fixes/a", pid="p1")
assert worklog.set_status("acme", "p1", "running")
assert worklog.load("acme")[0]["status"] == "running"
assert worklog.set_status("acme", "p1", "cancelled")
# a terminal row is frozen — a late 'done' can't resurrect a cancelled job
assert not worklog.set_status("acme", "p1", "done")
assert worklog.load("acme")[0]["status"] == "cancelled"
def test_cross_tenant_isolation(orgdirs):
worklog.record("acme", kind="fix", prompt="secret acme prompt", refs=[], uploads=[],
parents=["a.png"], output_prefix="fixes/a", pid="p1")
# a different org's log is a different file — zero leakage either direction
assert worklog.load("acme") and worklog.load("globex") == []
worklog.record("globex", kind="fix", prompt="globex prompt", refs=[], uploads=[],
parents=["b.png"], output_prefix="fixes/b", pid="p2")
assert [r["prompt"] for r in worklog.load("acme")] == ["secret acme prompt"]
assert [r["prompt"] for r in worklog.load("globex")] == ["globex prompt"]
def test_cap_keeps_newest(orgdirs, monkeypatch):
monkeypatch.setattr(worklog, "_CAP", 5)
for i in range(12):
worklog.record("acme", kind="fix", prompt=f"p{i}", refs=[], uploads=[],
parents=[], output_prefix=f"fixes/{i}", pid=f"p{i}")
rows = worklog.load("acme")
assert len(rows) == 5 and [r["prompt"] for r in rows] == [f"p{i}" for i in range(7, 12)]
def test_build_lineage_chain():
# a.png --fix--> b.png --fix--> c.png ; and a.png --fix--> d.png (a sibling branch)
rows = [
{"kind": "fix", "prompt": "step1", "ts": 1, "parents": ["a.png"], "output": "b.png"},
{"kind": "fix", "prompt": "step2", "ts": 2, "parents": ["b.png"], "output": "c.png"},
{"kind": "fix", "prompt": "branch", "ts": 3, "parents": ["a.png"], "output": "d.png"},
]
lin = worklog.build_lineage(rows, "c.png")
assert [a["path"] for a in lin["ancestors"]] == ["a.png", "b.png"] # oldest first
assert lin["node"] == "c.png"
assert lin["descendants"] == []
# from the root, descendants include both branches, chronological
lin2 = worklog.build_lineage(rows, "a.png")
assert [d["path"] for d in lin2["descendants"]] == ["b.png", "d.png", "c.png"]
assert lin2["ancestors"] == []
def test_record_stores_node(orgdirs):
r = worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
output_prefix="f", pid="p1", lane="gpu", node="spark")
assert r["node"] == "spark"
r2 = worklog.record("acme", kind="fix", prompt="y", refs=[], uploads=[], parents=[],
output_prefix="g", pid="p2", lane="local")
assert r2["node"] == "studio pod" # local jobs run on the pod
def test_mark_started_finished_and_median(orgdirs):
worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
output_prefix="fixes/x", pid="p1")
assert worklog.mark_started("acme", "p1", 1000)
r = worklog.load("acme")[0]
assert r["started_at"] == 1000 and r["status"] == "running"
assert not worklog.mark_started("acme", "p1", 2000) # idempotent: keeps first
assert worklog.load("acme")[0]["started_at"] == 1000
assert worklog.mark_finished("acme", "p1", 1050)
r = worklog.load("acme")[0]
assert r["finished_at"] == 1050 and r["status"] == "done"
assert worklog.median_durations(worklog.load("acme"))["fix"] == 50 # 1050-1000
def test_median_durations_per_kind():
rows = [
{"kind": "fix", "ts": 100, "started_at": 100, "finished_at": 110}, # 10
{"kind": "fix", "ts": 200, "started_at": 200, "finished_at": 220}, # 20
{"kind": "fix", "ts": 300, "started_at": 300, "finished_at": 360}, # 60 → median 20
{"kind": "compose", "ts": 0, "started_at": 0, "finished_at": 100}, # 100
{"kind": "fix", "ts": 400}, # unfinished → ignored
]
m = worklog.median_durations(rows)
assert m == {"fix": 20, "compose": 100}
# no started_at → duration falls back to finished queued(ts)
assert worklog.median_durations([{"kind": "rerun", "ts": 0, "finished_at": 30}]) == {"rerun": 30}
def test_estimate_lanes_positions_and_eta():
now = 1000
active = [
{"id": "a", "kind": "fix", "lane": "gpu", "ts": 100, "status": "running", "started_at": now - 60, "node": "spark"},
{"id": "b", "kind": "fix", "lane": "gpu", "ts": 200, "status": "queued", "node": "spark"},
{"id": "c", "kind": "compose", "lane": "gpu", "ts": 300, "status": "queued", "node": "spark"},
{"id": "d", "kind": "fix", "lane": "local", "ts": 150, "status": "queued", "node": "studio pod"},
]
medians = {"fix": 120, "compose": 300}
est, counts = worklog.estimate_lanes(active, medians, now)
assert counts == {"gpu": 3, "local": 1}
# running: elapsed 60 of median 120 → 60 remaining
assert est["a"]["position"] == 1 and est["a"]["of"] == 3
assert est["a"]["elapsed_seconds"] == 60 and est["a"]["remaining_seconds"] == 60 and est["a"]["eta_seconds"] == 60
assert est["a"]["node"] == "spark"
# queued #2: wait = running remaining(60); eta = 60 + own fix median(120)
assert est["b"]["position"] == 2 and est["b"]["wait_seconds"] == 60 and est["b"]["eta_seconds"] == 180
# queued #3: wait = 60 + b's fix(120) = 180; eta = 180 + compose(300)
assert est["c"]["position"] == 3 and est["c"]["wait_seconds"] == 180 and est["c"]["eta_seconds"] == 480
# separate lane, single queued item, nothing running → starts ~now
assert est["d"]["position"] == 1 and est["d"]["of"] == 1 and est["d"]["wait_seconds"] == 0 and est["d"]["eta_seconds"] == 120
def test_estimate_lanes_default_median_for_unknown_kind():
est, _ = worklog.estimate_lanes(
[{"id": "x", "kind": "video", "lane": "gpu", "ts": 1, "status": "queued"}], {}, 0, default_median=200)
assert est["x"]["eta_seconds"] == 200
def test_build_lineage_terminates_on_cycle():
rows = [
{"kind": "fix", "prompt": "x", "ts": 1, "parents": ["y.png"], "output": "x.png"},
{"kind": "fix", "prompt": "y", "ts": 2, "parents": ["x.png"], "output": "y.png"},
]
lin = worklog.build_lineage(rows, "x.png") # must not loop forever
assert lin["node"] == "x.png"
+267 -1
View File
@@ -44,7 +44,273 @@ def test_safe_asset_blocks_traversal(tmp_path):
def test_status_vocab_matches_library_lifecycle():
assert sh._STATUSES == ("draft", "approved", "queued", "published")
assert sh._STATUSES == ("draft", "approved", "queued", "published", "deleted")
def test_resolve_uploads_keeps_only_safe_existing_images(tmp_path, monkeypatch):
in_dir = tmp_path / "orgs" / "acme" / "input"
in_dir.mkdir(parents=True)
(in_dir / "ref.png").write_bytes(b"\x89PNG")
(in_dir / "shot.jpg").write_bytes(b"jpg")
(tmp_path / "secret.txt").write_text("no")
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda org=None: str(in_dir))
out = sh._resolve_uploads("acme", [
"ref.png", # kept
"sub/dir/shot.jpg", # basename kept (upload names are flat)
"ref.png", # de-duped
"../secret.txt", # traversal + non-image -> dropped
"missing.png", # absent -> dropped
"notes.txt", # non-image -> dropped
42, # non-string -> dropped
])
assert out == ["ref.png", "shot.jpg"]
@pytest.mark.asyncio
async def test_compose_accepts_uploaded_references(tmp_path, monkeypatch):
"""A base library asset + one uploaded image is a valid 2-reference compose:
the upload is named directly (already in the input dir), the library asset is
staged, and both reach the graph no lower bound violation."""
org = "acme"
root = tmp_path / "orgs" / org / "output"
root.mkdir(parents=True)
(root / "library.json").write_text(json.dumps({"assets": [{"path": "a.png"}]}))
(root / "a.png").write_bytes(b"\x89PNG-base")
in_dir = tmp_path / "orgs" / org / "input"
in_dir.mkdir(parents=True)
(in_dir / "up_0001_.png").write_bytes(b"\x89PNG-up")
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda o=None: str(in_dir))
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
captured = {}
async def fake_queue(request, server, graph):
captured["graph"] = graph
return "pid-1"
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
out = await sh.dispatch_compose(None, None, org, ["a.png"], "merge them", uploads=["up_0001_.png"])
assert out["ok"] and out["refs"] == 2
load_names = [n["inputs"]["image"] for n in captured["graph"].values()
if n["class_type"] == "LoadImage"]
assert "up_0001_.png" in load_names # upload referenced by its own name
assert any(n.startswith("compose_src_") for n in load_names) # library asset staged
class _FakeQueue:
def __init__(self, running=None, pending=None):
self.running = running or []
self.pending = pending or []
self.deleted = []
def get_current_queue_volatile(self):
return (self.running, self.pending)
def delete_queue_item(self, fn):
before = len(self.pending)
self.pending = [it for it in self.pending if not fn(it)]
self.deleted.append(before - len(self.pending))
class _FakeServer:
port = 1
def __init__(self, **kw):
self.prompt_queue = _FakeQueue(**kw)
def _orgaware(tmp_path, monkeypatch):
def outd(o=None):
d = tmp_path / "orgs" / (o or "default") / "output"
d.mkdir(parents=True, exist_ok=True)
return str(d)
def ind(o=None):
d = tmp_path / "orgs" / (o or "default") / "input"
d.mkdir(parents=True, exist_ok=True)
return str(d)
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", outd)
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", ind)
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
return outd, ind
def test_cancel_marks_cancelled_and_removes_gpu_row_org_scoped(tmp_path, monkeypatch):
outd, _ = _orgaware(tmp_path, monkeypatch)
(Path(outd("acme")) / "library.json").write_text('{"assets":[]}')
sh.worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
output_prefix="fixes/x", pid="p1", lane="gpu")
(Path(outd("acme")) / "render_jobs.json").write_text(json.dumps([{"id": "p1", "prefix": "x"}]))
srv = _FakeServer()
assert sh._owns_job(srv, "acme", "p1")
assert not sh._owns_job(srv, "globex", "p1") # cross-tenant: not owned
assert sh._cancel_job(srv, "acme", "p1") == "gpu"
assert sh.worklog.load("acme")[0]["status"] == "cancelled"
assert json.loads((Path(outd("acme")) / "render_jobs.json").read_text()) == []
def test_cancel_deletes_local_pending_only_when_org_matches(tmp_path, monkeypatch):
_orgaware(tmp_path, monkeypatch)
mine = (1, "p1", {}, {"org_id": "acme"}, [])
theirs = (2, "p2", {}, {"org_id": "globex"}, [])
srv = _FakeServer(pending=[mine, theirs])
sh.worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
output_prefix="fixes/x", pid="p1")
assert sh._cancel_job(srv, "acme", "p1") == "local"
assert [it[1] for it in srv.prompt_queue.pending] == ["p2"] # only mine deleted
@pytest.mark.asyncio
async def test_amend_atomic_swap_keeps_original_refs_and_adds_new(tmp_path, monkeypatch):
outd, ind = _orgaware(tmp_path, monkeypatch)
(Path(outd("acme")) / "library.json").write_text('{"assets":[]}')
(Path(ind("acme")) / "fix_src_1.png").write_bytes(b"\x89PNG") # original ref, still present
(Path(ind("acme")) / "drop_9.png").write_bytes(b"\x89PNG") # new upload
sh.worklog.record("acme", kind="fix", prompt="brighten", refs=["fix_src_1.png"],
uploads=[], parents=["a.png"], output_prefix="fixes/a", pid="p1", lane="local")
captured = {}
async def fake_queue(request, server, graph):
captured["graph"] = graph
return "p2"
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
out = await sh.dispatch_amend(None, _FakeServer(), "acme", "p1", "brighten a lot more",
paths=[], uploads=["drop_9.png"])
assert out["amended_from"] == "p1" and out["prompt_id"] == "p2" and out["refs"] == 2
rows = {r["id"]: r for r in sh.worklog.load("acme")}
assert rows["p1"]["status"] == "cancelled" # original swapped out
assert rows["p2"]["status"] == "queued" and rows["p2"]["prompt"] == "brighten a lot more"
loads = [n["inputs"]["image"] for n in captured["graph"].values() if n.get("class_type") == "LoadImage"]
assert "fix_src_1.png" in loads and "drop_9.png" in loads # original kept + new added
@pytest.mark.asyncio
async def test_amend_unknown_job_is_not_owned(tmp_path, monkeypatch):
_orgaware(tmp_path, monkeypatch)
with pytest.raises(sh.NotOwned):
await sh.dispatch_amend(None, _FakeServer(), "acme", "ghost", "change it")
@pytest.mark.asyncio
async def test_amend_terminal_job_rejected(tmp_path, monkeypatch):
outd, _ = _orgaware(tmp_path, monkeypatch)
sh.worklog.record("acme", kind="fix", prompt="x", refs=["r.png"], uploads=[], parents=[],
output_prefix="fixes/x", pid="p1")
sh.worklog.set_status("acme", "p1", "done")
with pytest.raises(sh.DispatchError):
await sh.dispatch_amend(None, _FakeServer(), "acme", "p1", "too late")
def test_prompt_error_message_extracts_real_reason():
# model-less pod: /prompt returns a validation error — the user must SEE it.
body = {"error": {"type": "prompt_outputs_failed_validation",
"message": "Prompt outputs failed validation",
"details": "UNETLoader: unet_name 'qwen-image-edit-2511.safetensors' not in []"}}
msg = sh._prompt_error_message(body)
assert "Prompt outputs failed validation" in msg and "not in []" in msg
assert sh._prompt_error_message({}) and "silent" not in sh._prompt_error_message({}).lower()
assert sh._prompt_error_message({"error": "no GPU worker"}) == "no GPU worker"
@pytest.mark.asyncio
async def test_text_only_fix_dispatches_single_image(tmp_path, monkeypatch):
"""REGRESSION LOCK: click Fix, type text, no references → the single-image
/v1/library/fix path (base library asset staged, proven Qwen edit graph)."""
org = "acme"
root = tmp_path / "orgs" / org / "output"
root.mkdir(parents=True)
(root / "library.json").write_text(json.dumps({"assets": [{"path": "a.png"}]}))
(root / "a.png").write_bytes(b"\x89PNG")
in_dir = tmp_path / "orgs" / org / "input"
in_dir.mkdir(parents=True)
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda o=None: str(in_dir))
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
captured = {}
async def fake_queue(request, server, graph):
captured["graph"] = graph
return "pid-fix"
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
out = await sh.dispatch_fix(None, None, org, "a.png", "make the straps thicker")
assert out["ok"] and out["prompt_id"] == "pid-fix"
g = captured["graph"]
assert g["1"]["class_type"] == "LoadImage" and g["1"]["inputs"]["image"].startswith("fix_src_")
assert g["15"]["class_type"] == "SaveImage" and "straps thicker" in g["6"]["inputs"]["prompt"]
@pytest.mark.asyncio
async def test_fix_accepts_uploaded_base_image(tmp_path, monkeypatch):
"""A freshly uploaded photo can be the fix base (upload=) without landing it in
the library first the '+ add a photo' single-ref path."""
org = "acme"
in_dir = tmp_path / "orgs" / org / "input"
in_dir.mkdir(parents=True)
(in_dir / "shot_1.png").write_bytes(b"\x89PNG-up")
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda o=None: str(in_dir))
captured = {}
async def fake_queue(request, server, graph):
captured["graph"] = graph
return "pid-up"
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
out = await sh.dispatch_fix(None, None, org, "", "brighten it", upload="shot_1.png")
assert out["ok"] and captured["graph"]["1"]["inputs"]["image"] == "shot_1.png"
with pytest.raises(sh.DispatchError):
await sh.dispatch_fix(None, None, org, "", "brighten it", upload="../etc/passwd")
@pytest.mark.asyncio
async def test_queue_prompt_surfaces_downstream_error(tmp_path, monkeypatch):
"""A non-200 from the self-POST /prompt (model-less pod) becomes a DispatchError
carrying the REAL reason never a raw 502 the dialog swallows into 'Failed:'."""
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
class Srv:
port = None
srv = Srv()
routes = web.RouteTableDef()
@routes.post("/prompt")
async def prompt(request):
return web.json_response({"error": {"message": "Prompt outputs failed validation",
"details": "UNETLoader: unet_name '' not in []"}}, status=400)
async def edit(request):
graph = (await request.json())["graph"]
try:
pid = await sh._queue_prompt(request, srv, graph)
return web.json_response({"prompt_id": pid})
except sh.DispatchError as e:
return web.json_response({"error": str(e)}, status=400)
routes.post("/edit")(edit)
app = web.Application()
app.add_routes(routes)
ts = TestServer(app)
await ts.start_server()
srv.port = ts.port
client = TestClient(ts)
await client.start_server()
r = await client.post("/edit", json={"graph": {"1": {"class_type": "SaveImage", "inputs": {}}}})
body = await r.json()
assert r.status == 400 and "error" in body
assert "failed validation" in body["error"] and "not in []" in body["error"] # real reason, shown
await client.close()
@pytest.mark.asyncio
async def test_compose_rejects_single_reference(tmp_path, monkeypatch):
org = "acme"
root = tmp_path / "orgs" / org / "output"
root.mkdir(parents=True)
(root / "library.json").write_text(json.dumps({"assets": [{"path": "a.png"}]}))
(root / "a.png").write_bytes(b"\x89PNG")
in_dir = tmp_path / "orgs" / org / "input"
in_dir.mkdir(parents=True)
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda o=None: str(in_dir))
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
with pytest.raises(sh.DispatchError):
await sh.dispatch_compose(None, None, org, ["a.png"], "just one", uploads=[])
@pytest.mark.asyncio
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo
+32
View File
@@ -0,0 +1,32 @@
# Studio web — the Hanzo Studio product frontend
Our unique studio frontend: a specialized **chat app** on the shared Hanzo stack.
You say what to create; the cloud `/v1/chat` orchestrator drives studio's render
tools and the outputs land in the library. This is the default surface at
`studio.hanzo.ai/studio`.
- **Stack**: Vite + React + TS. `@hanzo/*` packages are npm-published, so a clean
`npm install` — no workspace linking.
- **Chat**: `POST api.hanzo.ai/v1/chat {capability, messages}``{reply, actions, ops}`
— the cloud `clients/chat` orchestrator, called **directly** (no studio proxy). The
`hanzo_token` cookie is `.hanzo.ai`-scoped, so `credentials:'include'` carries it
cross-origin and the handler bills the caller org. (Gateway CORS must allow the
`studio.hanzo.ai` origin with credentials.)
- **Engine**: the studio pod serves `/v1/session`, `/v1/library`, `/v1/render-queue`,
`/v1/gpu-status` same-origin (same cookie) — no token handling in the browser.
```bash
npm install
npm run build # -> dist/ (served at /studio; base = /studio/)
npm run dev # local, set VITE_STUDIO_API=https://studio.hanzo.ai
```
The ComfyUI-derived node editor ("Advanced mode") is a **separate** repo —
`hanzoai/studio-ui` (formerly `studio-frontend`) — not this app.
## Status / next
- Increment 1 (done): chat wired to `/v1/chat` create + live library/queue/gpu strip.
- Increment 2: swap the transcript/composer for `@hanzo/chat` `<Chat>` on `@hanzo/gui`
(GuiProvider + SessionProvider, copying console2's `Provider.tsx` spine).
- Wiring: Dockerfile multi-stage to build `dist/` and serve it at `/studio` in
place of `middleware/studio_home.html`. (No proxy — chat hits api.hanzo.ai direct.)
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Hanzo Studio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Generated Vendored
+1888
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@hanzo/studio-chat",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Studio as a specialized chat app — @hanzo/ai + @hanzo/gui talking to the cloud /v1/chat orchestrator, served at studio.hanzo.ai/studio.",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^6.0.7"
}
}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useRef, useState } from 'react'
import {
chat, getSession, getLibrary, getRenderQueue, gpuOnline, thumbURL,
type ChatMessage, type Session, type Asset, type RenderJob,
} from './api'
// Studio-chat: the specialized chat surface. You say what to create; the cloud
// /v1/chat "create" capability drives studio's render tools (fix/compose) and the
// outputs land in the library strip. Increment 1 uses a minimal own-UI; the next
// increment swaps the transcript/composer for @hanzo/chat's <Chat> on @hanzo/gui.
export function App() {
const [session, setSession] = useState<Session>({})
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState('')
const [busy, setBusy] = useState(false)
const [assets, setAssets] = useState<Asset[]>([])
const [jobs, setJobs] = useState<RenderJob[]>([])
const [gpu, setGpu] = useState(false)
const scroller = useRef<HTMLDivElement>(null)
useEffect(() => { getSession().then(setSession) }, [])
// Poll the library + in-flight queue so dispatched renders appear as they land.
useEffect(() => {
let alive = true
const tick = async () => {
if (!alive) return
const [lib, q, on] = await Promise.all([getLibrary(), getRenderQueue(), gpuOnline()])
if (!alive) return
setAssets(lib.assets.filter(a => a.status !== 'deleted').slice(0, 24))
setJobs(q.jobs); setGpu(on)
}
tick()
const t = setInterval(tick, 5000)
return () => { alive = false; clearInterval(t) }
}, [])
useEffect(() => { scroller.current?.scrollTo(0, scroller.current.scrollHeight) }, [messages, busy])
async function send() {
const text = input.trim()
if (!text || busy) return
const next = [...messages, { role: 'user' as const, content: text }]
setMessages(next); setInput(''); setBusy(true)
try {
const r = await chat(next)
const note = r.actions.length
? `\n\n${r.actions.map(a => a.error ? `⚠︎ ${a.name}: ${a.error}` : `${a.name} dispatched`).join('\n')}`
: ''
setMessages([...next, { role: 'assistant', content: (r.reply || '…') + note }])
} catch (e) {
setMessages([...next, { role: 'assistant', content: `Couldn't reach the studio chat (${(e as Error).message}).` }])
} finally {
setBusy(false)
}
}
return (
<div className="app">
<header className="top">
<div className="brand">Hanzo <b>Studio</b></div>
<div className="spacer" />
<div className={`gpu ${gpu ? 'on' : 'off'}`}>{gpu ? 'GPU online' : 'no GPU'}</div>
<div className="who">{session.authenticated ? (session.org || session.user || 'signed in') : 'sign in'}</div>
</header>
<main className="body">
<section className="chat">
<div className="scroll" ref={scroller}>
{messages.length === 0 && (
<div className="hero">
<h1>What should we create?</h1>
<p>Describe an edit or a new shot make the Valentina back crotchless,
put design 13 on model 01. Ill run it on your GPU and it lands below.</p>
</div>
)}
{messages.map((m, i) => (
<div key={i} className={`msg ${m.role}`}><div className="bubble">{m.content}</div></div>
))}
{busy && <div className="msg assistant"><div className="bubble dim">working</div></div>}
</div>
<div className="composer">
<textarea
value={input} placeholder="Describe what to create or change…"
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }}
/>
<button onClick={send} disabled={busy || !input.trim()}>Send</button>
</div>
</section>
<aside className="rail">
{jobs.length > 0 && (
<div className="queue">
<div className="rail-h">Rendering ({jobs.length})</div>
{jobs.map(jb => <div key={jb.id} className="job">{jb.prefix || jb.id.slice(0, 8)}</div>)}
</div>
)}
<div className="rail-h">Library</div>
<div className="grid">
{assets.map(a => (
<a key={a.path} className="cell" href={thumbURL(a.path)} target="_blank" rel="noreferrer">
<img loading="lazy" src={thumbURL(a.path)} alt="" />
</a>
))}
{assets.length === 0 && <div className="empty">Renders appear here.</div>}
</div>
</aside>
</main>
</div>
)
}
+52
View File
@@ -0,0 +1,52 @@
// The studio-chat data layer. Two backends, no proxy layer between them:
// - the cloud chat orchestrator: POST api.hanzo.ai/v1/chat (clients/chat) —
// called DIRECTLY, no studio pass-through. The hanzo_token cookie is scoped to
// .hanzo.ai so credentials:'include' carries it cross-origin, and the handler
// replays it into the per-org-billed completion. (Gateway CORS must allow the
// studio.hanzo.ai origin with credentials.)
// - the studio engine API: /v1/session, /v1/library, /v1/render-queue, … —
// same-origin on studio.hanzo.ai (same cookie).
// No token handling in the browser: the cookie is the credential for both.
// VITE_STUDIO_API / VITE_CHAT_API override the bases for local dev.
const BASE = (import.meta.env.VITE_STUDIO_API ?? '').replace(/\/$/, '')
const CHAT_API = (import.meta.env.VITE_CHAT_API ?? 'https://api.hanzo.ai').replace(/\/$/, '')
async function j<T>(url: string, init?: RequestInit): Promise<T> {
const r = await fetch(url, { credentials: 'include', ...init })
if (!r.ok) throw new Error(`${url} -> ${r.status}`)
return r.json() as Promise<T>
}
export type Role = 'user' | 'assistant'
export interface ChatMessage { role: Role; content: string }
export interface Action { name: string; args?: Record<string, unknown>; result?: unknown; error?: string }
export interface Op { name: string; args?: Record<string, unknown> }
export interface ChatReply { reply: string; actions: Action[]; ops: Op[] }
// One turn of the create capability: the model edits/combines library assets;
// `actions` are the renders it dispatched (each result carries a prompt_id).
export function chat(messages: ChatMessage[], capability = 'create'): Promise<ChatReply> {
return j<ChatReply>(`${CHAT_API}/v1/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ capability, messages }),
})
}
export interface Session {
user?: string; email?: string; org?: string; orgs?: string[]
authenticated?: boolean; iam_url?: string
}
export const getSession = () => j<Session>(`${BASE}/v1/session`).catch(() => ({ authenticated: false } as Session))
export interface Asset { path: string; status?: string; mtime?: number; view?: unknown }
export const getLibrary = () => j<{ org: string; assets: Asset[] }>(`${BASE}/v1/library`).catch(() => ({ org: '', assets: [] }))
export interface RenderJob { id: string; prefix?: string; refs?: string[]; ts?: number; age?: number; status?: string }
export const getRenderQueue = () => j<{ jobs: RenderJob[] }>(`${BASE}/v1/render-queue`).catch(() => ({ jobs: [] }))
export const gpuOnline = () => j<{ online: boolean }>(`${BASE}/v1/gpu-status`).then(r => r.online).catch(() => false)
// Thumbnail URL for a library asset (the studio pod serves a downsized JPEG).
export const thumbURL = (path: string) => `${BASE}/v1/library/file?thumb=1&path=${encodeURIComponent(path)}`
export const fileURL = (path: string) => `${BASE}/v1/library/file?path=${encodeURIComponent(path)}`
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import './styles.css'
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+48
View File
@@ -0,0 +1,48 @@
:root {
--bg: #0d0d0f; --panel: #151517; --line: #26262b; --text: #f4f4f6;
--dim: #9a9aa2; --accent: #f4f4f6; --accent-ink: #0d0d0f;
}
@media (prefers-color-scheme: light) {
:root { --bg: #fafafa; --panel: #fff; --line: #e6e6ea; --text: #16161a; --dim: #6b6b73; --accent: #16161a; --accent-ink: #fff; }
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body { background: var(--bg); color: var(--text); font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.app { display: flex; flex-direction: column; height: 100%; }
.top { display: flex; align-items: center; gap: 12px; padding: 12px 18px; border-bottom: 1px solid var(--line); }
.brand { font-weight: 500; letter-spacing: .2px; } .brand b { font-weight: 700; }
.spacer { flex: 1; }
.gpu { font-size: 12px; padding: 3px 8px; border-radius: 999px; border: 1px solid var(--line); color: var(--dim); }
.gpu.on { color: var(--text); } .gpu.on::before { content: "● "; color: #35c26b; }
.who { font-size: 13px; color: var(--dim); }
.body { flex: 1; display: grid; grid-template-columns: 1fr 320px; min-height: 0; }
.chat { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--line); }
.scroll { flex: 1; overflow-y: auto; padding: 22px; }
.hero { max-width: 560px; margin: 8vh auto 0; text-align: center; }
.hero h1 { font-size: 30px; font-weight: 600; margin: 0 0 10px; }
.hero p { color: var(--dim); }
.msg { display: flex; margin: 10px 0; } .msg.user { justify-content: flex-end; }
.bubble { max-width: 74%; padding: 10px 14px; border-radius: 14px; white-space: pre-wrap; border: 1px solid var(--line); background: var(--panel); }
.msg.user .bubble { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
.bubble.dim { color: var(--dim); }
.composer { display: flex; gap: 10px; padding: 14px; border-top: 1px solid var(--line); }
.composer textarea { flex: 1; resize: none; height: 44px; padding: 11px 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); color: var(--text); font: inherit; }
.composer button { padding: 0 18px; border-radius: 12px; border: 0; background: var(--accent); color: var(--accent-ink); font-weight: 600; cursor: pointer; }
.composer button:disabled { opacity: .5; cursor: default; }
.rail { overflow-y: auto; padding: 16px; }
.rail-h { font-size: 12px; text-transform: uppercase; letter-spacing: .6px; color: var(--dim); margin: 14px 0 8px; }
.queue .job { font-size: 12px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 8px; margin-bottom: 6px; color: var(--dim); }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
.cell { aspect-ratio: 3 / 4; overflow: hidden; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); }
.cell img { width: 100%; height: 100%; object-fit: cover; display: block; }
.empty { color: var(--dim); font-size: 13px; }
@media (max-width: 820px) {
.body { grid-template-columns: 1fr; }
.rail { display: none; }
.chat { border-right: 0; }
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// Served same-origin by the studio Python pod at /studio, so the app is based at
// /studio/ and every /v1/* fetch rides the same-origin hanzo_token cookie (no CORS).
export default defineConfig({
base: '/studio/',
plugins: [react()],
build: { outDir: 'dist', sourcemap: false },
})
BIN
View File
Binary file not shown.