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
hanzo-dev b6317999dd fix: dedupe version key from rebase resolution
Claude-Session: https://claude.ai/code/session_01Gq8suw7uuodAMPDRpo6iAB
2026-07-14 16:52:46 -07:00
hanzo-dev 5e9f72bc16 feat(studio): Studio Home — content-forward default view; node editor becomes Advanced mode
/studio: org-scoped asset gallery over library.json (status chips draft/
approved/queued/published), per-asset workflow provenance from the embedded
PNG graph, Re-run (exact graph, fresh seed) and chat-fix (instruction ->
Qwen-edit pass on that output) both through the ONE /prompt path with the
caller's own credentials. Pipelines tab reuses the 0.15.1 deep-link opener
for Edit/Run; STUDIO_HOME_DEFAULT=1 makes / land on /studio (SPA at
/?advanced=1). 5 unit tests.

Claude-Session: https://claude.ai/code/session_01Gq8suw7uuodAMPDRpo6iAB
2026-07-14 16:52:24 -07:00
Hanzo AI acd3bcc99c chore: version 0.15.1 — deep-link opener atop the live 0.15.0 line 2026-07-14 06:32:07 -07:00
Hanzo AI 2ea07a2678 feat(studio): deep-link opener — ?org=&workflow=&run=1 opens (and runs) a saved workflow
A gallery/console/chat link lands you on the graph ready to render: sets the
session org (so a re-run dispatches to that org's BYO-GPU and outputs to its
gallery), loads the saved workflow, and optionally auto-queues. Injected as a
frontend shim (patch_deeplink.py, step 13 of apply-branding), guarded end-to-end.
2026-07-14 06:06:24 -07:00
Hanzo AI 1dba6abcbf fix(ci): launch smoke test waits 120s — boot now includes alembic + assets scan, 30s flakes at the boundary 2026-07-12 22:53:35 -07:00
Hanzo AI cc352ba658 chore: version 0.14.11 2026-07-12 22:50:42 -07:00
Hanzo AI 3bb37419b5 chore: version 0.14.10 2026-07-12 22:50:16 -07:00
Hanzo AI 5c9215afe3 fix(cloud): persist models on the tenant-data PVC
/app/models is ephemeral overlay — every model vanished on pod restart
(CheckpointLoaderSimple: 'not in []'). Bake extra_model_paths.yaml into
the image registering /app/orgs/.models (PVC) as the default model store
for all model classes; main.py auto-loads it. Downloads land durable.
2026-07-12 22:42:34 -07:00
Hanzo AI 82a8487e7b fix(test): force CPU device path before importing execution — GPU-less CI runner has no NVIDIA driver 2026-07-12 22:32:52 -07:00
Hanzo AI ef128fadcc chore: drop watchdog script — hanzo gpu connect --studio-dir supervises the render backend (one way) 2026-07-12 22:25:03 -07:00
Hanzo AI 35f4a0328c merge: zen-video headless text-to-video queue (lint-clean) 2026-07-12 22:14:12 -07:00
Hanzo AI 9978f26e25 fix(zen-video): lint — split imports, JSON to stdout without print 2026-07-12 22:14:12 -07:00
f8007e6ced feat(zen-video): headless text-to-video queue on the GB10 (Wan2.2 via Studio)
Serialized, SQLite-persisted queue fronting Hanzo Studio (ComfyUI :8188) that
turns a prompt into a real MP4 on the GB10. One GPU => one job at a time; a
single async worker drains an ordered queue and submits to Studio.

Two composable surfaces over one backend:
- OpenAI-style /v1/videos/generations (gateway-injected X-Org-Id identity, per
  generated-second billing into a usage table).
- do-ai-compatible async /v1/videos + /{id} + /{id}/content (Bearer provider
  key) — the exact Sora-style create->poll->download contract the cloud LLM
  gateway's video client drives, so the ai registry adopts spark by pointing
  the zen3-video provider at spark, no ai-side client change.

Zen video family public names (zen3-video/-fast/-pro); private Wan2.2 filenames
confined to zen_wan_workflow.py, never returned to callers. Includes the
systemd unit and studio_clean_restart.sh (breaks the Studio JIT/EADDRINUSE
crash-loop).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:11:05 -07:00
Hanzo AI 5ed1dacbdc fix(dispatch): route BYO-GPU jobs before coordinator validation
A GPU-less cloud pod validates ckpt_name & friends against ITS OWN (empty)
model list, rejecting every checkpoint-referencing graph with 'not in []'
before dispatch could ship it to the org's GPU box. Validation belongs where
execution happens: the dispatch attempt now runs first, and the worker
validates against its real node classes + models. Orgs with no online GPU
fall through to local validation unchanged.
2026-07-12 22:08:44 -07:00
Hanzo AI 4200c52000 fix(packs): comfyui_segment_anything under Transformers 5; installer applies scripts/patches/<pack>-*.patch
BertModelWarper now delegates to the real BertModel.forward instead of
reimplementing internals whose private signatures drift between releases;
checkpoint weights remap bert.* -> bert.bert_model.* at load. The installer
force-checkouts each pin (discarding prior patches) then applies any
scripts/patches/<pack>-*.patch, so re-runs stay idempotent and the image
build gets the same fix the GB10 venv had by hand.
2026-07-12 22:08:44 -07:00
Hanzo AI 1d67adb0fb feat(scripts): karma library manifest generator + studio watchdog (from the GB10 box)
karma_manifest.py regenerates the ONE canonical library.json index of the
karma org's rendered assets (storefront + socials queue both read it).
studio_watchdog.sh keeps the local render backend on :8188 alive unattended;
paths derive from the script location (STUDIO/STUDIO_PY overridable).
2026-07-12 22:08:44 -07:00
hanzo-devandHanzo AI 50c338ecfd feat(content): record completed renders as Assets in the content lane
A finished render becomes an `Asset` document — the DocType hanzoai/cloud
clients/content already declares (framework module "marketing") and already writes
itself when IT drives a render. Studio-initiated renders were the only ones with
no path into that record; this closes exactly that gap and adds NO second schema,
NO second lifecycle, and NO second storage plane. cloud is untouched.

  POST /v1/framework/Asset   (the already-live generic framework surface)

Same field shape clients/content writes (studio_render.go draftAsset): title,
kind, design, prompt, file, generator, workflow, source_prompt_id, render_params,
project, tags. `file` is the org-scoped output key (orgs/<org>/output/...) —
byte-identical to the key persistOrLink produces and the key Studio's own output
mirror lands, so the bytes are ALREADY persisted: this module uploads nothing and
holds no storage credential.

Written AS THE REQUESTING USER: the verified IAM access token rides to the prompt
worker on the queue's existing `sensitive` channel (never persisted into history)
and authenticates every write, so the Asset lands in that user's org with that
user's rights. No service account, no impersonation, no cross-org path.

Assets land in draft (the DocType default); the content lane's ONE state machine
(draft->in_review->approved->queued->published) owns every transition, and a human
does the promoting. Publishing is what a storefront shows, so an unattended render
can never push an image to karma.style.

Fire-and-forget on the same prompt-worker seam as billing: a render is never failed
because the lane was unreachable. Env-gated (STUDIO_CONTENT_PUBLISH); local dev
unaffected.
2026-07-12 22:05:54 -07:00
hanzo-dev 203375e5db ci: run linux jobs on hanzo-build-linux-amd64 ARC scale set (no GitHub-hosted builders)
Claude-Session: https://claude.ai/code/session_01RFrWpXc1BsqfrFYMbyDusJ
2026-07-11 00:22:48 -07:00
Hanzo Dev cea869bd14 feat(hanzo_engine): music + dub nodes, textured 3D, flagship pipeline
Add HanzoMusic (POST /v1/audio/music -> ACE-Step stereo AUDIO) and HanzoDub
(POST /v1/animate -> MuseTalk lip-sync: portrait IMAGE + driving AUDIO -> frames
+ audio). Fix HanzoImageTo3D to send the now-live `texture` flag (textured GLB
via SLAT + FlexiCubes) and offer obj. Correct HanzoTTS response_format to the
engine's actual wav/pcm. Drop the torchaudio dependency: studio_audio_to_wav_bytes
now uses the stdlib wave module, and studio_audio_to_data_url feeds the dub's
driving audio.

Ship user/default/workflows/hanzo-full-generative-pipeline.json: a prompt is
expanded by Chat and fans out to Image Gen -> (textured Image-to-3D AND WAN
Text-to-Video), plus TTS narration and ACE-Step Music -- every modality in one
graph, all on the native engine.

contract_check.py drives all 8 nodes over real HTTP against a stub engine that
enforces each Rust handler's request fields and the async job submit->poll->content
protocol.
2026-07-10 16:48:53 -07:00
Hanzo Dev ace9d057f8 feat(hanzo_engine): native-engine node pack over one shared client
Run Hanzo Studio AI workloads on the native-Rust Hanzo Engine instead of
in-process Python. Transport, config, and Studio<->engine type conversions
live in one client (client.py); each node is one endpoint, so a new engine
endpoint is a new small node with no framework change.

Nodes (category Hanzo/Engine): HanzoChat (SSE streaming, model dropdown from
/v1/models), HanzoImageGen (engine width/height/n schema), HanzoTTS
(/v1/audio/speech -> AUDIO), HanzoASR (/v1/audio/transcriptions -> text),
HanzoEngineRequest (generic /v1 escape hatch), HanzoVisionCaption,
HanzoTextToVideo, HanzoImageTo3D, HanzoSaveText.

Config: one way, HANZO_ENGINE_URL (default http://127.0.0.1:1234), /v1/* only.
Ships example user/default/workflows/hanzo-native-pipeline.json (LLM prompt ->
image prompt -> image gen -> save), e2e-verified against a CPU Qwen3-0.6B engine.
2026-07-09 21:16:03 -07:00
hanzo-dev 4d98076483 byo-gpu: ship uploaded inputs with the render job
Uploaded images land in orgs/{org}/input on this GPU-less pod; the BYO
worker renders on its own disk and cannot read them, so LoadImage of an
uploaded photo failed. Collect every LoadImage-referenced file that
exists under the org input dir, base64-inline it into the studio.render
job input (capped, traversal-safe). The worker materializes them before
rendering (cloud cli/gpu.go). Files already on the worker are harmless to
resend.
2026-07-08 15:50:46 -07:00
hanzo-dev 7ff75c0cce fix(dispatch): worker-mode nodes queue locally (never re-dispatch); absolute-import fallback for top-level server.py 2026-07-08 15:23:47 -07:00
hanzo-dev 28ed5bd9d1 feat(dispatch): tell the BYO-GPU worker where to return outputs (uploadUrl)
The studio.render job input now carries this deployment's public base (STUDIO_PUBLIC_URL,
default studio.hanzo.ai) as uploadUrl, so the worker POSTs finished renders back to
/upload/output here — org derived from the user's token, landing in orgs/{org}/output.
2026-07-07 20:39:47 -07:00
hanzo-dev fd02d0f6b9 feat(studio): token-authorized /upload/output for BYO-GPU gallery ingest
A remote hanzod gpu-fleet worker renders a studio.render job on its own GPU
and POSTs each finished image to /upload/output with the user's IAM bearer.
The IAM middleware validates the token and derives org from owner/groups, so
the file lands in this org's orgs/{org}/output (PVC + S3 mirror -> gallery).
No S3/rclone credentials on the worker box; the session token is the only
credential. Replaces the old rclone/S3-creds output sync.

- verify_jwt accepts a set of first-party audiences (STUDIO_IAM_ALLOWED_AUDIENCES)
  so a console/desktop/CLI-minted user token authorizes an org-scoped upload
  without a per-service re-mint (org still comes from owner/groups, not aud).
- image_upload forwards the request so /upload/image|mask|output are org-scoped.
2026-07-07 17:35:05 -07:00
hanzo-dev b24a2a12c1 fix(dispatch): accept browser session cookie (hanzo_token/access_token) as IAM bearer — mirrors iam_auth_middleware 2026-07-07 16:48:08 -07:00
z 49d300aba3 feat(studio): complete modality coverage — Kolors (t2i photorealism), Mochi-1 + Pyramid-Flow (permissive video), DiffRhythm (long-form music), CosyVoice2 (streaming/emotional TTS). All Apache-clean (PyramidFlow weights noted). Studio now spans every modality. 2026-07-07 14:21:22 -07:00
z 929671031b feat(studio): fill modality gaps — FOLEY (MMAudio video->audio, AudioLDM2 text->SFX, weights NC noted), audio source-separation (Demucs MIT), VibeVoice TTS (MIT). Verified real weight licenses (AudioLDM2/MMAudio weights are NC despite Apache code). 2026-07-07 14:16:38 -07:00
hanzo-dev c443d56f5c feat(studio): dispatch Run to org's connected GPU node via gpu-jobs (forwards user IAM token; org+project+billing derived cloud-side); in-pod fallback 2026-07-07 10:46:21 -07:00
z 43b7c60325 feat(studio): SOTA OSS across ALL modalities — video (WAN 2.2/LTX/CogVideoX/Hunyuan), voice/TTS (Kokoro/Chatterbox/F5), GGUF loader. Code-only, weights runtime BYO-license; non-commercial (F5 CC-BY-NC) + restricted (Hunyuan Tencent) weights noted. Fully OSS + customizable. 2026-07-06 11:26:42 -07:00
z d774d3f508 feat(studio): add music/song generation — ACE-Step (Apache-2.0, commercial-clean text->music w/ vocals+accompaniment). Code+weights permissive; LeVo (best-quality Tencent) BYO-license path noted. 2026-07-06 10:55:08 -07:00
z 40c26e3080 feat(studio): add 3D-gen + texture node packs — TRELLIS.2, Pixal3D, ComfyUI-3D-Pack (TripoSR/InstantMesh/gaussian-splat/texture-bake), Hunyuan3D wrapper. Code-only (permissive MIT/Apache); weights NOT shipped (runtime download, BYO-license); Hunyuan weights-license noted inline. No gate. 2026-07-06 10:30:12 -07:00
hanzo-dev 1063ee3d0e fix(sd1_tokenizer): restore canonical CLIP-L vocab (unbreaks FLUX.1 DualCLIPLoader)
The bundled studio/sd1_tokenizer/vocab.json had the token 'comfy</w>' scrubbed
(49407 tokens vs canonical 49408) while merges.txt still contained the 'com fy'
merge, so CLIPTokenizer.from_pretrained raised 'Error while initializing BPE:
Token comfy</w> out of vocabulary' for ANY FLUX.1 CLIP load (DualCLIPLoader type
'flux'). Only ever hit now because FLUX.1-Fill is the first CLIP-L consumer here
(Qwen/Klein use other tokenizers). Restored the canonical openai/clip-vit-large-
patch14 vocab.json (49408 tokens). Required by couture_best.json.
2026-07-05 18:34:07 -07:00
hanzo-dev 0337677c77 studio: add couture_best garment-swap workflow (2026 SOTA bake-off winner)
Head-to-head of 10 garment-swap methods on one fixed test case (pool_hires +
red_realistic). Winner = CatVTON-Flux native in-context transfer: SAM mask ->
InpaintCrop -> [garment|person] 768x1024 canvas -> FLUX.1-Fill + CatVTON LoRA ->
crop person half -> InpaintStitch (background byte-identical). Reproduces the
reference garment (true red, square neck, corset seams, ties) where Qwen-Edit
mottles the fabric and whole-image img2img smears the background.

- user/default/workflows/fashion/couture_best.json  parameterized (scene + garment -> Run)
- custom_nodes/hanzo-packs.txt  pin ComfyUI-Inpaint-CropAndStitch (GPL-3.0); note CatvtonFluxWrapper (bakeoff-only)

Model licenses flagged NC (FLUX.1-Fill-dev, Redux, CatVTON LoRA); Qwen-Image-Edit
(Apache-2.0) is the commercial fallback path.
2026-07-05 18:33:26 -07:00
hanzo-dev 0e774c87b6 studio: style login footer links (were default-blue underlined)
The bottom Docs/GitHub/Console/Discord row had no .product a rule, so it fell
back to the browser default link color + underline. Match the card's muted
console tokens (#cdd2da, no underline, hover -> #eef0f3).
2026-07-05 12:33:38 -07:00
hanzo-dev 201cfaf2f3 studio: console-match login + identity chrome, force Nodes 2.0 on
Task 2 — login/header look & feel to match console.hanzo.ai. The console is a
deliberately monochrome true-black system (no brand-purple; white primary
action; 9px radii; hairline #23262b borders), so the prior black+#8b5cf6 purple
login + identity pill did not read as the same product family. Restyled to
console's tokens:
- web/login.html: rebuilt as a console-style centered card (surface #050505,
  1px #23262b, 9px radius) with the 5-path Hanzo H mark, 'Sign in with Hanzo'
  as a white (#fbfcfd) primary button with near-black text — not the default
  ComfyUI splash.
- branding/hanzo-identity.css: pill + dropdown restyled monochrome to match
  console's OrgSwitcher/user menu (surface/border/text tokens, neutral avatar,
  red only for logout). Bumped identity bundle v2 -> v3 (cache-bust).

Task 3 — Nodes 2.0 (Comfy.VueNodes.Enabled) shipped as the only node renderer.
New idempotent branding/patch_vuenodes.py (wired as apply-branding step 12):
forces the registered default !1 -> !0, sets type boolean -> hidden so the
Settings dialog row is gone (ComfyUI-native, like Bookmarks.V2), flips the
inline ??!1 read fallbacks to ??!0, and deletes the logo-menu nodes-2.0-toggle
item. Same hidden treatment for the sibling AutoScaleLayout so the whole
'Nodes 2.0' settings category collapses. Verified: forced on, no user toggle.
2026-07-05 12:30:57 -07:00
hanzo-dev b289513c9d docs(LLM): custom-node packs mechanism + studio_compat shim 2026-07-04 13:20:01 -07:00
hanzo-dev e56eee9dc6 studio: identity menu shows Sign in only on 401, not local auth-off
Locally (IAM off) /v1/session returns 200 with authenticated:false; the
menu now renders the functional Local User pill instead of a Sign in
button whose /login 404s locally. The real signed-out state is the cloud
401 (auth on, no token) -> Sign in. Verified unmocked against the live
backend. Bundle v2.
2026-07-04 13:19:45 -07:00
hanzo-dev a9165125b5 studio packs: vendor top ComfyUI node packs as first-class hanzo packages
Add a comfy->studio import shim so upstream ComfyUI custom nodes run
unmodified against this fork's renamed core, then vendor the popular packs
(Impact-Pack/Subpack, IPAdapter_plus, controlnet_aux, Ultimate SD Upscale,
Frame-Interpolation, VideoHelperSuite, rgthree, Custom-Scripts, KJNodes,
cg-use-everywhere, SAM2, segment_anything) pinned to immutable commits and
reproducibly rebuilt in the image.

- studio_compat: one meta-path finder aliasing comfy*->studio* and mirroring
  Comfy*->Studio* API classes (the deferred phase-4 of scripts/rename_modules.py,
  done with the 3.12 find_spec API); activated by one import at top of main.py.
- nodes.py: honor the upstream `comfy_entrypoint` V3 extension name too.
- custom_nodes/hanzo-packs.txt: pinned pack manifest (SHAs + SPDX licenses).
- custom_nodes-requirements.txt + scripts/install_custom_nodes.sh: pack deps
  with the cu130 torch / NumPy 2.x / Transformers 5.x stack locked (derived
  from the live env, never downgraded); ultralytics installed --no-deps.
- Dockerfile/.dockerignore/.gitignore: image vendors the packs at build.

Live GB10 worker verified: node types 644 -> 1290 (+646), 0 import failures.
was-node-suite quarantined (hard numba dep incompatible with NumPy 2.5).
2026-07-04 13:19:09 -07:00
hanzo-dev a9d77be4e7 studio: IAM identity menu + session API + de-Comfy display strings
Top-right user menu driven by GET /v1/session (user, org switcher,
project scope, Console link, Logout). Org switching is real via one
hook: the auth middleware applies session.resolve_org to a COPY of the
validated user so iam_user["org_id"] -- the id every tenancy/output
path already reads -- reflects the selection; membership from
_orgs_from_claims (owner + organizations/orgs/groups). POST
/v1/session/{org,project} set validated cookies; /logout clears the
session + selection cookies and bounces to the IAM end-session -> /login.
Frontend is a SPA-agnostic pill + dropdown (branding/hanzo-identity.*),
injected like the copilot (patch_identity.py, apply-branding step 10).

patch_destring.py (step 11) sweeps English display 'ComfyUI' -> 'Hanzo
Studio' (boundary-safe: skips code identifiers, i18n keys, hyphenated
product names, and non-English locale bundles) and rebrands the Settings
section header value 'Comfy' -> 'Studio' / 'Comfy-Desktop' -> 'Studio
Desktop' while preserving the setting KEYS. Served English display
'ComfyUI' 63->0. Tests: middleware_test/session_test.py (12 new).
2026-07-04 13:15:51 -07:00
hanzo-dev b2e96ef256 copilot: chat sidebar that reads + mutates the workflow graph
Backend POST /v1/copilot/chat (middleware/copilot.py, registered in server.py)
runs one OpenAI tool-calling round to a chat endpoint (STUDIO_COPILOT_URL,
default local engine :1234 else api.hanzo.ai) and returns {reply, ops}. add_node
ops validated against nodes.NODE_CLASS_MAPPINGS so the model can't invent nodes.

Frontend bundle (branding/hanzo-copilot.js + on-brand css) registers a Copilot
sidebar tab via app.extensionManager.registerSidebarTab and applies ops against
app.graph (LiteGraph): add/set_widget/connect/set_prompt/move/delete/layout/queue.
Every op guarded. Injected by branding/patch_copilot.py (step 9 of apply-branding.sh),
survives frontend upgrades. window.HanzoCopilot.applyOps exposed for tests.

Tests: middleware_test/copilot_test.py (13, op-validation + mocked tool loop),
branding/copilot_smoke.py (headless: applier mutates a mock graph + panel renders).
Live route needs one server restart to register.
2026-07-04 12:19:39 -07:00
hanzo-dev 55d5c7a58e product workflows: worn-3D invisible-figure volume (reject flat-lay) 2026-07-04 11:27:12 -07:00
hanzo-dev fef673ade4 workflows: clean left-to-right layout (inputs -> prep -> conditioning -> sampler -> output) 2026-07-04 00:22:03 -07:00
hanzo-dev c0bd700407 studio: meter one render-completion event to Commerce (per-org, idempotent)
On a successful prompt execution, emit exactly one fire-and-forget usage
event to Commerce meter-events (POST /v1/billing/meter-events): userId =
IAM org_id, value = artifacts produced, idempotency = prompt_id. Timeout +
retry-once; a billing failure only logs and is dropped — a render is never
blocked or failed for billing.

Replaces the execution-time-priced record_usage (which billed against the
STUDIO_ORG_ID env, not the real IAM tenant) with metered record_render:
Studio reports raw quantity, pricing lives on the meter in Commerce.

Env-gated on STUDIO_BILLING_URL + STUDIO_COMMERCE_TOKEN + STUDIO_BILLING_METER
(all absent locally = no-op, zero behavior change). check_balance left as-is.

Tests: tests-unit/middleware_test/billing_test.py — success shape, idempotency
key, timeout-doesn't-block + retry-once, 4xx-no-retry, 5xx-retry, unconfigured
and partial-config no-ops. Full middleware_test suite green (52 passed).
2026-07-04 00:19:08 -07:00
hanzo-dev 8e6423dc30 theme: dark selected-state for sidebar icons (interface-panel-selected-surface was white) + hover/active state overrides 2026-07-04 00:13:10 -07:00
hanzo-dev e46945427d ci: bootstrap uv on runners that lack it 2026-07-03 22:19:36 -07:00
57bb837d35 fix(auth): route studio login through the server OIDC /callback flow (#5)
The SPA "Sign in with Hanzo" button hand-rolled an authorize URL
(IAM /login?redirect_uri=<studio root>), which the IAM default login app
(hanzo-console) rejected because the studio root is not in its allowed
redirect-URI list -- blocking all customer login.

Give the flow exactly one entry point: the button now links to the studio
server's own /login route, which runs the middleware's OIDC Authorization
Code flow (client_id=hanzo-studio, redirect_uri=<origin>/callback, PKCE +
signed state). /callback is already an allowed redirect URI, so login
completes and lands on /. Removes the client-side URL builder and the dead
{{IAM_URL}} templating.

Tests: /login is public; middleware exposes handle_login; unauth /login
builds the hanzo-studio authorize URL to /callback; post-login return path is /.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-03 21:38:48 -07:00
hanzo-dev 99c3d53d8b studio: seed 25-tab fashion workspace idempotently (guard v3)
Move the workspace-tab seeder out of a hand-edited index.html and into a
dedicated branding patcher wired into apply-branding.sh, so it survives a
frontend-package reinstall and re-applies on every branding pass.

Root cause the seeder addresses: the frontend restores open tabs from
localStorage via getStorageValue/setStorageValue, whose clientId-suffixed
sessionStorage layer is read BEFORE plain localStorage. A previously-saved
1-tab shadow (Comfy.OpenWorkflowsPaths:<clientId>) masked the seed, and the
old guard was burned at v2 so existing profiles never re-seeded.

Fix: bump guard v2->v3 (existing profiles re-seed once) and purge the stale
sessionStorage shadows on re-seed so the fresh localStorage seed wins even on
a same-tab reload. Clean-profile boot now opens all 25 fashion workflows
(alongside ComfyUI's own default tab).
2026-07-03 20:49:21 -07:00
hanzo-dev 4ab38a1f06 studio: crash-durable SQLite render queue + engine selector
SqlitePromptQueue (middleware/tasks_queue.py): drop-in for PromptQueue backed
by one SQLite file (stdlib sqlite3, WAL, zero external processes). Opt in with
STUDIO_QUEUE_DB; precedence STUDIO_QUEUE_DB > STUDIO_PERSIST_QUEUE > memory so
local default is unchanged. The put/get/task_done seam maps to a durable job
state machine: idempotent submit (INSERT OR IGNORE on prompt_id), exactly-once
claim (BEGIN IMMEDIATE), retry-on-error, and crash-recovery via a lease + reap
that re-runs an abandoned claim (idempotent — SaveImage suffixes increment).
Multiple Studio processes on the same DB compete safely.

Engine selector (middleware/engine_selector.py): GET /v1/engines lists execution
targets for the org (local + registered compute_config workers), PUT
/v1/engines/default sets a per-org default stored on ComputeProfile; route_prompt
honors it (local = unchanged). Leased cloud machines are a future engine class
(interface stubbed). Frontend picker is a TODO.

docs/federation.md rewritten to the durable-queue reality with one future
paragraph (remote Tasks backend + Go/unified-binary HIP-0106 migration + leased
machines). Tests: middleware_test/{tasks_queue,engine_selector}_test.py, incl.
crash-recovery across queue instances.
2026-07-03 15:43:42 -07:00
125 changed files with 15023 additions and 50058 deletions
+8 -2
View File
@@ -1,7 +1,11 @@
models/
output/
input/
custom_nodes/
custom_nodes/*
# ...but the pack manifest must reach the build context so the image can vendor
# the packs reproducibly (scripts/install_custom_nodes.sh clones them at build).
# Local clones stay excluded so the image builds them fresh from the manifest.
!custom_nodes/hanzo-packs.txt
user/
temp/
orgs/
@@ -19,7 +23,9 @@ venv/
.ruff_cache/
tests/
tests-unit/
scripts/
scripts/*
# ...except the pack installer, which the image build invokes.
!scripts/install_custom_nodes.sh
notebooks/
*.swp
.env
+1 -1
View File
@@ -12,7 +12,7 @@ permissions:
jobs:
inject:
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
steps:
- name: Ensure template exists and append to PR body
uses: actions/github-script@v7
+1 -1
View File
@@ -6,7 +6,7 @@ on:
jobs:
check-line-endings:
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
steps:
- name: Checkout code
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
use_prior_commit: 'true'
comment:
if: ${{ github.event.label.name == 'Run-CI-Test' }}
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
permissions:
pull-requests: write
steps:
+1 -1
View File
@@ -6,7 +6,7 @@ on:
jobs:
send-webhook:
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
env:
DESKTOP_REPO_DISPATCH_TOKEN: ${{ secrets.DESKTOP_REPO_DISPATCH_TOKEN }}
steps:
+2 -2
View File
@@ -5,7 +5,7 @@ on: [push, pull_request]
jobs:
ruff:
name: Run Ruff
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
steps:
- name: Checkout repository
@@ -27,7 +27,7 @@ jobs:
pylint:
name: Run Pylint
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
steps:
- name: Checkout repository
+1 -1
View File
@@ -8,7 +8,7 @@ permissions:
jobs:
stale:
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
steps:
- uses: actions/stale@v9
with:
+1 -1
View File
@@ -14,7 +14,7 @@ on:
jobs:
build:
name: Build Test
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
strategy:
fail-fast: false
matrix:
+2 -2
View File
@@ -8,7 +8,7 @@ on:
jobs:
test:
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
steps:
- name: Checkout Hanzo Studio
uses: actions/checkout@v4
@@ -29,7 +29,7 @@ jobs:
- name: Start Hanzo Studio server
run: |
python main.py --cpu 2>&1 | tee console_output.log &
wait-for-it --service 127.0.0.1:8188 -t 30
wait-for-it --service 127.0.0.1:8188 -t 120
working-directory: studio
- name: Check for unhandled exceptions in server log
run: |
+1 -1
View File
@@ -7,7 +7,7 @@ on:
jobs:
generate-models:
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
steps:
- name: Checkout repository
+1 -1
View File
@@ -12,7 +12,7 @@ on:
jobs:
update-ci-container:
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
# Skip pre-releases unless manually triggered
if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease
steps:
+1 -1
View File
@@ -10,7 +10,7 @@ on:
jobs:
update-version:
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
# Don't run on fork PRs
if: github.event.pull_request.head.repo.full_name == github.repository
permissions:
+7 -2
View File
@@ -5,8 +5,12 @@ __pycache__/
!/input/example.png
/models/
/temp/
/custom_nodes/
!custom_nodes/example_node.py.example
# Ignore vendored pack contents but keep the directory listable so the tracked
# files below can be re-included (a file under a fully-excluded dir cannot be).
/custom_nodes/*
!/custom_nodes/example_node.py.example
# Pinned pack manifest is tracked; the pack sources it clones are not (vendored at build).
!/custom_nodes/hanzo-packs.txt
extra_model_paths.yaml
/.vs
.vscode/
@@ -24,3 +28,4 @@ web_custom_versions/
openapi.yaml
filtered-openapi.yaml
uv.lock
/custom_nodes_quarantine/
+34
View File
@@ -25,9 +25,43 @@ RUN chmod +x /tmp/branding/apply-branding.sh && /tmp/branding/apply-branding.sh
# Copy application code
COPY . .
# Vendor the Hanzo Studio custom-node packs (segmentation, controlnet, ipadapter,
# upscaling, video, utils) at their pinned commits and install their deps with the
# torch/numpy/transformers stack locked. See custom_nodes/hanzo-packs.txt.
RUN chmod +x scripts/install_custom_nodes.sh && scripts/install_custom_nodes.sh
# Create directories for runtime volumes
RUN mkdir -p models output input custom_nodes user
# /app/orgs is the tenant-data PVC; /app/models is ephemeral overlay. Register
# .models on the PVC as the default model store (main.py auto-loads this file)
# so models survive pod restarts and downloads land durable.
RUN printf '%s\n' \
'hanzo:' \
' base_path: /app/orgs/.models' \
' is_default: true' \
' checkpoints: checkpoints' \
' configs: configs' \
' loras: loras' \
' vae: vae' \
' text_encoders: text_encoders' \
' diffusion_models: diffusion_models' \
' clip_vision: clip_vision' \
' style_models: style_models' \
' embeddings: embeddings' \
' diffusers: diffusers' \
' vae_approx: vae_approx' \
' controlnet: controlnet' \
' gligen: gligen' \
' upscale_models: upscale_models' \
' latent_upscale_models: latent_upscale_models' \
' hypernetworks: hypernetworks' \
' photomaker: photomaker' \
' classifiers: classifiers' \
' model_patches: model_patches' \
' audio_encoders: audio_encoders' \
> extra_model_paths.yaml
EXPOSE 8188
# Default: listen on all interfaces, CPU mode
+123 -4
View File
@@ -38,6 +38,41 @@ studio/
- `Dockerfile` -- Container build (applies `branding/` then runs `main.py`)
- `hanzo.yml` + `.github/workflows/cicd.yml` -- canonical CI/CD (hanzoai/ci); builds `ghcr.io/hanzoai/studio`, rolls the `studio` operator Service CR at `studio.hanzo.ai`.
## Custom-node packs ("hanzo packages")
The popular ComfyUI packs are vendored as first-class hanzo packages, pinned and
reproducible. One and only one way to add/bump them:
- **Manifest**: `custom_nodes/hanzo-packs.txt` — `<dir> <git_url> <commit_sha>
<spdx>` lines (immutable SHAs). This is the tracked source of truth; the cloned
pack sources are gitignored (vendored at build).
- **Deps**: `custom_nodes-requirements.txt` — hand-resolved, only what's not
already in `requirements.txt`. Installed with the studio torch(cu130)/NumPy 2.x/
Transformers 5.x stack **locked** so packs can never downgrade it.
- **Installer**: `scripts/install_custom_nodes.sh` clones each pack at its pin and
installs deps; the lock is derived from the live env (correct for both the CPU
image and the GB10 cu130 venv). `ultralytics` is installed `--no-deps` (else it
pulls non-headless opencv). Local (GB10): `STUDIO_PY=.venv/bin/python
STUDIO_PIP="uv pip install --python .venv/bin/python" scripts/install_custom_nodes.sh`.
- **Image**: `Dockerfile` runs the installer after `COPY . .`; `.dockerignore`
re-includes only the manifest + installer (`custom_nodes/*` / `scripts/*` form).
So `git push origin main` → CI builds `ghcr.io/hanzoai/studio` with the packs →
studio.hanzo.ai gets them on the next image build.
**Compat shim — why packs work here at all.** This fork renamed ComfyUI's core
packages (`comfy`→`studio`, `comfy_extras`→`studio_extras`, `comfy/comfy_types`→
`studio/node_types`, and the `Comfy*`→`Studio*` API classes; see
`scripts/rename_modules.py`). `studio_compat/` is one meta-path finder that lazily
aliases every `comfy*` import to its `studio*` counterpart and mirrors
`ComfyExtension`/`io.ComfyNode`→`Studio*` — so upstream packs run **unmodified**,
no forks/renames. It is the phase-4 redirect `rename_modules.py` deferred, done
with the 3.12 `find_spec` API. Activated by `import studio_compat` at the top of
`main.py`. `nodes.py` also honors the upstream `comfy_entrypoint` V3 name.
Restart the local worker only when idle: `scripts/studio-service-swap.sh` polls
`/queue` and restarts on the first empty window. Bad packs are isolated by the
loader (studio still boots); genuinely-incompatible ones go to
`custom_nodes_quarantine/` (e.g. was-node-suite: hard `numba` dep vs NumPy 2.5).
## Multi-tenant + IAM (cloud: studio.hanzo.ai)
Standalone app; `console.hanzo.ai` link-outs land here already authenticated via
shared Hanzo IAM (standard OIDC code flow). No embedding.
@@ -60,13 +95,97 @@ shared Hanzo IAM (standard OIDC code flow). No embedding.
the prompt worker from the queue item's `org_id`.
- **GPU federation** (`middleware/{worker_client,prompt_router,compute_config,
visor_client}.py`): `/v1/workers/register` (heartbeat), `/v1/workers` (list),
`/v1/worker/execute` (push). Worker↔coordinator trust via
`X-Worker-Token` (`STUDIO_WORKER_TOKEN`, KMS-sourced). NAT'd local boxes (GB10)
join outbound — pull channel is specced in `docs/federation.md`.
`/v1/worker/execute` (in-cluster push fast path). Worker↔coordinator trust via
`X-Worker-Token` (`STUDIO_WORKER_TOKEN`, KMS-sourced).
- **Durable render queue** (`middleware/tasks_queue.py`, `docs/federation.md`):
`SqlitePromptQueue` — crash-durable render queue in a single SQLite file
(stdlib `sqlite3`, WAL, **zero external processes**). `STUDIO_QUEUE_DB=<path>`
→ `server.py` swaps `PromptQueue` for it (precedence **STUDIO_QUEUE_DB >
STUDIO_PERSIST_QUEUE > memory**; default local behavior unchanged). The
`PromptQueue` seam maps to a durable job state machine: `put→INSERT (pending,
idempotent on prompt_id)`, `get→claim next pending under BEGIN IMMEDIATE
(exactly-once, leased)`, `task_done→done | retry/failed`. Crash-recovery: a
lease + reap returns an abandoned claim to `pending` (on every get(), a
heartbeat thread, and on boot) so another process re-runs it — idempotent
(SaveImage suffixes increment). Multiple Studio processes on the same DB file
compete safely. Tested: `middleware_test/tasks_queue_test.py` (incl.
crash-recovery across instances). Future (federation.md §6): a remote queue
backend served by Hanzo Tasks + a Go/unified-binary (HIP-0106) migration.
- **Engine selector** (`middleware/engine_selector.py`): `/v1/engines` lists
execution targets for the org (`local` + registered compute_config workers) and
`PUT /v1/engines/default` sets a per-org default (stored on
`ComputeProfile.default_engine`). `prompt_router.route_prompt` routes to the
chosen worker; `local` = unchanged. Leased cloud machines (Visor
`GET /v1/machines`) are a future engine class (interface stubbed, not built).
Frontend picker is a TODO. Tested: `middleware_test/engine_selector_test.py`.
- **Chat Copilot** (`middleware/copilot.py` + `branding/hanzo-copilot.{js,css}`):
a sidebar chat that reads and MUTATES the workflow graph in natural language.
Backend `POST /v1/copilot/chat` proxies one OpenAI tool-calling round to a
chat endpoint (`STUDIO_COPILOT_URL`; default = local engine
`127.0.0.1:1234` if its port is open, else `api.hanzo.ai`; `STUDIO_COPILOT_MODEL`
default `zen`; token from `STUDIO_COPILOT_TOKEN`→`STUDIO_COMMERCE_TOKEN`→request
bearer). It returns `{reply, ops}`; `add_node` ops are validated against
`nodes.NODE_CLASS_MAPPINGS` server-side so the model can't invent node types.
The **frontend** applies ops against `app.graph` (LiteGraph) — op schema:
`add_node`(id?/pos?) · `set_widget` · `connect`(slots by name or index) ·
`set_prompt` · `move_node` · `delete_node` · `layout` (columnar tidy) ·
`queue`. Every op is guarded (unknown type/slot = skip + note, never throw).
Injected into the prebuilt frontend by `branding/patch_copilot.py` (copies the
bundle into static/, CSS `<link>` in `<head>`, deferred `<script>` before
`</body>`; idempotent, wired as step 9 of `apply-branding.sh`) — survives
frontend upgrades. `window.HanzoCopilot.applyOps` is exposed for tests. Tab
registered via `app.extensionManager.registerSidebarTab` (id `hanzo-copilot`).
Tested: `middleware_test/copilot_test.py` (op-validation + mocked tool loop) and
`branding/copilot_smoke.py` (headless: applier mutates a mock graph + panel
renders). Live end-to-end needs one server restart to register the route.
- **Identity user menu + session API** (`middleware/session.py` +
`branding/hanzo-identity.{js,css}`): the top-right user menu, driven entirely
by the IAM-validated token. Backend (thin, `/v1` house style, registered in
`add_routes` so it works auth-on and auth-off): `GET /v1/session` →
`{user,email,org,orgs[],project,projects[],console_url,iam_url,authenticated}`
read from `request["iam_user"]`; `POST /v1/session/org` sets the active org
(validated against membership → `studio_active_org` cookie); `POST
/v1/session/project` sets the workflow-workspace project scope; `GET|POST
/logout` clears the session + selection cookies and bounces to the IAM
end-session → `/login`. **Org switching is real, one hook**: the auth
middleware applies `session.resolve_org(...)` to a COPY of the validated user
(never the cached dict) so `iam_user["org_id"]` — the id every tenancy/output
path already reads — reflects the switch; membership comes from
`_orgs_from_claims` (`owner` + `organizations`/`orgs`/`groups`, list or CSV or
`{name}` dicts, home first, deduped). Frontend is a SPA-agnostic fixed pill +
dropdown (avatar/initials, user, org switcher, project switcher, Console
link → `console.hanzo.ai`, Logout); 401 → "Sign in with Hanzo" → `/login`.
Injected by `branding/patch_identity.py` (step 10 of `apply-branding.sh`,
same seam as the copilot). `window.HanzoIdentity` is exposed for tests.
Tested: `middleware_test/session_test.py` (resolve/orgs/routes/logout, real
aiohttp app) + `scratchpad/studio-iam/test_identity.py` (headless: pill,
dropdown, org switch re-scope, signed-out). The `/v1/session` routes need one
server restart to register (static + de-Comfy load without restart).
- **De-Comfy display strings** (`branding/patch_destring.py`, step 11 —
runs LAST): the systematic English brand-word sweep the curated phrase list in
`patch_frontend.py` structurally can't reach. Rule 1: whole-word `ComfyUI` →
`Hanzo Studio` only where it reads as a display word (preceded by
open-quote/space/`>`/`(`, followed by close-quote/space/sentence-punct) — that
boundary skips every code use in the bundle (`ComfyUIStarted`, `.ComfyUI`,
`=ComfyUI`, the `/^(ComfyUI-.../` regex, dotted i18n keys `.category.ComfyUI`)
and hyphenated product names (`ComfyUI-Manager`). Rule 2: curated bare-`Comfy`
**values** as exact `key:` value`` pairs so the Settings dialog **keys**
(`Comfy`, `Comfy-Desktop`, `Comfy.ColorPalette`) are preserved while the
displayed section header becomes **Studio** / **Studio Desktop** (this is the
"Settings must not say Comfy" fix) + `Comfy Cloud` → `Hanzo Cloud`. Scope:
English only — files above `LOCALE_RATIO` (0.15%) non-ASCII are skipped (all 33
locale bundles are ≥0.31%; English bundles ~0.02%) and each match needs a
pure-ASCII window, so the non-English `main-*/nodeDefs-*/commands-*` chunks are
left as-is (noted, out of scope). Idempotent. Verified: served English display
`ComfyUI` 63→0; a few example filesystem paths (`Documents/ComfyUI`) and the
external comfy.org service labels (`Comfy API Key`, `Comfy Node Registry`,
behind disabled API-nodes) are intentionally left. patch_frontend.py keeps its
orthogonal concern (URLs/domains + `Comfy-Org` → `Hanzo AI`).
## Tests
`tests-unit/{folder_paths_test,middleware_test,app_test,prompt_server_test}` —
run `uv run pytest tests-unit/folder_paths_test tests-unit/middleware_test
tests-unit/app_test tests-unit/prompt_server_test -q`. The auth/tenancy work is
covered by `middleware_test/iam_auth_test.py` and
covered by `middleware_test/iam_auth_test.py`,
`middleware_test/session_test.py` and
`folder_paths_test/org_scoping_test.py`.
+7 -4
View File
@@ -22,10 +22,13 @@ Modifications made in this fork
branding/. Backend Python packages are namespaced under studio_* and the
server reports itself as "Hanzo Studio".
- Hanzo Engine integration nodes (custom_nodes/hanzo_engine/): custom nodes
that call a local Hanzo Engine OpenAI-compatible server — HanzoChat
(chat/completions), HanzoImageGen (images/generations), and
HanzoVisionCaption (vision chat/completions). Hanzo Engine is the native-Rust,
- Hanzo Engine integration nodes (custom_nodes/hanzo_engine/): first-party
nodes that run AI workloads on a local Hanzo Engine OpenAI-compatible server
over a shared client — HanzoChat (chat/completions, streaming), HanzoImageGen
(images/generations), HanzoTTS (audio/speech), HanzoASR
(audio/transcriptions), HanzoEngineRequest (generic /v1 escape hatch),
HanzoVisionCaption (vision chat/completions), HanzoTextToVideo (videos),
HanzoImageTo3D (3d), and HanzoSaveText. Hanzo Engine is the native-Rust,
all-modality inference and training backend.
- Fashion/swimwear starter workflows (user/default/workflows/fashion/):
+41 -1
View File
@@ -71,7 +71,47 @@ python3 "$BRANDING_DIR/patch_sidebar_logo.py" "$STATIC_DIR"
# --- 7. Apply the black + Hanzo-purple theme ---
# Installs hanzo-theme.css, injects it as the last stylesheet in index.html, and
# rewrites the "dark" palette's canvas clear color to pure black.
echo "[7/7] Applying black + purple theme..."
echo "[7/8] Applying black + purple theme..."
python3 "$BRANDING_DIR/patch_theme.py" "$STATIC_DIR" "$BRANDING_DIR"
# --- 8. Seed the default 25-tab fashion workspace ---
# Injects a <head> bootstrap that pre-populates the localStorage keys the
# frontend reads to restore open workflow tabs. Idempotent; bump SEED_VERSION
# in patch_workspace_seed.py to force existing profiles to re-seed once.
echo "[8/9] Seeding default workspace tabs..."
python3 "$BRANDING_DIR/patch_workspace_seed.py" "$STATIC_DIR"
# --- 9. Inject the Copilot sidebar chat (frontend bundle + on-brand CSS) ---
# Copies hanzo-copilot.{js,css} into static/ and injects them into index.html
# (CSS link in <head>, deferred <script> before </body>). Pairs with the backend
# route POST /v1/copilot/chat. Idempotent; bump BUNDLE_VERSION in patch_copilot.py.
echo "[9/11] Injecting Copilot..."
python3 "$BRANDING_DIR/patch_copilot.py" "$STATIC_DIR" "$BRANDING_DIR"
# --- 10. Inject the Identity user menu (frontend bundle + on-brand CSS) ---
# Top-right user pill driven by GET /v1/session: login/logout, org switcher,
# project scope, Console link. Idempotent; bump BUNDLE_VERSION in patch_identity.py.
echo "[10/11] Injecting Identity menu..."
python3 "$BRANDING_DIR/patch_identity.py" "$STATIC_DIR" "$BRANDING_DIR"
# --- 11. De-Comfy remaining English display strings ---
# Sweeps the served English bundle for user-visible 'ComfyUI'/'Comfy' -> Studio
# that the curated phrase list in patch_frontend.py cannot reach (mid-string),
# plus the Settings dialog section headers. Runs LAST so it mops up everything.
echo "[11/12] De-Comfy display strings..."
python3 "$BRANDING_DIR/patch_destring.py" "$STATIC_DIR"
# --- 12. Force "Nodes 2.0" (Comfy.VueNodes.Enabled) on, remove the toggles ---
# studio.hanzo.ai ships the modern DOM node renderer as the only node design:
# default the setting on, hide it from the Settings dialog (type:hidden), and
# drop the logo-menu switch. Idempotent; see patch_vuenodes.py.
echo "[12/13] Forcing Nodes 2.0 on (no toggle)..."
python3 "$BRANDING_DIR/patch_vuenodes.py" "$STATIC_DIR"
# --- 13. Inject the deep-link opener (open + optionally run a workflow by URL) ---
# ?org=<org>&workflow=<path>&run=1 sets the session org, loads the saved workflow,
# and queues it (dispatching to the org's BYO-GPU). Idempotent; see patch_deeplink.py.
echo "[13/13] Injecting deep-link opener..."
python3 "$BRANDING_DIR/patch_deeplink.py" "$STATIC_DIR" "$BRANDING_DIR"
echo "=== Hanzo Studio branding complete ==="
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Headless smoke test for the Copilot frontend bundle (hanzo-copilot.js).
Loads the REAL bundle into a page against a stub window.app / window.LiteGraph
(a minimal LiteGraph subset) and asserts:
1. window.HanzoCopilot.applyOps mutates the mock graph: add / connect /
set_widget / set_prompt / move / delete / layout / queue.
2. The sidebar tab registers and its render(el) builds the chat panel DOM.
This is the frontend half of the copilot tests. Live end-to-end (the panel
talking to POST /v1/copilot/chat) needs the one server restart that registers
the backend route; the bundle itself is exercised here without a server.
Run: python3 branding/copilot_smoke.py (needs: pip install playwright)
"""
import os
import sys
from playwright.sync_api import sync_playwright
BUNDLE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hanzo-copilot.js")
# Stub LiteGraph + app: the smallest surface applyOps touches.
STUB = """
window.__connections = [];
window.__queued = null;
window.LiteGraph = {
_id: 1000,
createNode(type) {
return {
type, id: ++window.LiteGraph._id, pos: [0, 0], size: [200, 100],
inputs: [{ name: 'image' }, { name: 'model' }],
outputs: [{ name: 'IMAGE' }],
widgets: [{ name: 'text', value: '' }, { name: 'seed', value: 0 }],
connect(outIdx, target, inIdx) {
window.__connections.push({ from: this.id, outIdx, to: target.id, inIdx });
return true;
},
};
},
};
(function () {
var nodes = [];
window.__nodes = nodes;
window.app = {
extensionManager: { registerSidebarTab(t) { window.__tab = t; } },
graph: {
_nodes: nodes, links: {},
add(n) { nodes.push(n); },
remove(n) { var i = nodes.indexOf(n); if (i >= 0) nodes.splice(i, 1); },
getNodeById(id) { return nodes.find(function (n) { return n.id == id; }) || null; },
setDirtyCanvas() {},
},
queuePrompt(n) { window.__queued = n; },
async graphToPrompt() { return { output: {} }; },
};
})();
"""
OPS = [
{"op": "add_node", "type": "CLIPTextEncode", "id": "pos", "pos": [10, 20]},
{"op": "add_node", "type": "SaveImage", "id": "save"},
{"op": "set_prompt", "node_id": "pos", "text": "a red one-piece at golden hour"},
{"op": "set_widget", "node_id": "save", "name": "seed", "value": 7},
{"op": "connect", "from_node": "pos", "from_slot": "IMAGE", "to_node": "save", "to_slot": "image"},
{"op": "move_node", "node_id": "save", "pos": [300, 40]},
{"op": "layout"},
{"op": "queue"},
{"op": "delete_node", "node_id": "pos"},
{"op": "bogus", "node_id": "pos"},
]
def main() -> int:
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.set_content("<!doctype html><html><head></head><body></body></html>")
page.evaluate(STUB)
page.add_script_tag(path=BUNDLE)
assert page.evaluate("typeof window.HanzoCopilot") == "object", "HanzoCopilot not exposed"
# Apply the op batch against the mock graph.
res = page.evaluate("(ops) => window.HanzoCopilot.applyOps(ops)", OPS)
print("applyOps ->", res)
nodes = page.evaluate("window.__nodes.map(function(n){return {id:n.id,type:n.type,pos:n.pos};})")
conns = page.evaluate("window.__connections")
queued = page.evaluate("window.__queued")
save_seed = page.evaluate(
"(function(){var n=window.__nodes.find(function(x){return x.type=='SaveImage';});"
"return n.widgets.find(function(w){return w.name=='seed';}).value;})()"
)
# 2 added, then 1 deleted -> only SaveImage remains.
types = sorted(n["type"] for n in nodes)
assert types == ["SaveImage"], f"expected only SaveImage after delete, got {types}"
assert len(conns) == 1 and conns[0]["outIdx"] == 0 and conns[0]["inIdx"] == 0, f"bad connect: {conns}"
assert save_seed == 7, f"set_widget failed: seed={save_seed}"
assert queued == 0, f"queue op did not fire: {queued}"
# bogus op is skipped, not thrown, and noted.
assert any("bogus" in note for note in res["notes"]), f"bogus op not noted: {res['notes']}"
# 8 good ops applied (add,add,set_prompt,set_widget,connect,move,layout,queue,delete = 9);
# bogus skipped.
assert res["applied"] == 9, f"expected 9 applied, got {res['applied']}"
# Sidebar tab registered; render builds the panel.
page.wait_for_function("() => window.__tab !== undefined", timeout=5000)
tab = page.evaluate("({id: window.__tab.id, title: window.__tab.title, icon: window.__tab.icon})")
assert tab["id"] == "hanzo-copilot", f"bad tab id: {tab}"
panel = page.evaluate(
"(function(){var d=document.createElement('div');document.body.appendChild(d);"
"window.__tab.render(d);return {panel:d.classList.contains('hanzo-copilot'),"
"chips:d.querySelectorAll('.hz-chip').length,input:!!d.querySelector('textarea')};})()"
)
assert panel["panel"] and panel["input"] and panel["chips"] >= 3, f"panel not built: {panel}"
browser.close()
print("tab ->", tab)
print("panel ->", panel)
print("PASS: copilot frontend smoke")
return 0
if __name__ == "__main__":
sys.exit(main())
+137
View File
@@ -0,0 +1,137 @@
/* Hanzo Studio Copilot — black + Hanzo-purple sidebar chat.
* Reuses the theme's CSS vars (hanzo-theme.css) so it tracks the shell palette;
* hard-coded fallbacks keep it on-brand if a var is ever absent.
*/
.hanzo-copilot {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
box-sizing: border-box;
background: var(--comfy-menu-bg, #0a0a0a);
color: var(--fg-color, #fff);
font-size: 13px;
}
.hanzo-copilot .hz-head {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
font-weight: 600;
letter-spacing: 0.02em;
border-bottom: 1px solid var(--border-color, #1f1f1f);
}
.hanzo-copilot .hz-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--p-primary-color, #8b5cf6);
box-shadow: 0 0 8px var(--p-primary-color, #8b5cf6);
}
.hanzo-copilot .hz-messages {
flex: 1 1 auto;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.hanzo-copilot .hz-msg { display: flex; }
.hanzo-copilot .hz-user { justify-content: flex-end; }
.hanzo-copilot .hz-assistant { justify-content: flex-start; }
.hanzo-copilot .hz-bubble {
max-width: 84%;
padding: 8px 11px;
border-radius: 12px;
line-height: 1.45;
word-wrap: break-word;
white-space: normal;
}
.hanzo-copilot .hz-user .hz-bubble {
background: var(--p-button-primary-background, #7c3aed);
color: #fff;
border-bottom-right-radius: 4px;
}
.hanzo-copilot .hz-assistant .hz-bubble {
background: #1a1a1f;
border: 1px solid var(--border-color, #1f1f1f);
border-bottom-left-radius: 4px;
}
.hanzo-copilot .hz-typing { opacity: 0.6; letter-spacing: 3px; }
.hanzo-copilot .hz-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 12px 4px;
}
.hanzo-copilot .hz-chip {
background: transparent;
color: #c4b5fd;
border: 1px solid #2a2140;
border-radius: 999px;
padding: 5px 10px;
font-size: 11.5px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.hanzo-copilot .hz-chip:hover {
background: rgba(139, 92, 246, 0.16);
border-color: var(--p-primary-color, #8b5cf6);
}
.hanzo-copilot .hz-input {
display: flex;
gap: 8px;
padding: 10px 12px 12px;
border-top: 1px solid var(--border-color, #1f1f1f);
align-items: flex-end;
}
.hanzo-copilot .hz-input textarea {
flex: 1 1 auto;
resize: none;
background: var(--comfy-input-bg, #0a0a0a);
color: var(--fg-color, #fff);
border: 1px solid var(--border-color, #1f1f1f);
border-radius: 8px;
padding: 8px 10px;
font: inherit;
outline: none;
}
.hanzo-copilot .hz-input textarea:focus {
border-color: var(--p-primary-color, #8b5cf6);
}
.hanzo-copilot .hz-send {
background: var(--p-button-primary-background, #7c3aed);
color: #fff;
border: none;
border-radius: 8px;
padding: 9px 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.hanzo-copilot .hz-send:hover { background: #6d28d9; }
.hanzo-copilot .hz-send:disabled { opacity: 0.5; cursor: default; }
/* Fallback toast (used only when the app toast service is unavailable) */
.hanzo-copilot-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translate(-50%, 12px);
background: #17171c;
color: #fff;
border: 1px solid #2a2140;
border-left: 3px solid var(--p-primary-color, #8b5cf6);
border-radius: 8px;
padding: 10px 16px;
font-size: 13px;
z-index: 100000;
opacity: 0;
transition: opacity 0.25s, transform 0.25s;
pointer-events: none;
}
.hanzo-copilot-toast.show { opacity: 1; transform: translate(-50%, 0); }
+359
View File
@@ -0,0 +1,359 @@
/* Hanzo Studio Copilot — a sidebar chat that reads and MUTATES the workflow graph.
*
* You talk to the canvas in natural language ("add a 4x upscale after the save
* image"). The backend (POST /v1/copilot/chat) decides graph operations; THIS
* file applies them against app.graph (LiteGraph). Every op is guarded — an
* unknown node type or bad slot is skipped and noted, never thrown.
*
* Injected as the last <script> by branding/patch_copilot.py so window.app exists
* (or is about to — we poll). window.HanzoCopilot.applyOps is exposed immediately
* for programmatic use and headless tests.
*/
(function () {
"use strict";
var VERSION = "1";
// ---------------------------------------------------------------- helpers
function app() { return window.app; }
function graph() { return window.app && window.app.graph; }
function LG() { return window.LiteGraph; }
function waitFor(pred, timeoutMs) {
return new Promise(function (resolve, reject) {
var t0 = Date.now();
(function tick() {
var v = pred();
if (v) return resolve(v);
if (Date.now() - t0 > timeoutMs) return reject(new Error("timeout"));
setTimeout(tick, 120);
})();
});
}
function resolveSlot(list, slot) {
// slot may be an index (number) or a slot name (string). Returns an index.
if (typeof slot === "number") return slot;
if (slot == null) return 0;
var name = String(slot).toLowerCase();
for (var i = 0; i < (list || []).length; i++) {
var s = list[i];
var n = (s && (s.name || s.label)) || s;
if (String(n).toLowerCase() === name) return i;
}
return 0;
}
// ---------------------------------------------------------------- applier
// Applies one batch of ops. Returns {applied, notes[]}. New nodes are referenced
// in later ops by the id label the model gave them in add_node.
function applyOps(ops) {
var g = graph();
var notes = [];
var applied = 0;
if (!g) { return { applied: 0, notes: ["no graph"] }; }
var local = {}; // model-label / id -> node
function resolve(id) {
if (id == null) return null;
if (local[id]) return local[id];
var n = g.getNodeById(typeof id === "string" && /^\d+$/.test(id) ? Number(id) : id);
if (!n && typeof id === "number") n = g.getNodeById(id);
return n || null;
}
function setWidget(node, name, value) {
if (!node.widgets) return false;
var lname = String(name).toLowerCase();
for (var i = 0; i < node.widgets.length; i++) {
var w = node.widgets[i];
if (w.name && w.name.toLowerCase() === lname) {
w.value = value;
if (typeof w.callback === "function") { try { w.callback(value); } catch (e) {} }
return true;
}
}
return false;
}
(ops || []).forEach(function (op, idx) {
try {
switch (op.op) {
case "add_node": {
var Lg = LG();
var node = Lg && Lg.createNode ? Lg.createNode(op.type) : null;
if (!node) { notes.push("skip add_node: unknown type " + op.type); break; }
g.add(node);
if (Array.isArray(op.pos)) node.pos = [op.pos[0], op.pos[1]];
if (op.id != null) local[op.id] = node;
local[node.id] = node;
applied++;
break;
}
case "set_widget": {
var n1 = resolve(op.node_id);
if (!n1) { notes.push("skip set_widget: no node " + op.node_id); break; }
if (!setWidget(n1, op.name, op.value)) { notes.push("skip set_widget: no widget " + op.name + " on " + op.node_id); break; }
applied++;
break;
}
case "set_prompt": {
var n2 = resolve(op.node_id);
if (!n2 || !n2.widgets) { notes.push("skip set_prompt: no node " + op.node_id); break; }
var target = null;
for (var j = 0; j < n2.widgets.length; j++) {
if (n2.widgets[j].name && n2.widgets[j].name.toLowerCase() === "text") { target = n2.widgets[j]; break; }
}
if (!target) target = n2.widgets.find(function (w) { return typeof w.value === "string"; });
if (!target) { notes.push("skip set_prompt: no text widget on " + op.node_id); break; }
target.value = op.text;
if (typeof target.callback === "function") { try { target.callback(op.text); } catch (e) {} }
applied++;
break;
}
case "connect": {
var from = resolve(op.from_node), to = resolve(op.to_node);
if (!from || !to) { notes.push("skip connect: missing node"); break; }
var oi = resolveSlot(from.outputs, op.from_slot);
var ii = resolveSlot(to.inputs, op.to_slot);
from.connect(oi, to, ii);
applied++;
break;
}
case "move_node": {
var n3 = resolve(op.node_id);
if (!n3 || !Array.isArray(op.pos)) { notes.push("skip move_node: " + op.node_id); break; }
n3.pos = [op.pos[0], op.pos[1]];
applied++;
break;
}
case "delete_node": {
var n4 = resolve(op.node_id);
if (!n4) { notes.push("skip delete_node: no node " + op.node_id); break; }
g.remove(n4);
applied++;
break;
}
case "layout": {
layout();
applied++;
break;
}
case "queue": {
if (app() && typeof app().queuePrompt === "function") { app().queuePrompt(0); applied++; }
else notes.push("skip queue: unavailable");
break;
}
default:
notes.push("skip unknown op " + op.op);
}
} catch (e) {
notes.push("error on op " + idx + " (" + op.op + "): " + e.message);
}
});
if (app() && app().graph && app().graph.setDirtyCanvas) app().graph.setDirtyCanvas(true, true);
return { applied: applied, notes: notes };
}
// Simple left-to-right columnar tidy: column = longest link-path from a source.
function layout() {
var g = graph();
if (!g || !g._nodes) return;
var nodes = g._nodes;
var col = {};
nodes.forEach(function (n) { col[n.id] = 0; });
for (var pass = 0; pass < nodes.length; pass++) {
var changed = false;
nodes.forEach(function (n) {
(n.inputs || []).forEach(function (inp) {
if (inp && inp.link != null && g.links && g.links[inp.link]) {
var src = g.links[inp.link].origin_id;
if (col[src] != null && col[n.id] < col[src] + 1) { col[n.id] = col[src] + 1; changed = true; }
}
});
});
if (!changed) break;
}
var COLW = 340, ROWH = 240, rowOf = {};
nodes.slice().sort(function (a, b) { return (col[a.id] - col[b.id]) || (a.id - b.id); }).forEach(function (n) {
var c = col[n.id] || 0;
rowOf[c] = (rowOf[c] || 0);
n.pos = [80 + c * COLW, 80 + rowOf[c] * ROWH];
rowOf[c]++;
});
if (g.setDirtyCanvas) g.setDirtyCanvas(true, true);
}
window.HanzoCopilot = { applyOps: applyOps, layout: layout, VERSION: VERSION };
// ---------------------------------------------------------------- catalog
var _objectInfo = null;
function getObjectInfo() {
if (_objectInfo) return Promise.resolve(_objectInfo);
return fetch("./object_info").then(function (r) { return r.json(); }).then(function (j) {
_objectInfo = j; return j;
});
}
function inputTypeStr(spec) {
if (Array.isArray(spec)) {
var t = spec[0];
if (typeof t === "string") return t;
if (Array.isArray(t)) return "COMBO";
return "ANY";
}
return "ANY";
}
function buildCatalog(info, graphJson) {
var types = Object.keys(info);
var present = {};
Object.keys(graphJson || {}).forEach(function (id) {
var ct = graphJson[id] && graphJson[id].class_type;
if (ct) present[ct] = true;
});
var detail = {};
Object.keys(present).forEach(function (t) {
var d = info[t]; if (!d) return;
var ins = {};
["required", "optional"].forEach(function (grp) {
var block = d.input && d.input[grp];
if (block) Object.keys(block).forEach(function (name) { ins[name] = inputTypeStr(block[name]); });
});
detail[t] = { inputs: ins, outputs: d.output_name || d.output || [] };
});
return { types: types, nodes: detail };
}
// ---------------------------------------------------------------- toast
function toast(msg) {
try {
var em = app() && app().extensionManager;
if (em && em.toast && typeof em.toast.add === "function") {
em.toast.add({ severity: "success", summary: "Copilot", detail: msg, life: 4000 });
return;
}
} catch (e) {}
var el = document.createElement("div");
el.className = "hanzo-copilot-toast";
el.textContent = msg;
document.body.appendChild(el);
setTimeout(function () { el.classList.add("show"); }, 10);
setTimeout(function () { el.classList.remove("show"); setTimeout(function () { el.remove(); }, 300); }, 3800);
}
// ---------------------------------------------------------------- chat state + UI
var state = { messages: [], busy: false };
var CHIPS = [
"Add a 4x upscale after the save image",
"Connect the latent output to a new VAE decode",
"Set the positive prompt to a red one-piece on a beach at golden hour",
"Tidy up the layout",
];
function esc(s) { return String(s).replace(/[&<>]/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]; }); }
function renderMessages(box) {
box.innerHTML = "";
state.messages.forEach(function (m) {
var row = document.createElement("div");
row.className = "hz-msg hz-" + m.role;
row.innerHTML = '<div class="hz-bubble">' + esc(m.content).replace(/\n/g, "<br>") + "</div>";
box.appendChild(row);
});
if (state.busy) {
var t = document.createElement("div");
t.className = "hz-msg hz-assistant";
t.innerHTML = '<div class="hz-bubble hz-typing">…</div>';
box.appendChild(t);
}
box.scrollTop = box.scrollHeight;
}
async function send(text, box) {
if (!text || state.busy) return;
state.messages.push({ role: "user", content: text });
state.busy = true;
renderMessages(box);
try {
var gp = await app().graphToPrompt();
var graphJson = (gp && gp.output) || {};
var info = await getObjectInfo().catch(function () { return {}; });
var catalog = buildCatalog(info, graphJson);
var resp = await fetch("./v1/copilot/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: state.messages, graph_json: graphJson, catalog: catalog }),
});
var data = await resp.json().catch(function () { return {}; });
if (!resp.ok) throw new Error(data.error || ("HTTP " + resp.status));
var reply = data.reply || "(no reply)";
state.messages.push({ role: "assistant", content: reply });
state.busy = false;
renderMessages(box);
var ops = data.ops || [];
if (ops.length) {
var res = applyOps(ops);
var summary = res.applied + " change" + (res.applied === 1 ? "" : "s") + " applied";
if (res.notes.length) summary += " · " + res.notes.length + " skipped";
toast(summary);
if (res.notes.length) {
state.messages.push({ role: "assistant", content: res.notes.join("\n") });
renderMessages(box);
}
}
} catch (e) {
state.busy = false;
state.messages.push({ role: "assistant", content: "⚠ " + e.message });
renderMessages(box);
}
}
function buildUI(el) {
el.classList.add("hanzo-copilot");
el.innerHTML =
'<div class="hz-head"><span class="hz-dot"></span> Copilot</div>' +
'<div class="hz-messages"></div>' +
'<div class="hz-chips"></div>' +
'<div class="hz-input"><textarea rows="2" placeholder="Tell the canvas what to do…"></textarea>' +
'<button class="hz-send" title="Send">Send</button></div>';
var box = el.querySelector(".hz-messages");
var ta = el.querySelector("textarea");
var chips = el.querySelector(".hz-chips");
CHIPS.forEach(function (c) {
var b = document.createElement("button");
b.className = "hz-chip"; b.textContent = c;
b.onclick = function () { ta.value = c; ta.focus(); };
chips.appendChild(b);
});
function fire() { var v = ta.value.trim(); ta.value = ""; send(v, box); }
el.querySelector(".hz-send").onclick = fire;
ta.addEventListener("keydown", function (e) {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); fire(); }
});
if (!state.messages.length) {
state.messages.push({ role: "assistant", content: "Hi — I can edit this workflow for you. Try a chip below or just tell me what to change." });
}
renderMessages(box);
}
// ---------------------------------------------------------------- boot
function register() {
var a = app();
if (a && a.extensionManager && typeof a.extensionManager.registerSidebarTab === "function") {
a.extensionManager.registerSidebarTab({
id: "hanzo-copilot",
icon: "pi pi-comments",
title: "Copilot",
tooltip: "Chat Copilot — edit the workflow in natural language",
type: "custom",
render: function (el) { buildUI(el); },
destroy: function () {},
});
console.log("[Hanzo Copilot] sidebar tab registered v" + VERSION);
return true;
}
return false;
}
waitFor(function () { return app() && app().extensionManager; }, 30000)
.then(register)
.catch(function () { console.warn("[Hanzo Copilot] app.extensionManager not found; copilot tab not mounted"); });
})();
+103
View File
@@ -0,0 +1,103 @@
/* Hanzo Studio deep-link — open a saved workflow (and optionally run it) straight
* from a URL, so a link in the gallery / console / chat lands you on the graph
* ready to render.
*
* Params (query string or hash, e.g. ?org=karma&workflow=fashion/shoot_valentina.json&run=1):
* org active org to scope userdata + render output to (sets the studio
* session org cookie BEFORE loading, so a re-run dispatches to the
* org's BYO-GPU and lands the output in that org's gallery).
* workflow path under the user's workflows/ dir (e.g. "fashion/shoot_x.json").
* run "1" to auto-queue the graph once loaded (re-run in one click).
*
* Injected as the last <script> by branding/patch_deeplink.py so window.app
* exists. Guarded end-to-end: a bad param logs and no-ops, never throws.
*/
(function () {
"use strict";
var VERSION = "1";
function app() { return window.app; }
function params() {
var out = {};
var q = new URLSearchParams(window.location.search);
q.forEach(function (v, k) { out[k] = v; });
if (window.location.hash && window.location.hash.length > 1) {
var h = new URLSearchParams(window.location.hash.slice(1));
h.forEach(function (v, k) { if (out[k] == null) out[k] = v; });
}
return out;
}
function waitFor(cond, ms) {
return new Promise(function (resolve, reject) {
var t0 = Date.now();
(function poll() {
var v; try { v = cond(); } catch (e) { v = null; }
if (v) return resolve(v);
if (Date.now() - t0 > ms) return reject(new Error("timeout"));
setTimeout(poll, 150);
})();
});
}
function toast(msg, ok) {
var el = document.createElement("div");
el.textContent = msg;
el.style.cssText =
"position:fixed;top:14px;left:50%;transform:translateX(-50%);z-index:99999;" +
"padding:8px 14px;border-radius:8px;font:13px system-ui;color:#fff;" +
"background:" + (ok ? "#1f7a4d" : "#8a1f1f") + ";box-shadow:0 4px 16px rgba(0,0,0,.35)";
document.body.appendChild(el);
setTimeout(function () { el.style.transition = "opacity .4s"; el.style.opacity = "0"; }, 3200);
setTimeout(function () { el.remove(); }, 3700);
}
async function setOrg(org) {
// Set the studio active org so userdata + render output scope to it. Same-origin
// fetch carries cookies; the response Set-Cookie updates studio_active_org.
var r = await fetch("/v1/session/org", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ org: org }),
});
if (!r.ok) throw new Error("org switch failed (" + r.status + ")");
}
async function loadWorkflow(wf) {
var key = "workflows/" + String(wf).replace(/^\/+/, "");
var r = await fetch("/userdata/" + encodeURIComponent(key), { credentials: "same-origin" });
if (!r.ok) throw new Error("workflow not found: " + wf + " (" + r.status + ")");
var data = await r.json();
await app().loadGraphData(data);
return key;
}
async function run() {
var p = params();
if (!p.workflow && !p.org) return; // nothing to do — normal studio load
await waitFor(function () { return app() && typeof app().loadGraphData === "function"; }, 30000);
try {
if (p.org) { await setOrg(p.org); }
if (p.workflow) {
await loadWorkflow(p.workflow);
toast("Opened " + p.workflow + (p.org ? " · " + p.org : ""), true);
if (String(p.run) === "1" && typeof app().queuePrompt === "function") {
// Give the graph a tick to settle before queuing.
setTimeout(function () {
try { app().queuePrompt(0); toast("Queued render → your GPU", true); }
catch (e) { toast("Auto-run failed: " + e.message, false); }
}, 400);
}
} else if (p.org) {
toast("Switched to " + p.org, true);
}
} catch (e) {
console.warn("[Hanzo deep-link]", e);
toast(e.message, false);
}
}
run();
console.log("[Hanzo deep-link] v" + VERSION + " ready");
})();
+111
View File
@@ -0,0 +1,111 @@
/* Hanzo Studio Identity menu — top-right user pill + dropdown.
* Matches console.hanzo.ai's design system: monochrome true-black surfaces,
* hairline borders, 9px radii, neutral (non-chromatic) accents. No brand-purple
* — console is deliberately monochrome, so the pill reads as the same product
* family as console's OrgSwitcher / user menu.
* --color1 #050505 (surface) · --backgroundHover #171717 · --borderColor
* #23262b · --borderColorHover #363b40 · --color12 #eef0f3 (text) ·
* --color11 #cdd2da · --color9 #848a93 (muted) · red10 #e5484d (destructive) */
.hanzo-identity {
position: fixed;
top: 8px;
right: 12px;
z-index: 3000;
font-family: 'Basel', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 13px;
color: #eef0f3;
}
.hzid-pill {
display: inline-flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 10px 0 4px;
background: #0a0a0a;
border: 1px solid #23262b;
border-radius: 9px;
color: #eef0f3;
cursor: pointer;
transition: border-color .12s ease, background .12s ease;
}
.hzid-pill:hover { border-color: #363b40; background: #171717; }
.hzid-avatar,
.hzid-avatar-lg {
display: inline-flex;
align-items: center;
justify-content: center;
background: #26292b;
color: #eef0f3;
font-weight: 500;
border-radius: 50%;
}
.hzid-avatar { width: 24px; height: 24px; font-size: 10px; }
.hzid-avatar-lg { width: 36px; height: 36px; font-size: 14px; flex: 0 0 auto; }
.hzid-pillname { font-weight: 500; max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hzid-org {
font-size: 11px;
color: #a4abb4;
background: #171717;
border: 1px solid #23262b;
padding: 1px 7px;
border-radius: 5px;
}
.hzid-caret { color: #848a93; font-size: 10px; }
.hzid-signin { text-decoration: none; padding: 0 14px; color: #eef0f3; }
.hzid-signin:hover { color: #fff; }
/* dropdown panel */
.hzid-panel {
position: absolute;
top: 40px;
right: 0;
min-width: 236px;
background: #050505;
border: 1px solid #23262b;
border-radius: 9px;
box-shadow: 0 12px 40px rgba(0, 0, 0, .55);
padding: 8px;
animation: hzid-in .12s ease;
}
@keyframes hzid-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
.hzid-id { display: flex; align-items: center; gap: 10px; padding: 6px 8px 10px; }
.hzid-idtext { min-width: 0; }
.hzid-name { font-weight: 500; color: #eef0f3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hzid-email { font-size: 11px; color: #848a93; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hzid-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: .06em;
color: #6b727e;
padding: 8px 8px 3px;
}
.hzid-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
box-sizing: border-box;
padding: 7px 8px;
background: none;
border: 0;
border-radius: 7px;
color: #cdd2da;
font: inherit;
text-align: left;
text-decoration: none;
cursor: pointer;
}
.hzid-item:hover { background: #171717; color: #eef0f3; }
.hzid-active { color: #eef0f3; }
.hzid-tick { width: 14px; flex: 0 0 auto; color: #eef0f3; font-size: 12px; }
.hzid-sep { height: 1px; background: #23262b; margin: 8px 4px; }
.hzid-logout:hover { background: rgba(229, 72, 77, .12); color: #e5484d; }
.hzid-logout .hzid-tick { color: #e5484d; }
+192
View File
@@ -0,0 +1,192 @@
/* Hanzo Studio Identity menu — the top-right user menu, driven entirely by the
* IAM-validated session the backend exposes at GET /v1/session.
*
* It reads {user,email,org,orgs[],project,projects[],console_url,iam_url} and
* renders a pill (avatar/initials + name). Opening it shows the signed-in user,
* an org switcher (POST /v1/session/org -> reload, re-scoped to that org for
* tenancy + output paths), a project switcher (POST /v1/session/project), a
* link back to Hanzo Console, and Logout (GET /logout -> clears the session
* cookie -> IAM end-session -> /login).
*
* Self-contained and SPA-agnostic: a fixed element in the menu-bar corner, so it
* never fights the Vue app. Injected as a deferred <script> by patch_identity.py.
* window.HanzoIdentity is exposed for headless tests.
*/
(function () {
"use strict";
if (window.__hanzoIdentityLoaded) return;
window.__hanzoIdentityLoaded = true;
var VERSION = "3";
// ------------------------------------------------------------- dom helper
function el(tag, attrs, kids) {
var n = document.createElement(tag);
if (attrs) Object.keys(attrs).forEach(function (k) {
if (k === "class") n.className = attrs[k];
else if (k === "text") n.textContent = attrs[k];
else if (k === "html") n.innerHTML = attrs[k];
else if (k.slice(0, 2) === "on") n[k.toLowerCase()] = attrs[k];
else n.setAttribute(k, attrs[k]);
});
(kids || []).forEach(function (c) { if (c) n.appendChild(c); });
return n;
}
function initials(name, email) {
var src = (name || email || "?").trim();
var parts = src.split(/[\s@._-]+/).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return src.slice(0, 2).toUpperCase();
}
// ------------------------------------------------------------- api
function getSession() {
return fetch("./v1/session", { headers: { Accept: "application/json" }, credentials: "same-origin" })
.then(function (r) {
// 401 is the real "not signed in" (auth on, no valid token) -> Sign in.
// A 200 body is always a usable session (a real IAM user in the cloud, or
// the local single-user stub when auth is off) -> render the pill.
if (r.status === 401) return { signedOut: true };
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
});
}
function post(path, body) {
return fetch("./" + path, {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then(function (r) { return r.json().catch(function () { return {}; }); });
}
var state = { session: null, open: false };
function switchOrg(org) {
if (!org || (state.session && org === state.session.org)) return;
post("v1/session/org", { org: org }).then(function () { location.reload(); });
}
function switchProject(p) {
if (!p || (state.session && p === state.session.project)) return;
post("v1/session/project", { project: p }).then(function () { location.reload(); });
}
// ------------------------------------------------------------- render
function close() { state.open = false; paint(); }
function menuPanel(s) {
var rows = [];
rows.push(el("div", { class: "hzid-id" }, [
el("div", { class: "hzid-avatar-lg", text: initials(s.user, s.email) }),
el("div", { class: "hzid-idtext" }, [
el("div", { class: "hzid-name", text: s.user || "User" }),
el("div", { class: "hzid-email", text: s.email || "" }),
]),
]));
// Organization switcher
rows.push(el("div", { class: "hzid-label", text: "Organization" }));
(s.orgs || [s.org]).forEach(function (o) {
rows.push(el("button", {
class: "hzid-item" + (o === s.org ? " hzid-active" : ""),
onclick: function () { switchOrg(o); },
}, [
el("span", { class: "hzid-tick", text: o === s.org ? "✓" : "" }),
el("span", { text: o }),
]));
});
// Project (workflow workspace) switcher
if (s.projects && s.projects.length) {
rows.push(el("div", { class: "hzid-label", text: "Project" }));
s.projects.forEach(function (p) {
rows.push(el("button", {
class: "hzid-item" + (p === s.project ? " hzid-active" : ""),
onclick: function () { switchProject(p); },
}, [
el("span", { class: "hzid-tick", text: p === s.project ? "✓" : "" }),
el("span", { text: p }),
]));
});
}
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" }),
]));
rows.push(el("a", { class: "hzid-item hzid-logout", href: "/logout" }, [
el("span", { class: "hzid-tick", text: "⏻" }), el("span", { text: "Logout" }),
]));
return el("div", { class: "hzid-panel" }, rows);
}
function paint() {
var root = document.getElementById("hanzo-identity");
if (!root) return;
root.innerHTML = "";
var s = state.session;
if (!s) return;
if (s.signedOut) {
root.appendChild(el("a", { class: "hzid-pill hzid-signin", href: "/login" }, [
el("span", { text: "Sign in with Hanzo" }),
]));
return;
}
var pill = el("button", { class: "hzid-pill", title: (s.email || s.user) + " · " + s.org, onclick: function (e) { e.stopPropagation(); state.open = !state.open; paint(); } }, [
el("span", { class: "hzid-avatar", text: initials(s.user, s.email) }),
el("span", { class: "hzid-pillname", text: s.user || "User" }),
el("span", { class: "hzid-org", text: s.org || "" }),
el("span", { class: "hzid-caret", text: "▾" }),
]);
root.appendChild(pill);
if (state.open) root.appendChild(menuPanel(s));
}
// dismiss on outside click
document.addEventListener("click", function (e) {
if (state.open && !e.target.closest("#hanzo-identity")) close();
});
// ------------------------------------------------------------- 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) {
state.session = s; paint();
console.log("[Hanzo Identity] mounted v" + VERSION + (s && s.user ? " as " + s.user : ""));
}).catch(function (err) {
// /v1/session not registered yet (backend pre-restart) — stay silent.
console.warn("[Hanzo Identity] session unavailable:", err && err.message);
});
}
// testability: let a test set the session and repaint without the network.
window.HanzoIdentity = {
VERSION: VERSION,
mount: mount,
reload: function () { return getSession().then(function (s) { state.session = s; paint(); return s; }); },
_set: function (s) { state.session = s; paint(); },
};
if (document.body) mount();
else document.addEventListener("DOMContentLoaded", mount);
})();
+78
View File
@@ -62,3 +62,81 @@ body.litegraph,
#vue-app {
background-color: #000000 !important;
}
/* ── hover/active states: dark surfaces, never light (fix white-on-white) ── */
:root, :root.dark-theme {
--p-content-hover-background: #1f1f23 !important;
--p-content-hover-color: #ffffff !important;
--p-navigation-item-hover-background: #1f1f23 !important;
--p-list-option-focus-background: #26262b !important;
--p-select-option-focus-background: #26262b !important;
--p-button-text-secondary-hover-background: #1f1f23 !important;
--p-button-text-primary-hover-background: rgba(139,92,246,.16) !important;
--p-button-secondary-hover-background: #26262b !important;
--p-togglebutton-hover-background: #26262b !important;
--p-tab-hover-background: #1f1f23 !important;
--p-menubar-item-focus-background: #26262b !important;
--p-menu-item-focus-background: #26262b !important;
--p-tree-node-hover-background: #1f1f23 !important;
--p-surface-100: #26262b !important;
--p-surface-200: #1f1f23 !important;
--p-highlight-background: rgba(139,92,246,.20) !important;
--p-highlight-color: #ffffff !important;
}
.side-tool-bar-container .p-button:hover,
.side-bar-button:hover,
.comfyui-button:hover {
background: #1f1f23 !important;
color: #fff !important;
}
.p-button-text:not(.p-button-danger):hover { background: #1f1f23 !important; color: #fff !important; }
/* ── the H logo menu button: keep the mark visible on hover/open ── */
button:has(> .comfyui-logo):hover,
button:has(.comfyui-logo):hover,
.p-button:has(.comfyui-logo):hover,
button:has(.comfyui-logo)[aria-expanded="true"],
.comfyui-logo-menu-trigger:hover {
background: #1f1f23 !important;
}
button:has(.comfyui-logo):hover .comfyui-logo,
button:has(.comfyui-logo):hover svg,
button:has(.comfyui-logo):hover path {
color: #ffffff !important;
fill: #ffffff !important;
}
/* ── active/selected/checked states: never white (white-box-under-H fix) ── */
:root, :root.dark-theme {
--p-button-secondary-active-background: #26262b !important;
--p-togglebutton-checked-background: #2a2140 !important;
--p-togglebutton-checked-color: #c4b5fd !important;
--p-navigation-item-active-background: #26262b !important;
--p-tab-active-background: #0a0a0a !important;
--p-button-text-primary-active-background: rgba(139,92,246,.24) !important;
}
.side-tool-bar-container .p-button.p-highlight,
.side-tool-bar-container .p-togglebutton.p-togglebutton-checked,
.side-tool-bar-container [aria-pressed="true"],
.side-tool-bar-container .p-button:active,
.p-selectbutton .p-button.p-highlight {
background: #2a2140 !important;
color: #c4b5fd !important;
}
.side-tool-bar-container .p-button.p-highlight svg,
.side-tool-bar-container [aria-pressed="true"] svg { color: #c4b5fd !important; fill: currentColor !important; }
/* ── selected sidebar icon: purple-tinted dark, never white ── */
:root, :root.dark-theme {
--interface-panel-selected-surface: #2a2140 !important;
--interface-menu-component-surface-selected: #2a2140 !important;
}
.side-bar-button-selected {
background-color: #2a2140 !important;
color: #c4b5fd !important;
}
.side-bar-button-selected svg, .side-bar-button-selected i { color: #c4b5fd !important; }
.selected:not(.p-galleria-thumbnail-item) {
background-color: #2a2140 !important;
color: #e5e5e5 !important;
}
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Inject the Hanzo Studio Copilot into the prebuilt frontend.
Two orthogonal levers, each in its own lane (mirrors patch_theme.py):
1. Assets -> copy hanzo-copilot.js + hanzo-copilot.css into static/.
2. index.html seam ->
- <link> for the CSS as the last stylesheet in <head>
- <script defer> for the bundle as the last element before </body>, so
window.app / window.LiteGraph exist (the bundle also polls, so order is
forgiving) and the copilot registers its sidebar tab.
Idempotent: strips any prior copilot link/script (matched by filename) and
re-injects with a ?v=BUNDLE_VERSION cache-buster. Bump BUNDLE_VERSION when the
JS/CSS changes so browsers pick it up on the next load.
Usage: patch_copilot.py <static_dir> <branding_dir>
"""
import os
import re
import shutil
import sys
STATIC_DIR = sys.argv[1]
BRANDING_DIR = sys.argv[2]
BUNDLE_VERSION = "1"
JS = "hanzo-copilot.js"
CSS = "hanzo-copilot.css"
LINK_TAG = f'<link rel="stylesheet" href="{CSS}?v={BUNDLE_VERSION}"/>'
SCRIPT_TAG = f'<script src="{JS}?v={BUNDLE_VERSION}" defer></script>'
def install_assets() -> None:
for fname in (JS, CSS):
shutil.copyfile(os.path.join(BRANDING_DIR, fname), os.path.join(STATIC_DIR, fname))
print(f" Installed {JS} + {CSS}")
def inject() -> None:
index = os.path.join(STATIC_DIR, "index.html")
if not os.path.isfile(index):
print(" WARNING: index.html not found — copilot not injected")
return
with open(index, encoding="utf-8") as f:
html = f.read()
# Strip any prior copilot tags (any ?v=) so re-runs stay clean.
html = re.sub(r'<link[^>]*href="%s[^"]*"[^>]*>' % re.escape(CSS), "", html)
html = re.sub(r'<script[^>]*src="%s[^"]*"[^>]*></script>' % re.escape(JS), "", html)
if "</head>" in html:
html = html.replace("</head>", LINK_TAG + "</head>", 1)
if "</body>" in html:
html = html.replace("</body>", SCRIPT_TAG + "</body>", 1)
else: # no </body> (minified edge) — append; the script still runs
html += SCRIPT_TAG
with open(index, "w", encoding="utf-8") as f:
f.write(html)
print(f" Injected copilot link + script (v{BUNDLE_VERSION}) into index.html")
def main() -> None:
install_assets()
inject()
if __name__ == "__main__":
main()
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Inject the Hanzo Studio deep-link opener into the prebuilt frontend.
Mirrors patch_copilot.py (same seam, same idempotency):
1. Assets -> copy hanzo-deeplink.js into static/.
2. index.html -> <script defer> as the last element before </body>, so
window.app exists when it reads the URL and opens/runs the
workflow.
Idempotent: strips any prior deeplink script (matched by filename) and re-injects
with a ?v=BUNDLE_VERSION cache-buster. Bump BUNDLE_VERSION when the JS changes.
Usage: patch_deeplink.py <static_dir> <branding_dir>
"""
import os
import re
import shutil
import sys
STATIC_DIR = sys.argv[1]
BRANDING_DIR = sys.argv[2]
BUNDLE_VERSION = "1"
JS = "hanzo-deeplink.js"
SCRIPT_TAG = f'<script src="{JS}?v={BUNDLE_VERSION}" defer></script>'
def install_assets() -> None:
shutil.copyfile(os.path.join(BRANDING_DIR, JS), os.path.join(STATIC_DIR, JS))
print(f" Installed {JS}")
def inject() -> None:
index = os.path.join(STATIC_DIR, "index.html")
if not os.path.isfile(index):
print(" WARNING: index.html not found — deep-link not injected")
return
with open(index, encoding="utf-8") as f:
html = f.read()
# Strip any prior deeplink tag (any ?v=) so re-runs stay clean.
html = re.sub(r'<script[^>]*src="%s[^"]*"[^>]*></script>' % re.escape(JS), "", html)
if "</body>" in html:
html = html.replace("</body>", SCRIPT_TAG + "</body>", 1)
else: # no </body> (minified edge) — append; the script still runs
html += SCRIPT_TAG
with open(index, "w", encoding="utf-8") as f:
f.write(html)
print(f" Injected deep-link script (v{BUNDLE_VERSION}) into index.html")
if __name__ == "__main__":
install_assets()
inject()
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""De-Comfy the served English frontend: user-visible 'ComfyUI'/'Comfy' brand
words -> 'Hanzo Studio'/'Studio', without touching code identifiers, i18n keys,
setting keys, or the non-English locale bundles.
One concern only: the PRODUCT brand word in English UI copy. (URLs/domains and
the org name 'Comfy-Org'->'Hanzo AI' stay in patch_frontend.py; logos/theme
elsewhere.) Runs after patch_frontend.py in apply-branding.sh and mops up every
mid-string 'ComfyUI' the curated phrase list there structurally cannot reach
(e.g. `Update ComfyUI`, `Installing ComfyUI`), plus the Settings dialog section
headers.
Two rules:
1. Whole-word 'ComfyUI' -> 'Hanzo Studio', but only where it reads as a word in
a display string: preceded by open-quote / space / '>' / '(' and followed by
close-quote / space / sentence punctuation. That boundary skips every code
use found in the bundle — ComfyUIStarted, .ComfyUI, =ComfyUI, the
/^(ComfyUI-.../ regex, and dotted i18n keys like .category.ComfyUI (the '.'
is not an opening delimiter) — and hyphenated product names (ComfyUI-Manager,
the '-' is not a closing delimiter) so the actual third-party tool keeps its
name.
2. Curated bare-'Comfy' *values*, replaced as exact key:`value` pairs so the
setting KEYS ('Comfy', 'Comfy-Desktop', 'Comfy.ColorPalette', which the app
reads programmatically) are preserved while the displayed section header
becomes 'Studio' / 'Studio Desktop'. Plus 'Comfy Cloud' -> 'Hanzo Cloud'.
Scope guard: English only. A file is processed iff its non-ASCII byte ratio is
below LOCALE_RATIO (English bundles ~0.02%, every locale bundle >=0.31%), and
each individual match is additionally required to sit in a pure-ASCII window so a
stray CJK/RTL run is never rewritten. Idempotent: a second run finds no
display-context 'ComfyUI' and the exact value pairs are already rewritten.
Usage: patch_destring.py <static_dir>
"""
import os
import re
import sys
STATIC_DIR = sys.argv[1]
# A file is treated as a non-English locale bundle (skipped) above this ratio.
# English bundles measure ~0.0002; the lowest locale bundle ~0.0031.
LOCALE_RATIO = 0.0015
ASCII_WINDOW = 24 # chars of context each side that must be ASCII to rewrite
WORD_RE = re.compile(r"(?<=[\s`\"'>(])ComfyUI(?=[\s`\"'.,!?:;<)])")
WORD_REPL = "Hanzo Studio"
# Exact key:`value` pairs — key preserved, display value rebranded.
CURATED = [
("Comfy:`Comfy`", "Comfy:`Studio`"), # settingsCategories header
('"Comfy-Desktop":`Comfy-Desktop`', '"Comfy-Desktop":`Studio Desktop`'),
("comfy:`Comfy`", "comfy:`Studio`"),
("Comfy Cloud", "Hanzo Cloud"),
]
EXTS = (".js", ".html", ".json")
def _non_ascii_ratio(raw: bytes) -> float:
if not raw:
return 1.0
return sum(1 for c in raw if c > 127) / len(raw)
def _replace_word(text: str) -> tuple[str, int]:
"""Apply WORD_RE, but only where the surrounding window is pure ASCII."""
out, last, n = [], 0, 0
for mobj in WORD_RE.finditer(text):
s, e = mobj.start(), mobj.end()
ctx = text[max(0, s - ASCII_WINDOW):e + ASCII_WINDOW]
out.append(text[last:s])
if all(ord(c) < 128 for c in ctx):
out.append(WORD_REPL)
n += 1
else:
out.append(mobj.group(0))
last = e
out.append(text[last:])
return "".join(out), n
def patch_file(path: str) -> int:
raw = open(path, "rb").read()
if _non_ascii_ratio(raw) > LOCALE_RATIO:
return 0 # non-English locale bundle — out of scope
text = raw.decode("utf-8", errors="ignore")
original = text
text, changed = _replace_word(text)
for old, new in CURATED:
if old in text:
changed += text.count(old)
text = text.replace(old, new)
# Collapse any accidental doubling from overlapping prior passes.
text = text.replace("Hanzo Studio Studio", "Hanzo Studio").replace("Hanzo Cloud Cloud", "Hanzo Cloud")
if text != original:
with open(path, "w", encoding="utf-8") as f:
f.write(text)
return changed
def _display_count(static_dir: str) -> int:
total = 0
for root, _dirs, files in os.walk(static_dir):
for fn in files:
if not fn.endswith(EXTS):
continue
p = os.path.join(root, fn)
raw = open(p, "rb").read()
if _non_ascii_ratio(raw) > LOCALE_RATIO:
continue
total += len(WORD_RE.findall(raw.decode("utf-8", errors="ignore")))
return total
def main() -> None:
before = _display_count(STATIC_DIR)
files = changed = 0
for root, _dirs, names in os.walk(STATIC_DIR):
for fn in names:
if not fn.endswith(EXTS):
continue
n = patch_file(os.path.join(root, fn))
if n:
files += 1
changed += n
after = _display_count(STATIC_DIR)
print(f" De-Comfy: {changed} display strings across {files} English files "
f"(display 'ComfyUI' {before} -> {after})")
if __name__ == "__main__":
main()
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Inject the Hanzo Studio Identity menu into the prebuilt frontend.
Mirrors patch_copilot.py exactly (same two orthogonal levers):
1. Assets -> copy hanzo-identity.js + hanzo-identity.css into static/.
2. index.html seam ->
- <link> for the CSS as the last stylesheet in <head>
- <script defer> for the bundle as the last element before </body>.
Idempotent: strips any prior identity link/script (matched by filename) and
re-injects with a ?v=BUNDLE_VERSION cache-buster. Bump BUNDLE_VERSION when the
JS/CSS changes so browsers pick it up on the next load.
Usage: patch_identity.py <static_dir> <branding_dir>
"""
import os
import re
import shutil
import sys
STATIC_DIR = sys.argv[1]
BRANDING_DIR = sys.argv[2]
BUNDLE_VERSION = "4"
JS = "hanzo-identity.js"
CSS = "hanzo-identity.css"
LINK_TAG = f'<link rel="stylesheet" href="{CSS}?v={BUNDLE_VERSION}"/>'
SCRIPT_TAG = f'<script src="{JS}?v={BUNDLE_VERSION}" defer></script>'
def install_assets() -> None:
for fname in (JS, CSS):
shutil.copyfile(os.path.join(BRANDING_DIR, fname), os.path.join(STATIC_DIR, fname))
print(f" Installed {JS} + {CSS}")
def inject() -> None:
index = os.path.join(STATIC_DIR, "index.html")
if not os.path.isfile(index):
print(" WARNING: index.html not found — identity menu not injected")
return
with open(index, encoding="utf-8") as f:
html = f.read()
html = re.sub(r'<link[^>]*href="%s[^"]*"[^>]*>' % re.escape(CSS), "", html)
html = re.sub(r'<script[^>]*src="%s[^"]*"[^>]*></script>' % re.escape(JS), "", html)
if "</head>" in html:
html = html.replace("</head>", LINK_TAG + "</head>", 1)
if "</body>" in html:
html = html.replace("</body>", SCRIPT_TAG + "</body>", 1)
else:
html += SCRIPT_TAG
with open(index, "w", encoding="utf-8") as f:
f.write(html)
print(f" Injected identity link + script (v{BUNDLE_VERSION}) into index.html")
def main() -> None:
install_assets()
inject()
if __name__ == "__main__":
main()
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Force ComfyUI "Nodes 2.0" (Vue/DOM node rendering, the `Comfy.VueNodes.Enabled`
setting) ON for the cloud build and remove it as a user-facing choice.
Upstream ships Nodes 2.0 as an *experimental, default-off* toggle exposed in two
places: a row in the Settings dialog (category Comfy > "Nodes 2.0") and a switch
in the top-left logo menu (analytics id `menu_nodes_2.0_toggle`). studio.hanzo.ai
ships the modern node design as the one and only node renderer, so this patch:
1. Flips the registered default of `Comfy.VueNodes.Enabled` !1 -> !0 (on).
2. Sets its `type` boolean -> hidden, so the Settings dialog stops rendering the
row (ComfyUI's own mechanism — same as `Comfy.NodeLibrary.Bookmarks.V2`).
Same hidden treatment for the sibling `Comfy.VueNodes.AutoScaleLayout` so the
entire "Nodes 2.0" settings category collapses (no orphan row). Its default
is already !0 upstream.
3. Flips every inline read fallback `...Enabled`)??!1` -> `??!0`, so a fresh
profile (no persisted value) resolves to on at every call site.
4. Deletes the `nodes-2.0-toggle` item from the logo-menu model so the menu
switch is gone too.
Operates on the served frontend bundle (comfyui-frontend-package/static), anchored
on the setting id / menu-item literal (not on minified hashes), so it survives a
frontend bump as long as the setting keeps its id. Idempotent: after a run the
`boolean`/`!1`/menu-item strings for these settings no longer exist, so a second
run is a no-op. Prints what it changed.
Usage: patch_vuenodes.py <static_dir>
"""
import os
import re
import sys
STATIC_DIR = sys.argv[1]
# Registration objects to force-on + hide. Anchored on the setting id and bounded
# to that one object by the lazy run up to its `versionAdded`.
FORCE_ON = "Comfy.VueNodes.Enabled" # experimental default-off -> on
HIDE_ONLY = "Comfy.VueNodes.AutoScaleLayout" # already default-on -> just hide row
# Logo-menu item literal (top-left menu switch). Removing it drops the switch.
MENU_ITEM = "{key:`nodes-2.0-toggle`,label:`Nodes 2.0`},"
def _reg_pattern(setting_id: str) -> re.Pattern:
# id:`<setting_id>` ... versionAdded:`x.y.z` (one registration object)
return re.compile(
r"id:`" + re.escape(setting_id) + r"`.{0,500}?versionAdded:`[^`]*`",
re.S,
)
def patch_text(text: str) -> tuple[str, int]:
n = 0
def force_on(m: re.Match) -> str:
seg = m.group(0)
seg = seg.replace("type:`boolean`", "type:`hidden`")
seg = seg.replace("defaultValue:!1", "defaultValue:!0")
return seg
def hide_only(m: re.Match) -> str:
return m.group(0).replace("type:`boolean`", "type:`hidden`")
text, c = _reg_pattern(FORCE_ON).subn(force_on, text)
n += c
text, c = _reg_pattern(HIDE_ONLY).subn(hide_only, text)
n += c
# Inline read fallbacks: unset -> on.
needle = FORCE_ON + "`)??!1"
if needle in text:
n += text.count(needle)
text = text.replace(needle, FORCE_ON + "`)??!0")
# Remove the logo-menu switch item.
if MENU_ITEM in text:
n += text.count(MENU_ITEM)
text = text.replace(MENU_ITEM, "")
return text, n
def main() -> None:
files = changed = 0
for root, _dirs, names in os.walk(os.path.join(STATIC_DIR, "assets")):
for fn in names:
if not fn.endswith(".js"):
continue
path = os.path.join(root, fn)
with open(path, encoding="utf-8", errors="ignore") as f:
original = f.read()
text, n = patch_text(original)
if n and text != original:
with open(path, "w", encoding="utf-8") as f:
f.write(text)
files += 1
changed += n
print(f" Nodes 2.0 forced on + toggles removed: {changed} edits across {files} files")
if __name__ == "__main__":
main()
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""
Seed the default multi-tab fashion workspace for Hanzo Studio.
Injects a tiny <head> bootstrap script into index.html that pre-populates the
localStorage keys the frontend reads on boot to restore open workflow tabs:
Comfy.OpenWorkflowsPaths JSON array of workflow paths (one tab each)
Comfy.ActiveWorkflowIndex restore split index (0 = open all as trailing tabs)
Why localStorage: the frontend's restore path (restoreWorkflowTabsState in
GraphView useWorkflowPersistence) reads these through getStorageValue()/
setStorageValue(), which are localStorage helpers with a clientId-suffixed
sessionStorage layer read FIRST. On re-seed we therefore also purge any stale
`<key>:<clientId>` sessionStorage shadows so the fresh localStorage seed is
authoritative even on a same-tab reload (otherwise a previously-saved 1-tab
shadow would mask the seed).
ComfyUI always opens its own blank "Unsaved Workflow" as the active tab on boot
(initializeWorkflow -> loadDefaultWorkflow); the 25 seeded fashion workflows are
restored alongside it in the background, so the workspace opens with 26 tabs.
There is no seed key that suppresses the default tab: loadPersistedWorkflow only
rehydrates drafts / the last-edited graph blob, never a userdata file by path.
The seed runs at most once per browser profile, guarded by hanzoWorkspaceSeed.
Bump SEED_VERSION to force every existing profile to re-seed exactly once.
Idempotent: re-running strips the previous seed <script> and re-injects the
current one, so this is safe to call on every branding pass.
"""
import re
import sys
STATIC_DIR = sys.argv[1]
INDEX = f"{STATIC_DIR}/index.html"
SEED_VERSION = "v3"
# Curated 8-design x 3-pose matrix + the shared 4K upscaler = 25 tabs.
DESIGNS = [
"festival_fuchsia", "neptune_green", "floralmesh", "stripedgem",
"redonepiece", "valentina", "red", "ches",
]
POSES = ["product", "hover", "shoot"]
PATHS = [f"workflows/fashion/{pose}_{d}.json" for d in DESIGNS for pose in POSES]
PATHS.append("workflows/fashion/upscale_4k.json")
# Marker id lets us find & replace our own block idempotently.
MARKER = "hanzo-workspace-seed"
paths_json = "[" + ", ".join('"%s"' % p for p in PATHS) + "]"
SEED_SCRIPT = (
f'<script id="{MARKER}">try{{'
f"if(localStorage.getItem('hanzoWorkspaceSeed')!=='{SEED_VERSION}'){{"
f"localStorage.setItem('Comfy.OpenWorkflowsPaths',JSON.stringify({paths_json}));"
f"localStorage.setItem('Comfy.ActiveWorkflowIndex','0');"
f"for(var i=sessionStorage.length-1;i>=0;i--){{var k=sessionStorage.key(i);"
f"if(k&&(k.indexOf('Comfy.OpenWorkflowsPaths:')===0"
f"||k.indexOf('Comfy.ActiveWorkflowIndex:')===0))sessionStorage.removeItem(k);}}"
f"localStorage.setItem('hanzoWorkspaceSeed','{SEED_VERSION}');"
f"}}}}catch(e){{}}</script>"
)
def main() -> None:
with open(INDEX, "r", encoding="utf-8") as f:
html = f.read()
# Remove any prior seed block (marked or the original unmarked variant).
html = re.sub(r'<script id="%s">.*?</script>' % re.escape(MARKER), "", html,
flags=re.DOTALL)
html = re.sub(r"<script>try\{if\(localStorage\.getItem\('hanzoWorkspaceSeed'\).*?</script>",
"", html, flags=re.DOTALL)
if MARKER not in html:
html = html.replace("<head>", "<head>" + SEED_SCRIPT, 1)
with open(INDEX, "w", encoding="utf-8") as f:
f.write(html)
print(f" Seeded workspace ({len(PATHS)} tabs, guard {SEED_VERSION}) into index.html")
if __name__ == "__main__":
main()
+51
View File
@@ -0,0 +1,51 @@
# Hand-resolved Python deps for the vendored custom-node packs (see
# custom_nodes/hanzo-packs.txt). Install via scripts/install_custom_nodes.sh, which
# locks the studio cu130 torch stack / NumPy 2.x / Transformers 5.x (derived from
# the live env) so none of these can downgrade them. Only deps NOT already in
# requirements.txt are listed; the studio-critical packages are locked, not here.
# `ultralytics` is intentionally absent -- the installer adds it with --no-deps so
# it cannot pull the non-headless opencv-python that fights opencv-*-headless.
# --- Impact-Pack / segment_anything: detailer + SAM masks ---
segment-anything
timm
scikit-image
piexif
dill
matplotlib
# --- Impact-Subpack (ultralytics detectors): light deps only; ultralytics itself
# is installed --no-deps by the installer, then these fill in its runtime needs ---
py-cpuinfo
ultralytics-thop
pandas
# --- comfyui_controlnet_aux: pose/depth/edge preprocessors ---
opencv-contrib-python-headless
onnxruntime
addict
yapf
omegaconf
ftfy
yacs
fvcore
trimesh
albumentations
mediapipe
python-dateutil
importlib_metadata
scikit-learn
# --- ComfyUI-KJNodes ---
color-matcher
mss
# --- VideoHelperSuite / Frame-Interpolation: video I/O ---
imageio
imageio-ffmpeg
# --- misc utils used across packs ---
gitpython
joblib
pilgram
fairscale
+99
View File
@@ -0,0 +1,99 @@
# Hanzo Studio custom-node packs -- pinned, reproducible, first-class "hanzo packages".
#
# Format: <dir_name> <git_url> <commit_sha> <spdx_license> # friendly-ref
#
# Installed by scripts/install_custom_nodes.sh (invoked in the Docker image build
# and runnable locally) so the studio.hanzo.ai image ships the same packs the
# local GB10 worker runs. Upstream packs import comfy.* / subclass io.ComfyNode /
# expose comfy_entrypoint -- studio_compat aliases those to the studio.* names at
# boot and the loader honors comfy_entrypoint, so packs run UNMODIFIED (no fork,
# no source edits). Python deps: custom_nodes-requirements.txt (+ -constraints.txt).
#
# Pinned to immutable commit SHAs. To bump a pack: replace its SHA + re-run the
# installer (patch-bump; never blind-track HEAD). All licenses are OSI (GPL/AGPL/
# MIT/Apache) -- none are non-commercial.
ComfyUI-Impact-Pack https://github.com/ltdrdata/ComfyUI-Impact-Pack.git 429d0159ad429e64d2b3916e6e7be9c22d025c3c GPL-3.0 # 8.28
ComfyUI-Impact-Subpack https://github.com/ltdrdata/ComfyUI-Impact-Subpack.git 50c7b71a6a224734cc9b21963c6d1926816a97f1 AGPL-3.0 # 1.3.4 Ultralytics detectors
ComfyUI_IPAdapter_plus https://github.com/cubiq/ComfyUI_IPAdapter_plus.git a0f451a5113cf9becb0847b92884cb10cbdec0ef GPL-3.0 # HEAD 2025-04-14
comfyui_controlnet_aux https://github.com/Fannovel16/comfyui_controlnet_aux.git e8b689a513c3e6b63edc44066560ca5919c0576e Apache-2.0 # HEAD 2026-04-13
ComfyUI_UltimateSDUpscale https://github.com/ssitu/ComfyUI_UltimateSDUpscale.git a5547db9e1d07d3318bb21e9e9c474f4c1e9c8df GPL-3.0 # HEAD 2026-06-22
ComfyUI-Frame-Interpolation https://github.com/Fannovel16/ComfyUI-Frame-Interpolation.git 26545cc2dd95bc3d27f056016300673bdeee78f5 MIT # HEAD 2026-03-29
ComfyUI-VideoHelperSuite https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite.git 4ee72c065db22c9d96c2427954dc69e7b908444b GPL-3.0 # HEAD 2026-05-14
rgthree-comfy https://github.com/rgthree/rgthree-comfy.git 27b4f4cdcf3b127c29d5d8135ac1536ecbd4c383 MIT # HEAD 2026-06-20
ComfyUI-Custom-Scripts https://github.com/pythongosssss/ComfyUI-Custom-Scripts.git 609f3afaa74b2f88ef9ce8d939626065e3247469 MIT # HEAD 2026-02-12
ComfyUI-KJNodes https://github.com/kijai/ComfyUI-KJNodes.git e27a505b3ba6ce42687fe00500deda103d9d6071 GPL-3.0 # HEAD 2026-07-01
cg-use-everywhere https://github.com/chrisgoringe/cg-use-everywhere.git 632ed7bb51bb18ceb03ccaefe1f34be8bd416500 Apache-2.0 # HEAD 2026-06-22
ComfyUI-segment-anything-2 https://github.com/kijai/ComfyUI-segment-anything-2.git 0c35fff5f382803e2310103357b5e985f5437f32 Apache-2.0 # HEAD 2025-09-28 SAM2
comfyui_segment_anything https://github.com/storyicon/comfyui_segment_anything.git ab6395596399d5048639cdab7e44ec9fae857a93 Apache-2.0 # HEAD GroundingDINO+SAM
ComfyUI-Inpaint-CropAndStitch https://github.com/lquesada/ComfyUI-Inpaint-CropAndStitch.git 3617559bcb9d15ff60b24c6800701402eb2cd478 GPL-3.0 # 2026-05-25 seam-invisible masked inpaint (couture_best dep)
# --- BAKEOFF-ONLY (cloned for the garment-swap bake-off; NOT auto-installed) ----
# ComfyUI-CatvtonFluxWrapper https://github.com/lujiazho/ComfyUI-CatvtonFluxWrapper.git 06558c42c539397af8e4d221ddccea870de72d1f (no LICENSE file)
# CatVTON-Flux diffusers wrapper. Hardcodes the GATED black-forest-labs/FLUX.1-Fill-dev
# diffusers repo + CC-BY-NC CatVTON LoRA, so it won't load without HF auth. The winning
# workflow (couture_best.json) reproduces CatVTON's in-context transfer with CORE nodes
# + the catvton_flux_lora_alpha weights via LoraLoaderModelOnly, so this pack is NOT a
# runtime dependency. Kept as a record of what was evaluated.
# --- QUARANTINED (not installed; kept here as a record) -------------------------
# was-node-suite-comfyui https://github.com/WASasquatch/was-node-suite-comfyui.git ea935d1044ae5a26efa54ebeb18fe9020af49a45 MIT
# Reason: hard `from numba import jit` at import; numba has no release supporting
# the studio venv's NumPy 2.5 ("Numba needs NumPy 2.4 or less"). Re-enabling would
# require downgrading NumPy (forbidden -- breaks the cu130 torch stack) or waiting
# for a numba release that supports NumPy 2.5. Revisit then. License (MIT) is fine.
# --- 3D generation + texturing (image->3D mesh / gaussian-splat, PBR texture bake) ---
# Packs install NODE/MODEL CODE ONLY -- all permissive (MIT/Apache). Model WEIGHTS are
# NOT shipped here: each downloads its own weights at runtime from the model's source.
# Where those weights are non-open, the friendly-ref NOTES it -- bring your own license.
# The software runs whatever you point it at; it just tells you the terms. No gate.
ComfyUI-3D-Pack https://github.com/MrForExample/ComfyUI-3D-Pack.git 9e8096e50c5bcf35e1f3e34c6ae06216101f8a11 MIT # TripoSR+InstantMesh+gaussian-splat+mesh/texture export (weights per-model, mostly MIT/Apache)
Comfy3D_Pre_Builds https://github.com/MrForExample/Comfy3D_Pre_Builds.git d31ef4c84dfa47d789cc5e4af04992763e4e9fbd MIT # prebuilt native deps for ComfyUI-3D-Pack (nvdiffrast/pytorch3d)
ComfyUI-Flowty-TripoSR https://github.com/flowtyone/ComfyUI-Flowty-TripoSR.git a0c94ac60a7cc062604f61aeeea6d0d493521de3 MIT # fast image->3D mesh; TripoSR weights MIT
TRELLIS.2 https://github.com/microsoft/TRELLIS.2.git 75fbf0183001ed9876c8dbb35de6b68552ee08bd MIT # Microsoft TRELLIS.2 image->3D (SLAT + RFT), best-in-class; weights MIT
Pixal3D https://github.com/TencentARC/Pixal3D.git cdbb2bbffbf4e6f298b5f2af3d1d76a8d823d2af MIT # TencentARC Pixal3D pixel-aligned image->3D (TRELLIS.2 core + DinoV3 + MoGe); MIT weights, commercial-clean
ComfyUI-Hunyuan3DWrapper https://github.com/kijai/ComfyUI-Hunyuan3DWrapper.git 2609efa38f6a98292476f714839b7c1e5f9b699a Apache-2.0 # node code Apache; NOTICE: Hunyuan3D *weights* = Tencent Community License (no commercial EU/UK/KR, 100M-MAU cap) -- bring your own license
# --- music + song generation (text/lyrics -> full song w/ vocals + accompaniment) ---
# Same rule: NODE CODE ONLY (permissive); model WEIGHTS download at runtime, BYO-license
# where non-open. ACE-Step = the commercial-clean pick (Apache weights). LeVo (Tencent AI
# Lab, best-quality per its paper) has an unsettled weights license -> noted, bring your own.
ComfyUI_ACE-Step https://github.com/billwuhao/ComfyUI_ACE-Step.git d4c2dc104a6977c83c9406b62464102c757a879c Apache-2.0 # ACE-Step 3.5B text->music foundation model; node Apache + weights Apache-2.0 (commercial-clean)
# --- video generation (text/image -> video) ---
ComfyUI-WanVideoWrapper https://github.com/kijai/ComfyUI-WanVideoWrapper.git 088128b224242e110d3906c6750e9a3a348a659b Apache-2.0 # WAN 2.1/2.2 t2v+i2v; node Apache + WAN weights Apache-2.0 (commercial-clean)
ComfyUI-LTXVideo https://github.com/Lightricks/ComfyUI-LTXVideo.git aceeae9635f6d493f2893ba3c411a1c36031788a Apache-2.0 # LTX-Video fast t2v/i2v; node Apache, weights OpenRAIL-M (open, use-restrictions apply)
ComfyUI-CogVideoXWrapper https://github.com/kijai/ComfyUI-CogVideoXWrapper.git fdb8abd2790b5459ddc7066c31861bb0b62e988b Apache-2.0 # CogVideoX t2v/i2v; node Apache, CogVideoX-2b Apache (5b = custom CogVideoX license, check)
ComfyUI-HunyuanVideoWrapper https://github.com/kijai/ComfyUI-HunyuanVideoWrapper.git fcbd6729a9b0b8ff6037c598bbada4a6bdc6d967 Apache-2.0 # node Apache; NOTICE: HunyuanVideo *weights* = Tencent Community License (no commercial EU/UK/KR) -- bring your own license
# --- voice / TTS (text -> speech, voice clone) ---
comfyui-kokoro https://github.com/stavsap/comfyui-kokoro.git cfb59b5605b351e196182c836f0b48c1a784e52f MIT # Kokoro-82M TTS; node MIT + weights Apache-2.0 (commercial-clean, fast)
ComfyUI_ChatterBox_Voice https://github.com/ShmuelRonen/ComfyUI_ChatterBox_Voice.git 6e5fc4e268656235c50e1e791f6b5d7afe615677 MIT # Resemble Chatterbox TTS + voice clone; node MIT + weights MIT (commercial-clean)
ComfyUI-F5-TTS https://github.com/niknah/ComfyUI-F5-TTS.git da9776ac5ba04f2d0b837f7dc6d5684499f3ba0b MIT # node MIT; NOTICE: F5-TTS *weights* = CC-BY-NC-4.0 (NON-COMMERCIAL) -- bring your own license for commercial
# --- infra: run GGUF-quantized diffusion/LLM models in-workflow (memory-efficient) ---
ComfyUI-GGUF https://github.com/city96/ComfyUI-GGUF.git 6ea2651e7df66d7585f6ffee804b20e92fb38b8a Apache-2.0 # GGUF-quantized model loader (Flux/SD/video in low VRAM); Apache
# --- foley / sound-effects (text->SFX, video->audio) — the named gap ---
ComfyUI-MMAudio https://github.com/kijai/ComfyUI-MMAudio.git 8eaeb72edc3aaf2059b57f2d96a1f6f689f19ae2 Apache-2.0 # MMAudio video->audio / text->SFX (Sony, CVPR2025); node Apache, NOTICE: weights CC-BY-NC-4.0 -- BYO-license for commercial
ComfyUI-AudioLDM https://github.com/sanbuphy/ComfyUI-AudioLDM.git d1edf02527f1cdc5c037842ffed1a2b564aa52fc MIT # AudioLDM2 text->audio/SFX; node MIT, NOTICE: weights CC-BY-NC-SA-4.0 -- BYO-license for commercial
# --- audio source separation (music -> stems: vocals/drums/bass) ---
audio-separation-nodes https://github.com/christian-byrne/audio-separation-nodes-comfyui.git ac339561973f0c1e56db2f9d40f11b0fddda6763 MIT # Demucs-based stem separation (vocals/drums/bass/other); node MIT + Demucs weights MIT (commercial-clean)
# --- voice/TTS (production, commercial-clean additions) ---
ComfyUI-VibeVoice https://github.com/wildminder/ComfyUI-VibeVoice.git 99a98031d79e07f279a7aa6d01ce89042e8bba26 MIT # Microsoft VibeVoice long-form conversational TTS; node + weights MIT (commercial-clean)
# --- image gen: text-render + photorealism (permissive alternatives to FLUX) ---
ComfyUI-KwaiKolorsWrapper https://github.com/kijai/ComfyUI-KwaiKolorsWrapper.git 6fc1cd9d20bb7537facf180e5494b486b9710e24 Apache-2.0 # Kwai Kolors t2i photorealism; node + weights Apache-2.0 (commercial-clean)
# --- video gen: permissive Apache alternatives ---
ComfyUI-MochiWrapper https://github.com/kijai/ComfyUI-MochiWrapper.git e1bd05240ac31b72166e9d952c75dd5735352311 Apache-2.0 # Genmo Mochi-1 t2v high-quality; node + weights Apache-2.0 (commercial-clean)
ComfyUI-PyramidFlowWrapper https://github.com/kijai/ComfyUI-PyramidFlowWrapper.git 426e3653048575dd1d1d49763a1e02fcfccf5078 Apache-2.0 # Pyramid-Flow t2v transformer-flow; node Apache, NOTICE: weights custom license -- verify for commercial
# --- music: long-form lyrics->song (Apache) ---
ComfyUI_DiffRhythm https://github.com/billwuhao/ComfyUI_DiffRhythm.git 2601a33e58fd49c63451f82db6a1c70b7b1f09fe Apache-2.0 # DiffRhythm full-length lyrics->song (non-autoregressive, fast); node + weights Apache-2.0
# --- voice: streaming + emotional TTS (Apache) ---
CosyVoice-ComfyUI https://github.com/AIFSH/CosyVoice-ComfyUI.git 67e650c761cd5306a4a06bc050196b84ab592aa3 Apache-2.0 # Alibaba CosyVoice2 real-time streaming + emotional TTS + zero-shot clone; node + weights Apache-2.0
+64 -24
View File
@@ -1,40 +1,80 @@
# Hanzo Engine nodes
Custom nodes that call a local **Hanzo Engine** from a Hanzo Studio graph.
Hanzo Engine is the native-Rust, all-modality inference and training backend
(the training loop lives engine-side); these nodes talk to its
OpenAI-compatible HTTP API.
First-party Hanzo Studio nodes that run AI workloads on the native-Rust
**Hanzo Engine** instead of in-process Python. Each node is one call to the
engine's OpenAI-compatible HTTP API; the shared transport and Studio<->engine
type conversions live in `client.py`, so every node stays a few lines and a new
engine endpoint becomes a new small node with no framework change.
## Configuration
Set the engine base URL via environment variable (default shown):
One way to point at an engine: the base URL, default `http://127.0.0.1:1234`,
overridable by env. Paths are always `/v1/*` (never `/api/*`); a trailing `/v1`
on the base URL is tolerated.
```bash
export HANZO_ENGINE_URL=http://127.0.0.1:1234/v1
export HANZO_ENGINE_URL=http://127.0.0.1:1234
hanzo engine serve -m Qwen/Qwen3-4B --port 1234 # or any local path
```
Connection or protocol failures surface as a clear error on the failing node
the server does not crash.
Engine-down and protocol failures raise a clear error on the failing node; the
server keeps running.
## Nodes (category: `Hanzo/Engine`)
| Node | Route | In Out |
|------|-------|----------|
| **Hanzo Chat (Engine)** | `POST /v1/chat/completions` | model + prompt + system → `text` (STRING) |
| **Hanzo Image Gen (Engine)** | `POST /v1/images/generations` | prompt + size + steps → `IMAGE` |
| **Hanzo Vision Caption (Engine)** | `POST /v1/chat/completions` (vision) | `IMAGE` + prompt → `caption` (STRING) |
| **Hanzo Save Text** | — | `text` (STRING) → writes `.txt` to `output/` |
| Node | Endpoint | In -> Out |
|------|----------|-----------|
| **Hanzo Chat (Engine)** | `POST /v1/chat/completions` (streams) | model + prompt -> `text` |
| **Hanzo Image Gen (Engine)** | `POST /v1/images/generations` | prompt + width/height -> `IMAGE` |
| **Hanzo TTS (Engine)** | `POST /v1/audio/speech` | text -> `AUDIO` |
| **Hanzo Music (Engine)** | `POST /v1/audio/music` | prompt -> `AUDIO` |
| **Hanzo ASR (Engine)** | `POST /v1/audio/transcriptions` | `AUDIO` -> `text` |
| **Hanzo Engine Request** | any `/v1` path | method + path + JSON -> `response` |
| **Hanzo Vision Caption (Engine)** | `POST /v1/chat/completions` (vision) | `IMAGE` + prompt -> `caption` |
| **Hanzo Text to Video (Engine)** | async `/v1/videos` | prompt -> `IMAGE` frames |
| **Hanzo Image to 3D (Engine)** | async `/v1/3d` | `IMAGE` (+`texture`) -> mesh path |
| **Hanzo Dub / Lip-Sync (Engine)** | `POST /v1/animate` | `IMAGE` + `AUDIO` -> `IMAGE` frames + `AUDIO` |
| **Hanzo Save Text** | -- | `text` -> writes `output/*.txt` |
- **Hanzo Chat** — prompt engineering and caption/text chains inside a graph.
- **Hanzo Image Gen** — decodes the engine's `b64_json` (or `url`) response into a
Hanzo Studio `IMAGE` tensor. Requests `n` images at the chosen `size`.
- **Hanzo Vision Caption** — encodes the input image as an OpenAI `image_url`
data URL and asks a vision model (e.g. Qwen3-VL) to caption it. Use for
LoRA/dataset prep.
- **Hanzo Save Text** — writes captions to `output/` as `.txt` (image/caption
pairs) and previews them in the node. Completes the caption-dataset loop.
- **Chat** streams tokens over SSE, driving the node progress bar, and returns
the full text (also previewed on the node). `model` is a dropdown populated
from `/v1/models`; `default` always routes to the loaded model.
- **Image Gen** sends the engine's `width`/`height`/`n` schema and decodes the
`b64_json` (or `url`) response into a Studio `IMAGE`.
- **TTS/Music/ASR** speak the Studio `AUDIO` type (`{waveform, sample_rate}`)
and compose with the built-in Load/Save/Preview Audio nodes. TTS and Music
serve `wav`/`pcm` (the engine's synchronous audio formats); Music is stereo
44.1 kHz ACE-Step.
- **Engine Request** is the forward-compatible escape hatch: reach any endpoint
the day it ships, then promote it to a dedicated node here.
- **Image to 3D** exposes the `texture` flag: on (default) runs the full
SLAT + FlexiCubes stage for a textured GLB; off returns the coarse mesh.
- **Text to Video / Image to 3D** use the engine's async job protocol
(`submit -> poll -> fetch content`) and report progress. **Dub** is
synchronous: it posts the portrait `IMAGE` + driving `AUDIO` and returns the
animated frames plus the audio (mux them with a video-combine node).
## Requirements
Uses only libraries already present in Hanzo Studio: `requests`, `Pillow`,
`numpy`, `torch`. No extra install.
Only libraries already in Hanzo Studio: `requests`, `Pillow`, `numpy`, `torch`
(always), plus `av` (loaded lazily, for the audio/video decode paths). WAV
encode/decode uses the stdlib `wave` module. No extra install.
## Verification
`contract_check.py` drives every node through real HTTP against a stub engine
that enforces the exact request fields each Rust handler deserializes and the
async job protocol (submit -> poll -> content):
```bash
python custom_nodes/hanzo_engine/contract_check.py
```
## Examples
- `user/default/workflows/hanzo-native-pipeline.json` -- Chat writes an image
prompt, Image Gen renders it, image + prompt are saved.
- `user/default/workflows/hanzo-full-generative-pipeline.json` -- the flagship
multimodal pipeline: Chat expands a prompt, which fans out to Image Gen ->
(textured Image-to-3D **and** WAN Text-to-Video), plus TTS narration and
ACE-Step Music. One graph, every modality, all on the native engine.
+318
View File
@@ -0,0 +1,318 @@
"""Single client layer for the Hanzo Engine — the native-Rust, all-modality
inference backend served over its OpenAI-compatible HTTP API.
Transport, config, and Studio<->engine type conversions live here so the nodes
in `nodes.py` stay thin: one node == one endpoint. New engine endpoints
(music, dub, ...) become a new small node over this same layer, no framework
change.
Base URL resolves from the HANZO_ENGINE_URL env var (default
http://127.0.0.1:1234); a trailing `/v1` is tolerated and stripped so callers
always build `/v1/...` paths themselves. Engine-down and protocol errors raise
EngineError, which Studio reports on the failing node instead of crashing the
server.
"""
import base64
import io
import json
import os
import time
import numpy as np
import requests
import torch
from PIL import Image
DEFAULT_BASE_URL = "http://127.0.0.1:1234"
POLL_INTERVAL_S = 2.0
class EngineError(RuntimeError):
"""Raised on any engine transport or protocol failure."""
def base_url() -> str:
raw = os.environ.get("HANZO_ENGINE_URL", DEFAULT_BASE_URL).strip().rstrip("/")
if raw.endswith("/v1"):
raw = raw[:-3]
return raw or DEFAULT_BASE_URL
def _url(path: str) -> str:
if not path.startswith("/"):
path = "/" + path
return f"{base_url()}{path}"
def _down_msg(url: str, exc: Exception) -> str:
return (
f"Hanzo Engine request to {url} failed: {exc}. Is the engine running? "
f"Start it with `hanzo engine serve` and set HANZO_ENGINE_URL "
f"(default {DEFAULT_BASE_URL})."
)
def _json_or_raise(resp: requests.Response, url: str) -> dict:
if not resp.ok:
raise EngineError(f"Hanzo Engine {url} returned HTTP {resp.status_code}: {resp.text[:500]}")
try:
return resp.json()
except ValueError as exc:
raise EngineError(f"Hanzo Engine {url} returned a non-JSON response: {resp.text[:200]}") from exc
# --- HTTP -------------------------------------------------------------------
def post_json(path: str, payload: dict, timeout: int = 120) -> dict:
url = _url(path)
try:
resp = requests.post(url, json=payload, timeout=timeout)
except requests.exceptions.RequestException as exc:
raise EngineError(_down_msg(url, exc)) from exc
return _json_or_raise(resp, url)
def get_json(path: str, timeout: int = 30) -> dict:
url = _url(path)
try:
resp = requests.get(url, timeout=timeout)
except requests.exceptions.RequestException as exc:
raise EngineError(_down_msg(url, exc)) from exc
return _json_or_raise(resp, url)
def post_bytes(path: str, payload: dict, timeout: int = 300) -> bytes:
"""POST JSON, return the raw response body (endpoints that return binary,
e.g. /v1/audio/speech)."""
url = _url(path)
try:
resp = requests.post(url, json=payload, timeout=timeout)
except requests.exceptions.RequestException as exc:
raise EngineError(_down_msg(url, exc)) from exc
if not resp.ok:
raise EngineError(f"Hanzo Engine {url} returned HTTP {resp.status_code}: {resp.text[:300]}")
return resp.content
def post_multipart(path: str, files: dict, data: dict, timeout: int = 300) -> dict:
url = _url(path)
try:
resp = requests.post(url, files=files, data=data, timeout=timeout)
except requests.exceptions.RequestException as exc:
raise EngineError(_down_msg(url, exc)) from exc
return _json_or_raise(resp, url)
def get_bytes(path_or_url: str, timeout: int = 120) -> bytes:
url = path_or_url if path_or_url.startswith("http") else _url(path_or_url)
try:
resp = requests.get(url, timeout=timeout)
except requests.exceptions.RequestException as exc:
raise EngineError(f"Hanzo Engine fetch {url} failed: {exc}.") from exc
if not resp.ok:
raise EngineError(f"Hanzo Engine fetch {url} returned HTTP {resp.status_code}.")
return resp.content
def list_models() -> list:
"""GET /v1/models -> [id, ...]. Returns [] if the engine is unreachable so
node definitions never fail to load."""
try:
data = get_json("/v1/models", timeout=5)
except EngineError:
return []
items = data.get("data") or []
return [it["id"] for it in items if isinstance(it, dict) and it.get("id")]
def stream_chat(payload: dict, timeout: int = 300):
"""Yield content deltas from a streaming /v1/chat/completions (OpenAI SSE)."""
url = _url("/v1/chat/completions")
body = {**payload, "stream": True}
try:
resp = requests.post(url, json=body, stream=True, timeout=timeout)
except requests.exceptions.RequestException as exc:
raise EngineError(_down_msg(url, exc)) from exc
if not resp.ok:
raise EngineError(f"Hanzo Engine {url} returned HTTP {resp.status_code}: {resp.text[:500]}")
with resp:
for line in resp.iter_lines(decode_unicode=True):
if not line or not line.startswith("data:"):
continue
chunk = line[len("data:"):].strip()
if chunk == "[DONE]":
break
try:
obj = json.loads(chunk)
except ValueError:
continue
delta = ((obj.get("choices") or [{}])[0].get("delta") or {}).get("content")
if delta:
yield delta
def extract_message_text(data: dict) -> str:
try:
content = (data["choices"][0].get("message") or {}).get("content")
except (KeyError, IndexError, TypeError) as exc:
raise EngineError(f"Unexpected Hanzo Engine response shape: {json.dumps(data)[:300]}") from exc
if isinstance(content, list): # OpenAI structured content parts
return "".join(p.get("text", "") for p in content
if isinstance(p, dict) and p.get("type") == "text").strip()
return (content or "").strip()
def run_job(resource: str, payload: dict, job_timeout: int, progress=None) -> bytes:
"""Submit an async generation job, poll to completion, return its content.
POST /v1/{resource} -> {"id": ...}
GET /v1/{resource}/{id} -> {"status": ..., "progress"?: ...}
GET /v1/{resource}/{id}/content -> raw bytes
"""
submit = post_json(f"/v1/{resource}", payload, timeout=60)
job_id = submit.get("id")
if not job_id:
raise EngineError(f"Hanzo Engine /v1/{resource} did not return a job id: {submit}")
deadline = time.monotonic() + job_timeout
while True:
body = get_json(f"/v1/{resource}/{job_id}", timeout=60)
status = str(body.get("status", "")).lower()
if status == "completed":
break
if status in {"failed", "cancelled", "error"}:
msg = body.get("error") or body.get("message") or status
raise EngineError(f"Hanzo Engine {resource} job {job_id} {status}: {msg}")
if progress is not None:
progress(body.get("progress"))
if time.monotonic() > deadline:
raise EngineError(
f"Hanzo Engine {resource} job {job_id} did not complete within "
f"{job_timeout}s (last status: {status or 'unknown'})."
)
time.sleep(POLL_INTERVAL_S)
return get_bytes(f"/v1/{resource}/{job_id}/content", timeout=job_timeout)
def progress_bar(total: int):
try:
from comfy.utils import ProgressBar
return ProgressBar(total)
except Exception:
return None
# --- Image conversions ------------------------------------------------------
def tensor_to_pil(image: torch.Tensor) -> Image.Image:
if image.dim() == 4: # [B,H,W,C] -> first frame
image = image[0]
arr = (image.cpu().numpy() * 255.0).clip(0, 255).astype(np.uint8)
return Image.fromarray(arr)
def pil_to_tensor(img: Image.Image) -> torch.Tensor:
arr = np.array(img.convert("RGB")).astype(np.float32) / 255.0
return torch.from_numpy(arr)[None,] # [1,H,W,C]
def pil_to_data_url(img: Image.Image) -> str:
buf = io.BytesIO()
img.save(buf, format="PNG")
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii")
def tensor_to_data_url(image: torch.Tensor) -> str:
return pil_to_data_url(tensor_to_pil(image))
def _decode_image_item(item: dict) -> Image.Image:
b64 = item.get("b64_json")
if b64:
raw = base64.b64decode(b64)
else:
url = item.get("url")
if not url:
raise EngineError("Hanzo Engine image item has neither 'b64_json' nor 'url'.")
raw = base64.b64decode(url.split(",", 1)[1]) if url.startswith("data:") else get_bytes(url)
return Image.open(io.BytesIO(raw)).convert("RGB")
def images_to_batch(items: list) -> torch.Tensor:
if not items:
raise EngineError("Hanzo Engine returned no image data.")
tensors = [pil_to_tensor(_decode_image_item(it)) for it in items]
if len({t.shape for t in tensors}) == 1:
return torch.cat(tensors, dim=0)
return tensors[0]
def mp4_bytes_to_image_batch(data: bytes) -> torch.Tensor:
import av
frames = []
with av.open(io.BytesIO(data)) as container:
for frame in container.decode(video=0):
frames.append(frame.to_ndarray(format="rgb24"))
if not frames:
raise EngineError("Hanzo Engine video job returned a video with no frames.")
arr = np.stack(frames).astype(np.float32) / 255.0
return torch.from_numpy(arr)
# --- Audio conversions (Studio AUDIO == {waveform:[B,C,T], sample_rate}) -----
def _f32_pcm(wav: torch.Tensor) -> torch.Tensor:
if wav.dtype.is_floating_point:
return wav
if wav.dtype == torch.int16:
return wav.float() / (2 ** 15)
if wav.dtype == torch.int32:
return wav.float() / (2 ** 31)
return wav.float()
def audio_bytes_to_studio(data: bytes) -> dict:
"""Decode engine speech bytes (any container) to a Studio AUDIO dict."""
import av
chunks, sr, n_ch = [], 24000, 1
with av.open(io.BytesIO(data)) as af:
if not af.streams.audio:
raise EngineError("Hanzo Engine speech response has no audio stream.")
stream = af.streams.audio[0]
sr = stream.codec_context.sample_rate or sr
n_ch = stream.channels or 1
for frame in af.decode(streams=stream.index):
buf = torch.from_numpy(frame.to_ndarray())
if buf.shape[0] != n_ch:
buf = buf.view(-1, n_ch).t()
chunks.append(buf)
wav = torch.cat(chunks, dim=1) if chunks else torch.zeros(n_ch, 0)
return {"waveform": _f32_pcm(wav).unsqueeze(0), "sample_rate": int(sr)}
def studio_audio_to_wav_bytes(audio: dict) -> bytes:
"""Encode a Studio AUDIO dict ({waveform:[B,C,T], sample_rate}) to 16-bit PCM
WAV bytes for multipart upload (ASR) or a data URL (dub). Stdlib `wave` only."""
import wave
wav = audio["waveform"]
if wav.dim() == 3: # [B,C,T] -> [C,T]
wav = wav[0]
arr = wav.detach().cpu().float().clamp_(-1.0, 1.0).numpy() # [C,T]
pcm16 = (arr * 32767.0).astype(np.int16)
interleaved = pcm16.T.reshape(-1).tobytes() # [T,C] interleaved
buf = io.BytesIO()
with wave.open(buf, "wb") as w:
w.setnchannels(arr.shape[0])
w.setsampwidth(2)
w.setframerate(int(audio["sample_rate"]))
w.writeframes(interleaved)
return buf.getvalue()
def studio_audio_to_data_url(audio: dict) -> str:
"""Encode a Studio AUDIO dict to a `data:audio/wav` URL (driving audio for
/v1/animate, which takes audio as an http/file/data URL)."""
return "data:audio/wav;base64," + base64.b64encode(studio_audio_to_wav_bytes(audio)).decode("ascii")
+212
View File
@@ -0,0 +1,212 @@
"""Contract test: drive every Hanzo Engine node through REAL HTTP against a stub
engine that enforces the exact request fields the Rust handlers deserialize
(ThreeDGenerationRequest, VideoGenerationRequest, AnimateRequest, Speech/Music
requests) and the async-job protocol (submit -> poll -> content). This is a real
request/response cycle over sockets + the requests lib + client.run_job polling,
not a monkeypatched mock. Run: python contract_check.py
"""
# ruff: noqa: T201 (a CLI harness prints its results)
import base64
import io
import json
import os
import sys
import threading
import types
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import numpy as np
import torch
# Stub out the ComfyUI-side modules the node pack imports so nodes.py loads.
fp = types.ModuleType("folder_paths")
fp.get_output_directory = lambda: "/tmp"
fp.get_save_image_path = lambda pre, d: (d, pre, 1, "", "")
sys.modules["folder_paths"] = fp
# Import the pack as a package so its `from . import client` resolves.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from hanzo_engine import nodes # noqa: E402
RECORDED = {} # path -> last JSON/body seen
def _wav_bytes(seconds=0.1, sr=44100, ch=2):
import wave
n = int(seconds * sr)
buf = io.BytesIO()
with wave.open(buf, "wb") as w:
w.setnchannels(ch)
w.setsampwidth(2)
w.setframerate(sr)
w.writeframes(b"\x00\x00" * n * ch)
return buf.getvalue()
def _png_b64():
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (8, 8), (128, 64, 32)).save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode("ascii")
def _mp4_bytes(frames=3, w=16, h=16):
import av
buf = io.BytesIO()
container = av.open(buf, mode="w", format="mp4")
stream = container.add_stream("libx264", rate=25)
stream.width, stream.height, stream.pix_fmt = w, h, "yuv420p"
for i in range(frames):
arr = np.full((h, w, 3), i * 20 % 255, dtype=np.uint8)
frame = av.VideoFrame.from_ndarray(arr, format="rgb24")
for pkt in stream.encode(frame):
container.mux(pkt)
for pkt in stream.encode():
container.mux(pkt)
container.close()
return buf.getvalue()
class Handler(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def _send(self, code, body, ctype="application/json"):
if isinstance(body, (dict, list)):
body = json.dumps(body).encode()
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _body_json(self):
n = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(n) if n else b"{}"
try:
return json.loads(raw)
except ValueError:
return {"_raw": raw}
def do_GET(self):
p = self.path
if p == "/v1/models":
return self._send(200, {"data": [{"id": "stub-model"}]})
if p.startswith("/v1/3d/") and p.endswith("/content"):
return self._send(200, base64.b64decode(_PLACEHOLDER_GLB_B64), "model/gltf-binary")
if p.startswith("/v1/videos/") and p.endswith("/content"):
return self._send(200, _mp4_bytes(), "video/mp4")
if p.startswith("/v1/3d/") or p.startswith("/v1/videos/"):
# poll: report completed immediately
return self._send(200, {"id": p.split("/")[3], "status": "completed", "progress": 1.0})
return self._send(404, {"message": "no"})
def do_POST(self):
try:
self._do_POST()
except Exception as exc: # surface stub-side errors to the client
self._send(500, {"stub_error": repr(exc)})
def _do_POST(self):
p = self.path
# multipart (ASR) is not JSON
if p == "/v1/audio/transcriptions":
n = int(self.headers.get("Content-Length", 0))
_ = self.rfile.read(n)
RECORDED[p] = {"multipart": True}
return self._send(200, {"text": "stub transcript"})
body = self._body_json()
RECORDED[p] = body
if p == "/v1/chat/completions":
return self._send(200, {"choices": [{"message": {"content": "expanded prompt"}}]})
if p == "/v1/images/generations":
return self._send(200, {"data": [{"b64_json": _png_b64()}]})
if p == "/v1/audio/speech":
assert "input" in body and "model" in body, "speech needs input+model"
return self._send(200, _wav_bytes(ch=1, sr=24000), "audio/wav")
if p == "/v1/audio/music":
assert "input" in body and "model" in body, "music needs input+model"
return self._send(200, _wav_bytes(ch=2, sr=44100), "audio/wav")
if p == "/v1/animate":
assert set(body) >= {"model", "audio", "visual"}, "animate needs model+audio+visual"
assert body["visual"].startswith("data:image/"), "visual must be an image data url"
assert body["audio"].startswith("data:audio/"), "audio must be an audio data url"
return self._send(200, _mp4_bytes(), "video/mp4")
if p == "/v1/videos":
assert "prompt" in body, "video needs prompt"
return self._send(202, {"id": "vid-1", "status": "queued"})
if p == "/v1/3d":
assert "image" in body, "3d needs image"
return self._send(202, {"id": "threed-1", "status": "queued", "progress": 0.0})
return self._send(404, {"message": "no"})
# A 1-triangle binary glTF is overkill; the node just writes bytes to disk, so any
# non-empty payload proves the content fetch + save path. Use a tiny placeholder.
_PLACEHOLDER_GLB_B64 = base64.b64encode(b"glTF\x02\x00\x00\x00stub-mesh").decode("ascii")
def main():
srv = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
port = srv.server_address[1]
threading.Thread(target=srv.serve_forever, daemon=True).start()
import os
os.environ["HANZO_ENGINE_URL"] = f"http://127.0.0.1:{port}"
img = torch.rand(1, 8, 8, 3)
audio = {"waveform": torch.zeros(1, 2, 4410), "sample_rate": 44100}
passed = []
# Chat (non-stream path)
(text,) = nodes.HanzoChat().generate(model="default", prompt="a red dragon", stream=False)["result"]
assert text == "expanded prompt", text
assert RECORDED["/v1/chat/completions"]["messages"][-1]["content"] == "a red dragon"
passed.append("HanzoChat -> /v1/chat/completions")
# ImageGen
(imgs,) = nodes.HanzoImageGen().generate("a red dragon", 64, 64, n=1)
assert imgs.shape[-1] == 3
passed.append("HanzoImageGen -> /v1/images/generations")
# TTS
(aud,) = nodes.HanzoTTS().speak("hello world", response_format="wav")
assert "waveform" in aud and aud["sample_rate"] == 24000
passed.append("HanzoTTS -> /v1/audio/speech")
# Music (NEW)
(music,) = nodes.HanzoMusic().compose("epic orchestral", response_format="wav")
assert music["sample_rate"] == 44100 and music["waveform"].shape[1] == 2, music["waveform"].shape
assert RECORDED["/v1/audio/music"]["input"] == "epic orchestral"
passed.append("HanzoMusic -> /v1/audio/music (input field, stereo 44.1k)")
# ASR
(tr,) = nodes.HanzoASR().transcribe(audio)
assert tr == "stub transcript"
passed.append("HanzoASR -> /v1/audio/transcriptions")
# ImageTo3D (texture flag fix) -- async job submit+poll+content
out = nodes.HanzoImageTo3D().generate(img, "glb", True, 0, 4)
assert RECORDED["/v1/3d"]["texture"] is True, "texture flag not sent"
assert RECORDED["/v1/3d"]["format"] == "glb"
assert out["result"][0].endswith(".glb")
passed.append("HanzoImageTo3D -> /v1/3d (texture=true sent; job submit->poll->content)")
# TextToVideo -- async job
(frames,) = nodes.HanzoTextToVideo().generate("a red dragon flying", 3, 16, 16, 4)
assert frames.dim() == 4 and frames.shape[0] >= 1
assert RECORDED["/v1/videos"]["num_frames"] == 3
passed.append("HanzoTextToVideo -> /v1/videos (job submit->poll->content)")
# Dub (NEW) -- synchronous mp4
(dframes, daudio) = nodes.HanzoDub().dub(img, audio, fps=25.0)
assert dframes.dim() == 4
assert daudio is audio
passed.append("HanzoDub -> /v1/animate (image+audio data urls; frames+audio out)")
srv.shutdown()
print("\n".join(f"PASS {p}" for p in passed))
print(f"\nALL {len(passed)} NODE CONTRACT CHECKS PASSED against a live HTTP engine stub.")
if __name__ == "__main__":
main()
+347 -143
View File
@@ -1,197 +1,236 @@
"""Hanzo Engine nodes for Hanzo Studio.
These nodes call a local Hanzo Engine — the native-Rust, all-modality inference
and training backend — over its OpenAI-compatible HTTP API. The base URL comes
from the HANZO_ENGINE_URL environment variable (default http://127.0.0.1:1234/v1).
Each node is one endpoint of the native-Rust Hanzo Engine, over the shared
client in `client.py`. Config is one way: the engine base URL (default
http://127.0.0.1:1234, override with HANZO_ENGINE_URL). Paths are always
`/v1/*` — never `/api/*`.
Connection and protocol errors surface as clear node errors (raised
RuntimeError), which Hanzo Studio reports on the failing node rather than
crashing the server.
HanzoChat -> POST /v1/chat/completions (prompt -> text)
HanzoImageGen -> POST /v1/images/generations (prompt -> IMAGE)
HanzoTTS -> POST /v1/audio/speech (text -> AUDIO)
HanzoMusic -> POST /v1/audio/music (prompt -> AUDIO)
HanzoASR -> POST /v1/audio/transcriptions (AUDIO -> text)
HanzoEngineRequest -> POST|GET <any /v1 path> (JSON -> response)
HanzoVisionCaption -> POST /v1/chat/completions (vision) (IMAGE -> text)
HanzoTextToVideo -> async /v1/videos (prompt -> frames)
HanzoImageTo3D -> async /v1/3d (IMAGE -> mesh path)
HanzoDub -> POST /v1/animate (IMAGE+AUDIO -> frames)
HanzoSaveText -> writes a STRING to output/*.txt
The generic HanzoEngineRequest is the forward-compatible escape hatch: a new
engine endpoint (music, dub, ...) is reachable the day it ships, and promoting
it to a first-class node is a few lines here.
"""
import base64
import io
import json
import os
import numpy as np
import requests
import torch
from PIL import Image
import folder_paths
DEFAULT_BASE_URL = "http://127.0.0.1:1234/v1"
from . import client
CATEGORY = "Hanzo/Engine"
def _base_url() -> str:
return os.environ.get("HANZO_ENGINE_URL", DEFAULT_BASE_URL).rstrip("/")
def _post(path: str, payload: dict, timeout: int = 120) -> dict:
url = f"{_base_url()}{path}"
try:
resp = requests.post(url, json=payload, timeout=timeout)
except requests.exceptions.RequestException as exc:
raise RuntimeError(
f"Hanzo Engine request to {url} failed: {exc}. Is the engine running? "
f"Set HANZO_ENGINE_URL (default {DEFAULT_BASE_URL})."
) from exc
if resp.status_code != 200:
raise RuntimeError(
f"Hanzo Engine {url} returned HTTP {resp.status_code}: {resp.text[:500]}"
)
try:
return resp.json()
except ValueError as exc:
raise RuntimeError(
f"Hanzo Engine {url} returned a non-JSON response: {resp.text[:200]}"
) from exc
def _get_bytes(url: str, timeout: int = 120) -> bytes:
try:
resp = requests.get(url, timeout=timeout)
except requests.exceptions.RequestException as exc:
raise RuntimeError(f"Hanzo Engine image fetch from {url} failed: {exc}.") from exc
if resp.status_code != 200:
raise RuntimeError(f"Hanzo Engine image fetch {url} returned HTTP {resp.status_code}.")
return resp.content
def _extract_message_text(data: dict) -> str:
try:
content = (data["choices"][0].get("message") or {}).get("content")
except (KeyError, IndexError, TypeError) as exc:
raise RuntimeError(
f"Unexpected Hanzo Engine response shape: {json.dumps(data)[:300]}"
) from exc
if isinstance(content, list): # OpenAI structured content parts
parts = [p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"]
return "".join(parts).strip()
return (content or "").strip()
def _tensor_to_pil(image: torch.Tensor) -> Image.Image:
if image.dim() == 4: # [B,H,W,C] -> first frame
image = image[0]
arr = (image.cpu().numpy() * 255.0).clip(0, 255).astype(np.uint8)
return Image.fromarray(arr)
def _pil_to_tensor(img: Image.Image) -> torch.Tensor:
arr = np.array(img.convert("RGB")).astype(np.float32) / 255.0
return torch.from_numpy(arr)[None,] # [1,H,W,C]
def _pil_to_data_url(img: Image.Image) -> str:
buf = io.BytesIO()
img.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
return f"data:image/png;base64,{b64}"
def _decode_image_item(item: dict) -> Image.Image:
b64 = item.get("b64_json")
if b64:
return Image.open(io.BytesIO(base64.b64decode(b64))).convert("RGB")
url = item.get("url")
if url:
if url.startswith("data:"):
raw = base64.b64decode(url.split(",", 1)[1])
else:
raw = _get_bytes(url)
return Image.open(io.BytesIO(raw)).convert("RGB")
raise RuntimeError("Hanzo Engine image item has neither 'b64_json' nor 'url'.")
def _model_combo() -> list:
"""["default", <ids from /v1/models>] — 'default' always routes to the
loaded model, so saved graphs stay portable across engines."""
combo, seen = [], set()
for name in ["default", *client.list_models()]:
if name not in seen:
seen.add(name)
combo.append(name)
return combo
class HanzoChat:
"""Text chat via Hanzo Engine /v1/chat/completions. Use for prompt
engineering, caption cleanup, and text chains inside a graph."""
"""LLM chat/completion. Streams tokens (live progress) and returns the
full text; feeds prompt chains and image prompts inside a graph."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("STRING", {"default": "default"}),
"model": (_model_combo(),),
"prompt": ("STRING", {"multiline": True, "default": ""}),
},
"optional": {
"system": ("STRING", {"multiline": True, "default": ""}),
"temperature": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 2.0, "step": 0.05}),
"max_tokens": ("INT", {"default": 512, "min": 1, "max": 32768}),
"stream": ("BOOLEAN", {"default": True}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
FUNCTION = "generate"
CATEGORY = "Hanzo/Engine"
CATEGORY = CATEGORY
OUTPUT_NODE = True
def generate(self, model, prompt, system="", temperature=0.7, max_tokens=512):
def generate(self, model, prompt, system="", temperature=0.7, max_tokens=512, stream=True):
messages = []
if system.strip():
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
data = _post(
"/chat/completions",
{"model": model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens},
)
return (_extract_message_text(data),)
payload = {"model": model, "messages": messages,
"temperature": temperature, "max_tokens": max_tokens}
if stream:
pbar = client.progress_bar(max_tokens)
parts = []
for i, delta in enumerate(client.stream_chat(payload)):
parts.append(delta)
if pbar:
pbar.update_absolute(min(i + 1, max_tokens))
text = "".join(parts).strip()
else:
text = client.extract_message_text(client.post_json("/v1/chat/completions", payload))
return {"ui": {"text": [text]}, "result": (text,)}
class HanzoImageGen:
"""Text-to-image via Hanzo Engine /v1/images/generations. Returns a
Hanzo Studio IMAGE tensor."""
"""Text-to-image via /v1/images/generations (native Rust). Returns a
Studio IMAGE tensor."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
"size": (["1024x1024", "1024x1536", "1536x1024", "768x768", "512x512"], {"default": "1024x1024"}),
"width": ("INT", {"default": 1024, "min": 64, "max": 4096, "step": 8}),
"height": ("INT", {"default": 1024, "min": 64, "max": 4096, "step": 8}),
},
"optional": {
"model": ("STRING", {"default": "default"}),
"steps": ("INT", {"default": 20, "min": 1, "max": 150}),
"model": (_model_combo(),),
"n": ("INT", {"default": 1, "min": 1, "max": 8}),
},
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "generate"
CATEGORY = "Hanzo/Engine"
CATEGORY = CATEGORY
def generate(self, prompt, size, model="default", steps=20, n=1):
data = _post(
"/images/generations",
{"model": model, "prompt": prompt, "size": size, "n": n, "steps": steps, "response_format": "b64_json"},
def generate(self, prompt, width, height, model="default", n=1):
data = client.post_json(
"/v1/images/generations",
{"model": model, "prompt": prompt, "width": width, "height": height,
"n": n, "response_format": "b64_json"},
timeout=600,
)
items = data.get("data") or []
if not items:
raise RuntimeError("Hanzo Engine /images/generations returned no image data.")
tensors = [_pil_to_tensor(_decode_image_item(it)) for it in items]
if len(tensors) == 1:
batch = tensors[0]
return (client.images_to_batch(data.get("data") or []),)
class HanzoTTS:
"""Text-to-speech via /v1/audio/speech (native Rust). Returns a Studio
AUDIO you can save/preview with the built-in audio nodes."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {"multiline": True, "default": ""}),
},
"optional": {
"model": (_model_combo(),),
"response_format": (["wav", "pcm"], {"default": "wav"}),
},
}
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("audio",)
FUNCTION = "speak"
CATEGORY = CATEGORY
def speak(self, text, model="default", response_format="wav"):
raw = client.post_bytes(
"/v1/audio/speech",
{"model": model, "input": text, "response_format": response_format},
)
return (client.audio_bytes_to_studio(raw),)
class HanzoASR:
"""Speech-to-text via /v1/audio/transcriptions (OpenAI multipart). Returns
the transcript text."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"audio": ("AUDIO",),
},
"optional": {
"model": (_model_combo(),),
"language": ("STRING", {"default": ""}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
FUNCTION = "transcribe"
CATEGORY = CATEGORY
def transcribe(self, audio, model="default", language=""):
wav = client.studio_audio_to_wav_bytes(audio)
data = {"model": model, "response_format": "json"}
if language.strip():
data["language"] = language.strip()
resp = client.post_multipart(
"/v1/audio/transcriptions",
files={"file": ("audio.wav", wav, "audio/wav")},
data=data,
)
return ((resp.get("text") or "").strip(),)
class HanzoEngineRequest:
"""Generic engine call: any `/v1` path with a JSON body -> the raw JSON
response as STRING. The forward-compatible escape hatch for endpoints that
do not have a dedicated node yet (music, dub, ...)."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"method": (["POST", "GET"], {"default": "POST"}),
"path": ("STRING", {"default": "/v1/chat/completions"}),
"json_body": ("STRING", {"multiline": True, "default": "{}"}),
},
"optional": {
"timeout": ("INT", {"default": 120, "min": 1, "max": 3600}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("response",)
FUNCTION = "call"
CATEGORY = CATEGORY
def call(self, method, path, json_body, timeout=120):
try:
payload = json.loads(json_body or "{}")
except ValueError as exc:
raise client.EngineError(f"HanzoEngineRequest json_body is not valid JSON: {exc}") from exc
if method == "GET":
data = client.get_json(path, timeout=timeout)
else:
same = len({t.shape for t in tensors}) == 1
batch = torch.cat(tensors, dim=0) if same else tensors[0]
return (batch,)
data = client.post_json(path, payload, timeout=timeout)
return (json.dumps(data, indent=2),)
class HanzoVisionCaption:
"""Caption an IMAGE via Hanzo Engine vision chat (OpenAI image_url format).
Feeds LoRA/dataset prep loops; pair with a vision model such as Qwen3-VL."""
"""Caption an IMAGE via vision chat (OpenAI image_url). Pair with a vision
model (e.g. Qwen3-VL) for LoRA/dataset prep."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"prompt": ("STRING", {"multiline": True, "default": "Describe this image in detail for a training caption."}),
"prompt": ("STRING", {"multiline": True,
"default": "Describe this image in detail for a training caption."}),
},
"optional": {
"model": ("STRING", {"default": "default"}),
"model": (_model_combo(),),
"system": ("STRING", {"multiline": True, "default": ""}),
"max_tokens": ("INT", {"default": 512, "min": 1, "max": 32768}),
},
@@ -200,27 +239,178 @@ class HanzoVisionCaption:
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("caption",)
FUNCTION = "caption"
CATEGORY = "Hanzo/Engine"
CATEGORY = CATEGORY
def caption(self, image, prompt, model="default", system="", max_tokens=512):
data_url = _pil_to_data_url(_tensor_to_pil(image))
content = [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": client.tensor_to_data_url(image)}},
]
messages = []
if system.strip():
messages.append({"role": "system", "content": system})
messages.append({
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": data_url}},
],
})
data = _post("/chat/completions", {"model": model, "messages": messages, "max_tokens": max_tokens}, timeout=300)
return (_extract_message_text(data),)
messages.append({"role": "user", "content": content})
data = client.post_json(
"/v1/chat/completions",
{"model": model, "messages": messages, "max_tokens": max_tokens},
timeout=300,
)
return (client.extract_message_text(data),)
class HanzoTextToVideo:
"""Text-to-video via the async job /v1/videos (native Rust). Returns the
decoded frames as an IMAGE batch so it composes with video-save nodes."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
"num_frames": ("INT", {"default": 81, "min": 1, "max": 1024}),
"width": ("INT", {"default": 832, "min": 64, "max": 2048, "step": 8}),
"height": ("INT", {"default": 480, "min": 64, "max": 2048, "step": 8}),
"steps": ("INT", {"default": 30, "min": 1, "max": 150}),
},
"optional": {
"model": (_model_combo(),),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("frames",)
FUNCTION = "generate"
CATEGORY = CATEGORY
def generate(self, prompt, num_frames, width, height, steps, model="default"):
pbar = client.progress_bar(100)
def report(p):
if pbar is not None and p is not None:
try:
pct = float(p)
pbar.update_absolute(max(0, min(100, int(pct * (100 if pct <= 1.0 else 1)))))
except (TypeError, ValueError):
pass
raw = client.run_job(
"videos",
{"model": model, "prompt": prompt, "num_frames": num_frames,
"width": width, "height": height, "steps": steps},
job_timeout=1800,
progress=report,
)
return (client.mp4_bytes_to_image_batch(raw),)
class HanzoImageTo3D:
"""Image-to-3D via the async job /v1/3d (native Rust TRELLIS/Pixal3D). Saves
the mesh to the output dir and returns its path for a 3D-viewer node. Enable
`texture` for the full SLAT + FlexiCubes stage (textured GLB vs coarse mesh)."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"format": (["glb", "ply", "obj"], {"default": "glb"}),
"texture": ("BOOLEAN", {"default": True}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF}),
"steps": ("INT", {"default": 30, "min": 1, "max": 150}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("mesh_path",)
FUNCTION = "generate"
CATEGORY = CATEGORY
OUTPUT_NODE = True
def generate(self, image, format, texture, seed, steps):
raw = client.run_job(
"3d",
{"image": client.tensor_to_data_url(image), "format": format,
"texture": texture, "seed": seed, "steps": steps},
job_timeout=900,
)
full_output_folder, filename, counter, _sub, _pre = folder_paths.get_save_image_path(
"hanzo3d", folder_paths.get_output_directory()
)
path = os.path.join(full_output_folder, f"{filename}_{counter:05}_.{format}")
with open(path, "wb") as fh:
fh.write(raw)
return {"ui": {"text": [path]}, "result": (path,)}
class HanzoMusic:
"""Text/tag/lyric prompt -> music via /v1/audio/music (native Rust ACE-Step).
Returns a Studio AUDIO you can save/preview with the built-in audio nodes."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"multiline": True,
"default": "epic cinematic orchestral, driving percussion, brass swells"}),
},
"optional": {
"model": (_model_combo(),),
"response_format": (["wav", "pcm"], {"default": "wav"}),
},
}
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("audio",)
FUNCTION = "compose"
CATEGORY = CATEGORY
def compose(self, prompt, model="default", response_format="wav"):
raw = client.post_bytes(
"/v1/audio/music",
{"model": model, "input": prompt, "response_format": response_format},
timeout=900,
)
return (client.audio_bytes_to_studio(raw),)
class HanzoDub:
"""Lip-sync / avatar dub via /v1/animate (native Rust MuseTalk). Drives a
portrait IMAGE (or the first video frame) with an AUDIO track and returns the
animated frames as an IMAGE batch plus the driving audio, so a video-combine
node can mux them."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"audio": ("AUDIO",),
},
"optional": {
"model": (_model_combo(),),
"fps": ("FLOAT", {"default": 25.0, "min": 1.0, "max": 60.0, "step": 1.0}),
},
}
RETURN_TYPES = ("IMAGE", "AUDIO")
RETURN_NAMES = ("frames", "audio")
FUNCTION = "dub"
CATEGORY = CATEGORY
def dub(self, image, audio, model="default", fps=25.0):
payload = {
"model": model,
"audio": client.studio_audio_to_data_url(audio),
"visual": client.tensor_to_data_url(image),
"fps": fps,
}
raw = client.post_bytes("/v1/animate", payload, timeout=1800)
return (client.mp4_bytes_to_image_batch(raw), audio)
class HanzoSaveText:
"""Write a STRING to a .txt file in the output directory (image/caption
pairs for dataset prep) and preview it in the node."""
"""Write a STRING to output/*.txt (image/caption pairs for dataset prep)
and preview it in the node."""
def __init__(self):
self.output_dir = folder_paths.get_output_directory()
@@ -230,17 +420,17 @@ class HanzoSaveText:
return {
"required": {
"text": ("STRING", {"forceInput": True}),
"filename_prefix": ("STRING", {"default": "caption"}),
"filename_prefix": ("STRING", {"default": "hanzo"}),
},
}
RETURN_TYPES = ()
FUNCTION = "save"
OUTPUT_NODE = True
CATEGORY = "Hanzo/Engine"
CATEGORY = CATEGORY
def save(self, text, filename_prefix="caption"):
full_output_folder, filename, counter, _subfolder, _prefix = folder_paths.get_save_image_path(
def save(self, text, filename_prefix="hanzo"):
full_output_folder, filename, counter, _sub, _pre = folder_paths.get_save_image_path(
filename_prefix, self.output_dir
)
path = os.path.join(full_output_folder, f"{filename}_{counter:05}_.txt")
@@ -252,13 +442,27 @@ class HanzoSaveText:
NODE_CLASS_MAPPINGS = {
"HanzoChat": HanzoChat,
"HanzoImageGen": HanzoImageGen,
"HanzoTTS": HanzoTTS,
"HanzoMusic": HanzoMusic,
"HanzoASR": HanzoASR,
"HanzoEngineRequest": HanzoEngineRequest,
"HanzoVisionCaption": HanzoVisionCaption,
"HanzoTextToVideo": HanzoTextToVideo,
"HanzoImageTo3D": HanzoImageTo3D,
"HanzoDub": HanzoDub,
"HanzoSaveText": HanzoSaveText,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"HanzoChat": "Hanzo Chat (Engine)",
"HanzoImageGen": "Hanzo Image Gen (Engine)",
"HanzoTTS": "Hanzo TTS (Engine)",
"HanzoMusic": "Hanzo Music (Engine)",
"HanzoASR": "Hanzo ASR (Engine)",
"HanzoEngineRequest": "Hanzo Engine Request",
"HanzoVisionCaption": "Hanzo Vision Caption (Engine)",
"HanzoTextToVideo": "Hanzo Text to Video (Engine)",
"HanzoImageTo3D": "Hanzo Image to 3D (Engine)",
"HanzoDub": "Hanzo Dub / Lip-Sync (Engine)",
"HanzoSaveText": "Hanzo Save Text",
}
+117 -130
View File
@@ -1,168 +1,155 @@
# Hanzo Studio — GPU Federation
How `studio.hanzo.ai` schedules generation jobs across a heterogeneous GPU pool:
in-cluster cloud pods **and** local boxes (e.g. a GB10) that join over an
**outbound-only** connection. No inbound tunnel to the local box is ever
required.
How `studio.hanzo.ai` runs generation jobs durably and spreads them across a
heterogeneous GPU pool: in-cluster cloud pods **and** local boxes (e.g. a GB10)
that join over an **outbound-only** connection. No inbound tunnel to a local box
is ever required.
This document is the contract. Sections are marked **[implemented]** (code in
this repo today) or **[specced]** (agreed design, not yet wired). Nothing here is
a stub in code — specced pieces live in this doc until they are built.
Sections are marked **[implemented]** (code in this repo today), **[fast-path]**
(retained in-cluster optimization), or **[future]**.
## 1. Roles
- **Coordinator** — the `studio.hanzo.ai` deployment. Full UI, IAM auth,
multi-tenant storage. Owns the per-org worker registry and the job schedule.
Runs a small CPU footprint (it routes; it does not need a GPU).
- **Worker** — a headless Studio started with `--worker-mode`. Reports its
device (GPU model, VRAM), registers with the coordinator, and executes prompts
it is given. A worker belongs to exactly one org (`STUDIO_ORG_ID`).
multi-tenant storage. Accepts `/prompt`, owns the durable render queue and the
per-org worker registry + engine selection.
- **Worker** — a Studio process that executes prompts. Either the same box as the
coordinator (local render), an in-cluster pod reached by push (§2a), or a box
sharing the coordinator's render queue.
A worker is the *same binary* as the coordinator; `--worker-mode` only drops the
UI/websocket surface and turns on registration + the execute endpoint. This is
the ComfyUI-Distributed lineage (workers are full ComfyUI instances the control
plane drives) narrowed to **job-level** dispatch and hardened for multi-tenant +
NAT traversal.
A worker is the *same binary* as the coordinator.
## 2. Two dispatch models
## 2. Durable render queue **[implemented]**
Reachability, not preference, decides which model a worker uses.
The render queue is crash-durable: pending and in-flight prompts survive a
process crash, and a killed render is re-run instead of being lost. Storage is a
single **SQLite** file (stdlib `sqlite3`, WAL) — **zero external processes**, per
the house rule (SQLite embedded by default; Postgres only for prod multi-instance).
### 2a. Push — for in-cluster workers **[implemented]**
Enable with `STUDIO_QUEUE_DB=<path>`. `server.py` then swaps `PromptQueue` for
`SqlitePromptQueue`; the existing prompt-executor thread is the claim loop
unchanged. The `PromptQueue` seam maps onto a durable job state machine:
The coordinator can open a connection to the worker (both are pods in
`hanzo-k8s`, or the worker has a routable `WORKER_EXTERNAL_URL`).
```
put(item) → INSERT job (pending) # idempotent on prompt_id
get(timeout) → claim next pending (claimed, leased) # BEGIN IMMEDIATE
task_done(success) → job → done
task_done(error) → job → pending (retry) | failed # per STUDIO_TASKS_MAX_ATTEMPTS
```
Guarantees:
- **Exactly-once submit** — the row is keyed by `prompt_id` (`INSERT OR IGNORE`);
a resubmit of the same prompt is a no-op.
- **Exactly-once claim** — `get()` claims under `BEGIN IMMEDIATE`, so with several
Studio processes on the same `STUDIO_QUEUE_DB` exactly one claims each job.
- **Crash-recovery** — a claim carries a lease. A background heartbeat renews it
while the render runs; if the process dies, the lease expires and the reap step
(run on every `get()`, in the heartbeat thread, and on boot) returns the job to
`pending` for another claim. A render retry is idempotent — SaveImage suffixes
increment.
Files: `middleware/tasks_queue.py` (`SqlitePromptQueue`), `server.py` (queue
selection). Precedence:
| Precedence | Trigger | Backend | Durability |
|---|---|---|---|
| 1 | `STUDIO_QUEUE_DB` set | `SqlitePromptQueue` (one file) | Crash-durable, exactly-once, retry, multi-process |
| 2 | `STUDIO_PERSIST_QUEUE=1` | `PromptQueue` + JSON snapshot | Single-box crash-survival (re-queue on boot) |
| 3 | (default) | `PromptQueue` (in-memory) | None — same as upstream ComfyUI |
Env: `STUDIO_QUEUE_DB` (path), `STUDIO_TASKS_QUEUE` (default `studio-render`),
`STUDIO_TASKS_MAX_ATTEMPTS` (default 3), `STUDIO_TASKS_LEASE_MS` (default 60000,
heartbeat renews at lease/3), `STUDIO_WORKER_ID` (default `<host>-<pid>`).
### 2a. Push — in-cluster fast path **[fast-path]**
When the coordinator can already reach a worker (both pods in `hanzo-k8s`, or a
routable `WORKER_EXTERNAL_URL`), it may forward a prompt directly:
```
client → POST /prompt (coordinator)
prompt_router.route_prompt(org, body)
→ get_available_gpu_worker(org) # compute_config registry
→ POST {worker.url}/v1/worker/execute # forward_to_worker
worker queues + executes locally, returns {prompt_id, ...}
```
Files: `middleware/prompt_router.py`, `middleware/worker_client.py`
(`add_worker_routes``/v1/worker/execute`), `server.py` (`/prompt` calls
`route_prompt`).
Files: `middleware/prompt_router.py`, `middleware/worker_client.py`. This path is
not durable on its own (a forwarded prompt lost to a worker crash is not retried);
keep it for latency-sensitive in-cluster dispatch. It requires the coordinator to
reach the worker, so it cannot serve a NAT'd box.
Limitation: requires the coordinator to reach the worker. A GB10 behind NAT is
**not** reachable, so push cannot serve it.
## 3. Engine selector **[implemented]**
### 2b. Pull — for NAT'd local boxes (the GB10 pattern) **[specced]**
An org chooses which execution target ("engine") its prompts run on. Engines are
derived from what already exists in the per-org compute registry:
The worker dials **out** and keeps the connection; the coordinator pushes jobs
down that already-open channel. No inbound port on the box.
- `local` — this Studio instance (always present), and
- each registered org GPU worker (from `compute.json`'s worker registry).
Primary transport is a WebSocket the worker opens to the coordinator:
Endpoints (coordinator):
```
worker → WSS {coordinator}/v1/workers/connect (Authorization: worker token)
← {type:"job", job_id, prompt, extra_data, org_id}
→ {type:"progress", job_id, value, max}
→ {type:"result", job_id, status, outputs:[{name, s3_url}...]}
```
| Method | Path | Purpose |
|---|---|---|
| GET | `/v1/engines` | List engines for the org + the current default. |
| PUT | `/v1/engines/default` | Set the org's default engine. Body `{"engine": "<id>"}`. |
HTTP long-poll fallback for environments without WSS:
The default is stored on the org's compute profile (`ComputeProfile.default_engine`).
`prompt_router.route_prompt` honors it: if the default names a live worker, the
prompt is forwarded there; `local` (the default) leaves current behavior
unchanged. A stale default (worker deregistered) falls back to `local`, never 500.
```
worker → POST /v1/jobs/claim {worker_id, org_id} # long-poll, ≤30s
← 204 (no work) | 200 {job_id, prompt, extra_data}
worker → POST /v1/jobs/{job_id}/result {status, outputs}
```
Files: `middleware/engine_selector.py`, `middleware/compute_config.py`,
`middleware/prompt_router.py`. **Frontend TODO:** a queue-panel/settings dropdown
to pick the engine — the API + per-org default ship now; the UI control is a
follow-up.
The coordinator keeps a per-org, per-worker job queue (a natural extension of
`compute_config`), enqueues on `/prompt` when the target worker is pull-mode, and
hands the job to whichever channel the worker holds open. `route_prompt` gains a
third branch (`{"action":"queued"}``202`) alongside the existing
forward/provisioning/unavailable branches.
## 4. Security **[implemented]**
Not implemented because it does not "drop out" of ComfyUI's `--listen`
architecture — it needs a real coordinator-side queue and a WS control channel.
Building it does not change the endpoints below.
- **User auth** — all user-facing routes require an IAM JWT (JWKS-verified); org
comes from the `owner` claim. See `middleware/iam_auth_middleware.py`.
- **Worker trust** — the IAM-exempt `/v1/worker/execute` and `/v1/workers/register`
require `X-Worker-Token == STUDIO_WORKER_TOKEN` (`worker_client.verify_worker_token`).
KMS-sourced in cloud; empty in a single-trust-domain local dev, where the check
is skipped.
- **Org isolation** — a render carries its `org_id` in `extra_data`; the worker
binds it as the execution org so outputs land in that org's namespace (§5). A
worker only serves its own `STUDIO_ORG_ID`.
## 3. Endpoints
## 5. Output persistence **[future]**
All under `/v1/` (also served under the `/api/v1/` alias ComfyUI adds for the
frontend proxy). Worker↔coordinator endpoints are exempt from user IAM and
carry the coordinator shared secret instead (`X-Worker-Token`, §5).
Workers execute against their own output dir (org-scoped via
`folder_paths.set_execution_org`). For the coordinator UI to serve results across
boxes, workers upload outputs to `s3.lux.cloud` (hanzo bucket), key
`studio/{org_id}/{prompt_id}/{filename}`, and the coordinator serves via `/view`
(or a signed redirect). Until then, results remain on the executing worker —
fine for the single-box and in-cluster cases.
| Method | Path | Dir | Status | Purpose |
|---|---|---|---|---|
| POST | `/v1/workers/register` | worker→coord | **impl** | Register / heartbeat. Body: `{worker_id, url, org_id, device, gpu_model, vram_gb, status}`. |
| GET | `/v1/workers` | client→coord | **impl** | List the org's workers + liveness. |
| POST | `/v1/worker/execute` | coord→worker | **impl** | Push a prompt to a reachable worker. Same body as `/prompt`. |
| WSS | `/v1/workers/connect` | worker→coord | **spec** | Persistent pull channel for NAT'd workers. |
| POST | `/v1/jobs/claim` | worker→coord | **spec** | HTTP long-poll fallback to claim a job. |
| POST | `/v1/jobs/{id}/result` | worker→coord | **spec** | Report result + artifact URLs. |
| GET | `/v1/compute/config` | client→coord | **impl** | Read the org's compute profile. |
| PUT | `/v1/compute/config` | client→coord | **impl** | Update profile (GPU tier, auto-provision). |
| POST | `/v1/compute/provision` | client→coord | **impl** | Autoscale a cloud GPU worker via Visor. |
## 6. Future direction
(Compute-profile + Visor autoscale routes are still under `/compute/*` in
`server.py`; the `/v1/compute/*` labels above are the canonical names to fold to
when those handlers are next touched. Worker-registry + execute already moved.)
## 4. Registration & scheduling **[implemented]**
- Worker sends a heartbeat every 30s (`WorkerClient._heartbeat_loop`).
- Coordinator upserts it into the org's `compute.json`
(`compute_config.register_worker`), pruning workers silent >300s.
Liveness for scheduling is 90s (`WorkerInfo.is_alive`).
- `get_available_gpu_worker(org)` returns the first ready, alive CUDA worker.
- `prompt_router.route_prompt` chooses local vs worker from the prompt's
`device_preference` (`auto`|`cpu`|`gpu`) and the org's `gpu_enabled`.
- If no worker and the org has `auto_provision`, Visor launches a cloud GPU VM
that boots a worker (`middleware/visor_client.py`).
## 5. Security
- **User auth** — all user-facing routes require an IAM JWT (JWKS-verified);
org comes from the `owner` claim. See `middleware/iam_auth_middleware.py`.
- **Worker trust** — worker↔coordinator endpoints are IAM-exempt (they carry no
user) and instead require `X-Worker-Token == STUDIO_WORKER_TOKEN`
(`worker_client.verify_worker_token`). The token is KMS-sourced in cloud;
empty in a single-trust-domain local dev, where the check is skipped.
**[specced]** forward direction: replace the shared secret with an IAM
service-account token (`hanzo-studio` client-credentials grant) so each worker
is individually attributable and revocable.
- **Org isolation** — a job carries its `org_id`; the worker binds it as the
execution org so outputs land in that org's namespace (§6). A worker only ever
claims jobs for its own `STUDIO_ORG_ID`.
- **No inbound** — pull-mode boxes expose nothing; the coordinator is reached
over TLS via `hanzoai/ingress`.
## 6. Output persistence **[specced]**
Push/pull both execute on the worker, which writes to *its own* output dir
(org-scoped via `folder_paths.set_execution_org`). For the coordinator UI to
show results, workers must ship artifacts to shared storage:
- On completion, upload outputs to `s3.lux.cloud` (hanzo bucket),
key `studio/{org_id}/{prompt_id}/{filename}`.
- Report the object URLs in the `result` message; the coordinator records them
in history so `/view` (or a signed redirect) serves them.
Until this lands, push-mode results remain on the worker and are addressable
only if the worker is reachable — acceptable for the in-cluster pool, blocking
for the NAT pull pool. This is the top build item after the pull channel.
The durable queue, engine selection, and dispatch are deliberately kept in a thin
Python layer at the `PromptQueue`/`prompt_router` seams so the backend can be
swapped without touching the render path. Two moves are planned: (a) a **remote
queue backend** — the same job state machine served by **Hanzo Tasks**
(`github.com/hanzoai/tasks`) over its HTTP surface, so multiple coordinators and
NAT'd `--worker`-style claimers share one durable queue instead of a local file;
and (b) folding that scheduling/queue layer into a **Go subsystem inside the
`hanzoai/cloud` unified binary** (HIP-0106), leaving Studio's Python as a pure
execution worker. The engine selector will likewise grow a third class,
**leased cloud machines** provisioned via the platform's Visor compute surface
(`GET {cloud}/v1/machines`) — the `/v1/engines` shape already accommodates the
extra entries; the client interface is stubbed in `engine_selector.py` and wired
when the console lands.
## 7. Joining a local box (GB10)
```bash
python main.py \
--worker-mode \
--coordinator-url https://studio.hanzo.ai \
--worker-id gb10-1
# env: STUDIO_ORG_ID=<org> STUDIO_WORKER_TOKEN=<from KMS>
```
Two paths today:
Today (push) this works only if the coordinator can reach the box (set
`WORKER_EXTERNAL_URL`). Once §2b lands, the box needs **no** inbound access: it
registers, opens the WSS channel, and pulls jobs for its org.
- **Shared queue** (durable): point the box at the same `STUDIO_QUEUE_DB` (shared
volume) as the coordinator; its prompt-executor thread claims jobs directly.
Exactly-once claim + crash-recovery hold across processes.
- **Push** (in-cluster, fast): `python main.py --worker-mode --coordinator-url … --worker-id gb10-1`
with `STUDIO_WORKER_TOKEN`; requires the coordinator to reach the box.
## 8. Build order
1. Pull WSS channel `/v1/workers/connect` + coordinator per-worker queue (§2b).
2. Artifact upload to `s3.lux.cloud` + result recording (§6).
3. IAM service-account worker identity replacing the shared secret (§5).
4. Fold `/compute/*` profile routes to `/v1/compute/*` (§3).
The remote-queue backend (§6) generalizes the shared-queue path to boxes that
cannot share a filesystem, outbound-only.
+5 -1
View File
@@ -149,7 +149,11 @@ class CacheSet:
}
return result
SENSITIVE_EXTRA_DATA_KEYS = ("auth_token_hanzo", "api_key_hanzo")
# Secrets that ride with a queued prompt but must never be persisted into history.
# `iam_token` is the requesting user's own verified IAM access token: the prompt
# worker records the finished render in the content lane as that user
# (middleware/content_publish.py).
SENSITIVE_EXTRA_DATA_KEYS = ("auth_token_hanzo", "api_key_hanzo", "iam_token")
def get_input_data(inputs, class_def, unique_id, execution_list=None, dynprompt=None, extra_data={}):
is_v3 = issubclass(class_def, _StudioNodeInternal)
+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
+2
View File
@@ -15,6 +15,8 @@ images:
test:
- name: unit
run: |
command -v uv >/dev/null || curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
uv venv --python 3.11
uv pip install -r requirements.txt pytest pytest-aiohttp pytest-asyncio
uv run pytest -q \
+31 -14
View File
@@ -1,3 +1,4 @@
import studio_compat # noqa: F401 aliases comfy.* -> studio.* for upstream custom nodes
import studio.options
studio.options.enable_args_parsing()
@@ -244,6 +245,7 @@ def prompt_worker(q, server_instance):
# Import metrics + billing for prompt tracking
from middleware import metrics_middleware
from middleware import billing_middleware
from middleware import content_publish
enable_billing = getattr(args, "enable_billing", False)
enable_metrics = getattr(args, "enable_metrics", False)
@@ -292,23 +294,38 @@ def prompt_worker(q, server_instance):
if enable_metrics:
metrics_middleware.record_prompt_end(execution_time, e.success)
# Record billing usage (fire-and-forget via event loop)
if enable_billing:
# Get user/org from extra_data if available
client_id = extra_data.get("client_id", "anonymous")
node_count = len(item[2]) if item[2] else 0
import asyncio
loop = server_instance.loop
# Billing: emit ONE metered usage event per completed render
# (fire-and-forget). org_id is the IAM tenant; prompt_id is the
# idempotency key; quantity is the number of artifacts produced.
# Never blocks or fails a render; no-op unless STUDIO_BILLING_* is set.
if enable_billing and e.success:
asyncio.run_coroutine_threadsafe(
billing_middleware.record_usage(
user=client_id,
org_id=os.environ.get("STUDIO_ORG_ID", "default"),
billing_middleware.record_render(
org_id=extra_data.get("org_id") or "default",
prompt_id=prompt_id,
execution_time=execution_time,
success=e.success,
node_count=node_count,
n_outputs=billing_middleware.count_outputs(e.history_result),
),
loop,
server_instance.loop,
)
# Content: record each output artifact as a draft Asset in the content
# lane (clients/content, module "marketing") — the SAME DocType that
# lane writes when it drives a render, so a studio-initiated render lands
# in the same place. Written as the requesting user via their IAM token
# from `sensitive`, so it lands in their org. Assets land draft; a human
# moves them through the lifecycle, and published is what a storefront
# shows. Fire-and-forget: a render is never failed if the lane is down.
if content_publish.enabled() and e.success:
asyncio.run_coroutine_threadsafe(
content_publish.publish_render(
org_id=extra_data.get("org_id") or "default",
prompt_id=prompt_id,
workflow=item[2],
history_result=e.history_result,
iam_token=extra_data.get("iam_token"),
extra_data=extra_data,
),
server_instance.loop,
)
# Log Time in a more readable way after 10 minutes
+76 -40
View File
@@ -1,14 +1,23 @@
"""
Commerce billing integration for Hanzo Studio.
Records prompt execution usage to the Commerce billing API.
Optionally checks balance before accepting expensive operations.
record_render emits ONE metered usage event per completed render to the
Commerce meter-events API. Fire-and-forget: a billing failure never blocks or
fails a render. Env-gated (all three required; absent locally = no-op):
Commerce API: POST /api/v1/billing/usage
Commerce API: GET /api/v1/billing/balance
STUDIO_BILLING_URL full URL of POST /v1/billing/meter-events
STUDIO_COMMERCE_TOKEN commerce admin bearer (KMS /studio commerce-token)
STUDIO_BILLING_METER id of the "studio render" meter (created in Commerce)
Commerce contract POST /v1/billing/meter-events:
{"events":[{"meterId","userId","value","idempotency","timestamp","dimensions"}]}
Pricing lives on the meter in Commerce; Studio only reports raw quantity.
check_balance pre-existing optional pre-flight balance gate.
"""
import logging
import os
from datetime import datetime, timezone
import aiohttp
@@ -35,49 +44,76 @@ async def _get_session() -> aiohttp.ClientSession:
return _shared_session
async def record_usage(
user: str,
org_id: str,
prompt_id: str,
execution_time: float,
success: bool,
node_count: int = 0,
) -> None:
"""
Record prompt execution usage to Commerce billing API.
Fire-and-forget errors are logged but do not block the response.
def _cfg() -> tuple[str, str, str] | None:
"""Return (url, token, meter_id) if render billing is fully configured, else None."""
url = os.environ.get("STUDIO_BILLING_URL", "").strip()
token = os.environ.get("STUDIO_COMMERCE_TOKEN", "").strip()
meter = os.environ.get("STUDIO_BILLING_METER", "").strip()
if url and token and meter:
return url, token, meter
return None
Pricing: 1 credit per prompt execution second (rounded up).
Minimum 1 credit per execution.
def count_outputs(history_result: dict | None) -> int:
"""Count media artifacts (images, gifs, audio, ...) a completed render produced."""
n = 0
for node_output in (history_result or {}).get("outputs", {}).values():
if isinstance(node_output, dict):
for v in node_output.values():
if isinstance(v, list):
n += len(v)
return n
async def record_render(org_id: str, prompt_id: str, n_outputs: int) -> None:
"""
if not COMMERCE_TOKEN:
Emit ONE metered usage event for a completed render to Commerce meter-events.
Fire-and-forget with a single retry (the idempotency key makes the retry safe
against double-counting). Never raises and never blocks: a billing failure
only logs, a render is never failed for it. No-op unless STUDIO_BILLING_URL,
STUDIO_COMMERCE_TOKEN and STUDIO_BILLING_METER are all set so local dev is
unaffected.
"""
cfg = _cfg()
if cfg is None:
return
url, token, meter = cfg
# Calculate cost: 1 cent per second of execution, min 1 cent
amount = max(1, int(execution_time + 0.5))
url = f"{COMMERCE_URL.rstrip('/')}/api/v1/billing/usage"
payload = {
"user": f"{org_id}/{user}" if org_id else user,
"currency": "usd",
"amount": amount,
"model": "studio",
"provider": "hanzo-studio",
"requestId": prompt_id,
"status": "success" if success else "error",
"promptTokens": node_count,
"completionTokens": 0,
"totalTokens": node_count,
"events": [
{
"meterId": meter,
"userId": org_id or "default",
"value": max(0, int(n_outputs)),
"idempotency": prompt_id,
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"dimensions": {"service": "studio"},
}
]
}
headers = {"Authorization": f"Bearer {token}"}
timeout = aiohttp.ClientTimeout(
total=float(os.environ.get("STUDIO_BILLING_TIMEOUT", "5"))
)
try:
session = await _get_session()
async with session.post(url, json=payload) as resp:
if resp.status not in (200, 201):
body = await resp.text()
logging.warning(f"Commerce billing error {resp.status}: {body}")
except Exception as e:
logging.warning(f"Commerce billing request failed: {e}")
for attempt in (1, 2):
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, json=payload, headers=headers) as resp:
if resp.status in (200, 201):
return
body = await resp.text()
logging.warning(
"billing: meter-events returned %s: %s", resp.status, body[:200]
)
if resp.status < 500:
return # deterministic client error — a retry won't help
except Exception as e: # timeout / connection reset — retry once, then drop
logging.warning(
"billing: render usage post failed (attempt %d/2): %s", attempt, e
)
# Both attempts failed. Drop it — a render is never failed for billing.
async def check_balance(user: str, org_id: str) -> tuple[bool, int]:
+4
View File
@@ -63,6 +63,7 @@ class ComputeProfile:
workers: list = field(default_factory=list)
auto_provision: bool = False
max_concurrent: int = 1
default_engine: str = "local" # engine id renders route to (see engine_selector)
def to_dict(self) -> dict:
return asdict(self)
@@ -139,6 +140,9 @@ def update_config(org_id: str, updates: dict) -> ComputeProfile:
mc = int(updates["max_concurrent"])
profile.max_concurrent = max(1, min(mc, 32))
if "default_engine" in updates:
profile.default_engine = str(updates["default_engine"])
# Derive gpu_enabled from profile if not explicitly set
if "active_profile" in updates and "gpu_enabled" not in updates:
profile.gpu_enabled = profile.active_profile in ("gpu-basic", "gpu-pro", "custom")
+189
View File
@@ -0,0 +1,189 @@
"""
Publish a completed Studio render into the Hanzo content lane.
THE one writer, and it writes NOTHING new. A finished render becomes an `Asset`
document the DocType hanzoai/cloud `clients/content` already declares (framework
module "marketing") and already writes itself when IT drives a render
(clients/content/studio_render.go). Studio-initiated renders were the only ones
with no path into that record; this closes exactly that gap and adds no second
schema, no second lifecycle, and no second storage plane.
POST /v1/framework/Asset (the already-live generic framework surface)
Same shape `clients/content` writes (studio_render.go `draftAsset`):
title, kind, design, prompt, file, generator, workflow,
source_prompt_id, render_params, project, tags
`file` is the object key under the org's studio output prefix —
``orgs/<org>/output/<subfolder>/<filename>`` byte-identical to the key
`persistOrLink` produces, and the same key Studio's own output mirror already
lands. So the bytes are ALREADY persisted by Studio: this module uploads nothing
and holds no storage credential. The record points at what exists.
Curation is the content lane's ONE state machine (clients/content/lifecycle.go):
draft in_review approved queued published
An asset lands in `draft` (the DocType's default) and a human moves it. Publishing
is what puts an image in front of customers, so an unattended render must never do
it. The storefront reads `published`.
Auth is the requesting user's OWN verified IAM access token, carried to the prompt
worker on the queue's existing `sensitive` channel (execution.SENSITIVE_EXTRA_DATA_KEYS)
so it is never persisted into history. The Asset is written AS that user, into that
user's org — the framework derives the tenant from the token's `owner` claim. No
service account, no impersonation, no cross-org write path.
Fire-and-forget: a publish failure only logs. A render is NEVER failed for it.
Env-gated; absent locally = no-op.
STUDIO_CLOUD_API_URL Hanzo Cloud base URL (default https://api.hanzo.ai)
STUDIO_CONTENT_PUBLISH "1"/"true" to enable
"""
import logging
import os
import posixpath
import aiohttp
CLOUD_URL = os.environ.get("STUDIO_CLOUD_API_URL", "https://api.hanzo.ai")
# One Asset row per artifact; small JSON writes.
_TIMEOUT = aiohttp.ClientTimeout(total=float(os.environ.get("STUDIO_CONTENT_TIMEOUT", "15")))
# clients/content declares Asset.kind as a Select; anything else would 422.
KINDS = ("ecom", "product", "lifestyle", "hover", "hero", "thumbnail")
DEFAULT_KIND = "product"
def enabled() -> bool:
"""True when render publishing is switched on. Absent locally = no-op."""
return os.environ.get("STUDIO_CONTENT_PUBLISH", "").strip().lower() in ("1", "true", "yes")
def artifacts(history_result: dict | None) -> list[dict]:
"""
The media artifacts a completed render produced, as Studio's own output refs
({filename, subfolder, type}). Mirrors billing_middleware.count_outputs the
same shape Studio already returns to the UI, so there is no second notion of
"what a render produced". Previews (type != "output") are not artifacts.
"""
out: list[dict] = []
for node_output in (history_result or {}).get("outputs", {}).values():
if not isinstance(node_output, dict):
continue
for values in node_output.values():
if not isinstance(values, list):
continue
for v in values:
if isinstance(v, dict) and v.get("filename") and v.get("type") == "output":
out.append(v)
return out
def object_key(org_id: str, ref: dict) -> str:
"""
The asset's key under the org's studio output prefix. Byte-identical to the key
clients/content builds in persistOrLink (orgs/<org>/output/...), which is also
where Studio's own output mirror lands — ONE key convention, so the record and
the bytes can never disagree.
"""
return posixpath.join(
"orgs", org_id, "output", (ref.get("subfolder") or "").strip("/"), ref["filename"]
).replace("//", "/")
def kind_of(extra_data: dict) -> str:
"""The Asset.kind Select value. Anything off-list would be refused by the engine."""
k = str(extra_data.get("kind") or "").strip().lower()
return k if k in KINDS else DEFAULT_KIND
def _model(workflow: dict | None) -> str:
"""Best-effort model name off the graph's first checkpoint/UNet loader."""
for node in (workflow or {}).values():
if not isinstance(node, dict):
continue
if "Loader" not in str(node.get("class_type", "")):
continue
for v in (node.get("inputs") or {}).values():
if isinstance(v, str) and v.endswith((".safetensors", ".ckpt", ".gguf", ".sft")):
return v
return ""
def asset_doc(*, org_id: str, prompt_id: str, workflow: dict, ref: dict, extra_data: dict) -> dict:
"""
One artifact as an Asset document the same field set clients/content writes.
`status` is omitted on purpose: the DocType defaults it to draft, and the
lifecycle hook owns every transition from there.
"""
model = _model(workflow)
return {
"title": ref["filename"],
"kind": kind_of(extra_data),
"design": str(extra_data.get("design") or "").strip(),
"role": str(extra_data.get("role") or "").strip(),
"prompt": str(extra_data.get("prompt") or "").strip(),
"file": object_key(org_id, ref),
"generator": model or "studio",
"workflow": model or "studio",
"source_prompt_id": prompt_id,
# The graph that reproduces this artifact — the reproducibility record.
"render_params": workflow or {},
"project": str(extra_data.get("project") or "").strip(),
"tags": str(extra_data.get("tags") or "").strip(),
}
async def publish_render(
*,
org_id: str,
prompt_id: str,
workflow: dict,
history_result: dict | None,
iam_token: str | None,
extra_data: dict | None = None,
) -> int:
"""
Publish a completed render's artifacts as draft Assets. Returns how many landed.
Never raises: the caller is the prompt worker, and a render must not fail
because the content lane was unreachable.
"""
if not enabled():
return 0
if not iam_token:
# No verified user principal on this execution. We refuse to invent one:
# writing via a service account would be a cross-org write path.
logging.warning("content_publish: no IAM token on prompt %s — not published", prompt_id)
return 0
refs = artifacts(history_result)
if not refs:
return 0
extra = extra_data or {}
url = f"{CLOUD_URL.rstrip('/')}/v1/framework/Asset"
headers = {"Authorization": f"Bearer {iam_token}"}
landed = 0
try:
async with aiohttp.ClientSession(timeout=_TIMEOUT) as session:
for ref in refs:
doc = asset_doc(
org_id=org_id, prompt_id=prompt_id, workflow=workflow,
ref=ref, extra_data=extra,
)
async with session.post(url, json=doc, headers=headers) as resp:
if resp.status in (200, 201):
landed += 1
else:
logging.warning(
"content_publish: POST /v1/framework/Asset -> %s: %s",
resp.status, (await resp.text())[:200],
)
except Exception as e: # unreachable cloud / timeout — a render is never failed for this
logging.warning("content_publish: prompt %s not published: %s", prompt_id, e)
return landed
+303
View File
@@ -0,0 +1,303 @@
"""
Chat copilot backend for Hanzo Studio.
POST /v1/copilot/chat turns a natural-language message about the workflow into
graph operations the FRONTEND applies against app.graph (LiteGraph). The backend
only decides ops it never touches the graph itself.
One tool-calling round trip:
request {messages, graph_json, catalog?}
-> proxy to an OpenAI-compatible chat endpoint with a system prompt that
describes the graph + the ops tool
-> {reply, ops} (add_node ops validated against the live node catalog
here so the model can't invent node types)
Env (all optional):
STUDIO_COPILOT_URL chat-completions URL. Default: the local engine at
http://127.0.0.1:1234/v1/chat/completions if that port
is open, else https://api.hanzo.ai/v1/chat/completions.
STUDIO_COPILOT_MODEL model id (default "zen").
STUDIO_COPILOT_TOKEN bearer for the gateway. Falls back to
STUDIO_COMMERCE_TOKEN, then any bearer on the request.
The op schema is small and explicit the frontend applier mirrors it exactly:
{op:"add_node", type, id?, pos?} id = a label you reuse to reference
this new node in later ops
{op:"set_widget", node_id, name, value}
{op:"connect", from_node, from_slot, to_node, to_slot} slots by name or index
{op:"move_node", node_id, pos:[x,y]}
{op:"delete_node", node_id}
{op:"set_prompt", node_id, text} sets a node's primary text widget
{op:"layout"} left-to-right columnar tidy
{op:"queue"} run the workflow
"""
from __future__ import annotations
import json
import logging
import os
import socket
from typing import Optional
import aiohttp
from aiohttp import web
logger = logging.getLogger(__name__)
# Ops the frontend applier understands, and their required fields. layout/queue
# take no args. slots default to 0 when omitted.
_OP_REQUIRED = {
"add_node": ("type",),
"set_widget": ("node_id", "name"),
"connect": ("from_node", "to_node"),
"move_node": ("node_id", "pos"),
"delete_node": ("node_id",),
"set_prompt": ("node_id", "text"),
"layout": (),
"queue": (),
}
_GATEWAY_URL = "https://api.hanzo.ai/v1/chat/completions"
_LOCAL_URL = "http://127.0.0.1:1234/v1/chat/completions"
# Single tool: the model calls it to both reply and edit the graph.
_EDIT_TOOL = {
"type": "function",
"function": {
"name": "edit_graph",
"description": (
"Reply to the user and, when they ask to change the workflow, return "
"the ordered graph operations to apply."
),
"parameters": {
"type": "object",
"properties": {
"reply": {
"type": "string",
"description": "Short natural-language reply describing what you did or answering the question.",
},
"ops": {
"type": "array",
"description": "Ordered graph operations. Empty when no change is needed.",
"items": {"type": "object"},
},
},
"required": ["reply", "ops"],
},
},
}
_SYSTEM = """You are the Hanzo Studio Copilot. Hanzo Studio is a node-graph image/video generator (a ComfyUI fork). You edit the user's live workflow graph by returning graph operations through the edit_graph tool.
Op schema (return ops in the order they should apply):
{"op":"add_node","type":"<NodeType>","id":"<label>","pos":[x,y]} id is a label you invent; reuse it in later ops to reference this new node
{"op":"set_widget","node_id":<id>,"name":"<widget>","value":<v>}
{"op":"connect","from_node":<id>,"from_slot":<name|index>,"to_node":<id>,"to_slot":<name|index>}
{"op":"set_prompt","node_id":<id>,"text":"<prompt text>"}
{"op":"move_node","node_id":<id>,"pos":[x,y]}
{"op":"delete_node","node_id":<id>}
{"op":"layout"} tidy the whole graph left-to-right
{"op":"queue"} run the workflow
Rules:
- Reference existing nodes by their numeric id from CURRENT GRAPH. Reference nodes you add by the id label you gave them in add_node.
- Prefer connecting slots by NAME (e.g. from_slot "IMAGE", to_slot "image"); the client resolves names to indices. Use index 0 if unsure.
- Only use node types that exist. Pick from NODE TYPES; if a type is not listed, do not invent it.
- Keep ops minimal do exactly what the user asked, nothing more.
- Always call edit_graph. If the user is only chatting/asking, reply with an empty ops array."""
def _default_url() -> str:
"""Local engine if its port is open, else the Hanzo gateway. Probed once at import."""
try:
with socket.create_connection(("127.0.0.1", 1234), timeout=0.2):
return _LOCAL_URL
except OSError:
return _GATEWAY_URL
_DEFAULT_URL = _default_url()
_session: Optional[aiohttp.ClientSession] = None
async def _get_session() -> aiohttp.ClientSession:
global _session
if _session is None or _session.closed:
_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=90))
return _session
async def close_session() -> None:
global _session
if _session and not _session.closed:
await _session.close()
_session = None
class CopilotError(Exception):
"""Upstream LLM call failed."""
def _resolve_target(request) -> tuple[str, str, str]:
"""(url, model, token) for this request."""
url = os.environ.get("STUDIO_COPILOT_URL", "").strip() or _DEFAULT_URL
model = os.environ.get("STUDIO_COPILOT_MODEL", "").strip() or "zen"
token = (
os.environ.get("STUDIO_COPILOT_TOKEN", "").strip()
or os.environ.get("STUDIO_COMMERCE_TOKEN", "").strip()
)
if not token:
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
token = auth[7:]
return url, model, token
def validate_ops(ops, valid_types: set) -> tuple[list, list]:
"""Drop malformed ops and add_node ops naming unknown node types.
Returns (clean_ops, warnings). The frontend guards again, but validating
node types here is the one place the model is prevented from inventing nodes.
"""
clean, warnings = [], []
if not isinstance(ops, list):
return clean, warnings
for op in ops:
if not isinstance(op, dict):
continue
name = op.get("op")
if name not in _OP_REQUIRED:
warnings.append(f"unknown op {name!r}")
continue
missing = [f for f in _OP_REQUIRED[name] if op.get(f) in (None, "")]
if missing:
warnings.append(f"{name} missing {', '.join(missing)}")
continue
if name == "add_node" and op["type"] not in valid_types:
warnings.append(f"unknown node type {op['type']!r}")
continue
clean.append(op)
return clean, warnings
def _build_messages(body: dict) -> list:
graph_json = body.get("graph_json")
catalog = body.get("catalog") or {}
history = body.get("messages") or []
types = catalog.get("types") or []
detail = catalog.get("nodes") or {}
ctx = (
_SYSTEM
+ "\n\nNODE TYPES (you may only add these):\n"
+ ", ".join(sorted(types)[:1200])
+ "\n\nSLOT DETAIL for node types currently in the graph:\n"
+ json.dumps(detail)[:12000]
+ "\n\nCURRENT GRAPH (node id -> {class_type, inputs}):\n"
+ json.dumps(graph_json)[:24000]
)
msgs = [{"role": "system", "content": ctx}]
# Only user/assistant turns from the client; cap history.
for m in history[-20:]:
if isinstance(m, dict) and m.get("role") in ("user", "assistant") and m.get("content"):
msgs.append({"role": m["role"], "content": str(m["content"])})
return msgs
def _parse_completion(data: dict) -> tuple[str, list]:
"""Pull (reply, ops) from an OpenAI-style completion, tolerating either a
tool_call or a JSON/plain-text content reply."""
try:
msg = data["choices"][0]["message"]
except (KeyError, IndexError, TypeError):
return "", []
for call in msg.get("tool_calls") or []:
fn = call.get("function") or {}
if fn.get("name") != "edit_graph":
continue
try:
args = json.loads(fn.get("arguments") or "{}")
except (json.JSONDecodeError, TypeError):
continue
return str(args.get("reply", "")), args.get("ops") or []
content = msg.get("content") or ""
if isinstance(content, list): # some gateways return content parts
content = "".join(p.get("text", "") for p in content if isinstance(p, dict))
content = content.strip()
# Fallback: a bare JSON object embedded in the prose.
start, end = content.find("{"), content.rfind("}")
if start != -1 and end > start:
try:
obj = json.loads(content[start : end + 1])
if isinstance(obj, dict) and "ops" in obj:
return str(obj.get("reply", content)), obj.get("ops") or []
except json.JSONDecodeError:
pass
return content, []
async def _post_chat(url: str, token: str, payload: dict) -> dict:
"""POST an OpenAI-compatible chat completion. Raises CopilotError on failure."""
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
try:
session = await _get_session()
async with session.post(url, json=payload, headers=headers) as resp:
text = await resp.text()
if resp.status != 200:
raise CopilotError(f"LLM {resp.status}: {text[:300]}")
try:
return json.loads(text)
except json.JSONDecodeError as e:
raise CopilotError(f"bad LLM JSON: {e}") from e
except aiohttp.ClientError as e:
raise CopilotError(f"LLM unreachable: {e}") from e
async def run_copilot(body: dict, valid_types: set, *, url: str, model: str, token: str) -> dict:
"""One tool-calling round trip. Returns {reply, ops} with ops validated."""
payload = {
"model": model,
"messages": _build_messages(body),
"tools": [_EDIT_TOOL],
"tool_choice": "auto",
"temperature": 0.2,
}
data = await _post_chat(url, token, payload)
reply, ops = _parse_completion(data)
clean, warnings = validate_ops(ops, valid_types)
if warnings:
note = "Skipped: " + "; ".join(warnings)
reply = f"{reply}\n\n{note}" if reply else note
return {"reply": reply, "ops": clean}
def add_copilot_routes(routes: web.RouteTableDef, server) -> None:
"""Register POST /v1/copilot/chat."""
@routes.post("/v1/copilot/chat")
async def copilot_chat(request):
try:
body = await request.json()
except Exception:
return web.json_response({"error": "invalid JSON"}, status=400)
if not isinstance(body, dict) or not body.get("messages"):
return web.json_response({"error": "messages is required"}, status=400)
import nodes # loaded by the time routes are added; kept out of module import for light tests
valid_types = set(nodes.NODE_CLASS_MAPPINGS.keys())
url, model, token = _resolve_target(request)
try:
result = await run_copilot(body, valid_types, url=url, model=model, token=token)
except CopilotError as e:
logger.warning("copilot: %s", e)
return web.json_response({"error": str(e), "reply": "", "ops": []}, status=502)
return web.json_response(result)
+100
View File
@@ -0,0 +1,100 @@
"""
Engine selector for Hanzo Studio.
Lists the execution targets ("engines") available to an org and records the
org's default. Thin wrapper over the existing per-org compute registry
(middleware/compute_config.py): an engine is either
- `local` this Studio instance, always present, and
- a registered org GPU worker (from compute.json's worker registry).
Selecting an engine routes prompts to it; prompt_router.py is the existing
routing seam and already forwards to a worker by URL, so the default engine
just names which target a prompt without an explicit device_preference lands on.
Leased cloud machines (via the platform's Visor compute surface,
`GET {cloud}/v1/machines`) are a future engine class the client interface is
noted below but not built this iteration; the API shape here already
accommodates the extra entries.
"""
from __future__ import annotations
import logging
from aiohttp import web
from middleware import compute_config
logger = logging.getLogger(__name__)
# Future: leased-machine engines. A VisorMachinesClient would implement
# list_machines(org_id) -> list[dict] hitting GET {STUDIO_CLOUD_API_URL}/v1/machines
# and its rows would be appended to list_engines() as {"type": "leased", ...}.
# Console wiring + the client land in a later iteration; not built now.
def list_engines(org_id: str) -> dict:
"""Available engines for org_id plus the current default."""
profile = compute_config.load_config(org_id)
engines = [{
"id": "local",
"type": "local",
"name": "This instance",
"status": "ready",
"device": "local",
}]
for w in profile.workers:
engines.append({
"id": w.worker_id,
"type": "worker",
"name": w.gpu_model or w.worker_id,
"status": "ready" if (w.status == "ready" and w.is_alive()) else "offline",
"device": w.device,
"vram_gb": w.vram_gb,
"url": w.url,
})
default = profile.default_engine or "local"
if default != "local" and not any(e["id"] == default for e in engines):
# The default engine deregistered — fall back to local, don't 500.
default = "local"
return {"engines": engines, "default": default}
def resolve_engine_worker(org_id: str):
"""If the org's default engine is a live worker, return it; else None (local)."""
profile = compute_config.load_config(org_id)
default = profile.default_engine or "local"
if default == "local":
return None
for w in profile.workers:
if w.worker_id == default and w.is_alive():
return w
return None
def add_engine_routes(routes: web.RouteTableDef, server):
"""Register /v1/engines on the coordinator."""
@routes.get("/v1/engines")
async def get_engines(request):
org_id = server._get_org_id(request)
return web.json_response(list_engines(org_id))
@routes.put("/v1/engines/default")
async def set_default_engine(request):
org_id = server._get_org_id(request)
try:
body = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON"}, status=400)
engine_id = body.get("engine")
if not engine_id:
return web.json_response({"error": "engine is required"}, status=400)
valid = {e["id"] for e in list_engines(org_id)["engines"]}
if engine_id not in valid:
return web.json_response(
{"error": f"unknown engine {engine_id!r}", "engines": sorted(valid)}, status=400
)
compute_config.update_config(org_id, {"default_engine": engine_id})
return web.json_response(list_engines(org_id))
+197
View File
@@ -0,0 +1,197 @@
"""Dispatch a render to the org's connected GPU node via the cloud gpu-jobs queue.
When the caller's IAM org has a live compute worker (a box running `hanzo gpu
connect` / hanzod, registered in the cloud fleet), the render runs THERE instead
of in this GPU-less pod. The user's own IAM bearer token is forwarded, so the
cloud derives org + project + linked billing account from it one auth path, no
shared keys. Falls back to the in-pod queue when there is no live worker.
"""
import base64
import json
import os
import urllib.request
import folder_paths
CLOUD = os.environ.get("STUDIO_CLOUD_API_URL", "https://api.hanzo.ai").rstrip("/")
JOBS_NS = os.environ.get("STUDIO_GPU_JOBS_NS", "gpu-jobs")
# Public base of THIS studio deployment — where the BYO-GPU worker returns finished
# outputs (POST /upload/output → orgs/{org}/output → gallery). The worker uploads
# here with the user's IAM token; no S3/rclone credentials ever touch the box.
STUDIO_PUBLIC_URL = os.environ.get("STUDIO_PUBLIC_URL", "https://studio.hanzo.ai").rstrip("/")
# Node classes whose `image`/`mask` widget names a file in the org input dir. The
# render runs on the worker's LOCAL disk, so any file a user UPLOADED here (it lands
# in orgs/{org}/input, which the worker cannot read) must travel WITH the job.
_IMAGE_INPUT_CLASSES = ("LoadImage", "LoadImageMask", "LoadImageOutput")
# Cap the inlined payload so a pathological graph can't blow the tasks-API body.
_MAX_INLINE_BYTES = 48 * 1024 * 1024
def _collect_input_images(prompt: dict, org_id: str) -> list[dict]:
"""Read every LoadImage-referenced file that exists under this org's input
directory and return them base64-encoded for transport to the worker. Files
already resident on the worker (e.g. staged garment refs) are harmless to
resend the worker overwrites byte-identical copies. Best-effort: an absent
or oversized file is skipped, never fatal."""
in_dir = folder_paths.get_org_input_directory(org_id)
seen: set[str] = set()
out: list[dict] = []
total = 0
for node in prompt.values():
if not isinstance(node, dict) or node.get("class_type") not in _IMAGE_INPUT_CLASSES:
continue
name = (node.get("inputs") or {}).get("image")
if not isinstance(name, str) or name in seen:
continue
seen.add(name)
# Honor a "sub/dir/file.png" reference; reject traversal.
rel = os.path.normpath(name)
if rel.startswith("..") or os.path.isabs(rel):
continue
path = os.path.join(in_dir, rel)
try:
if not os.path.isfile(path):
continue
size = os.path.getsize(path)
if size == 0 or total + size > _MAX_INLINE_BYTES:
continue
with open(path, "rb") as f:
data = f.read()
except OSError:
continue
total += len(data)
sub, base = os.path.split(rel)
out.append({"name": base, "subfolder": sub, "data": base64.b64encode(data).decode()})
return out
def _bearer(request) -> str:
"""The caller's IAM token, exactly as iam_auth_middleware accepts it:
Authorization header first, then the browser session cookies."""
h = request.headers.get("Authorization", "")
if h.lower().startswith("bearer "):
return h
tok = request.cookies.get("hanzo_token") or request.cookies.get("access_token")
return f"Bearer {tok}" if tok else ""
# 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())
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:
"""If the caller's org has an online GPU node in the fleet, enqueue a
studio.render job to gpu-jobs and return True. Return False to run in-pod.
Never raises any failure falls back to local."""
try:
tok = _bearer(request)
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,
# 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,
"uploadUrl": STUDIO_PUBLIC_URL,
# Uploaded inputs live in orgs/{org}/input on THIS pod; the worker
# renders on its own disk and cannot read them — ship them along so
# LoadImage resolves there.
"inputs": _collect_input_images(prompt, org_id),
},
}).encode()
req = urllib.request.Request(
f"{CLOUD}/v1/tasks/namespaces/{JOBS_NS}/activities",
data=body,
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
+95 -9
View File
@@ -45,9 +45,11 @@ PUBLIC_PATH_PREFIXES = (
PUBLIC_PATHS_EXACT = {
"/",
"/callback",
"/login",
"/health",
"/ready",
"/metrics",
"/logout",
"/api/health",
"/api/ready",
"/api/features",
@@ -65,6 +67,7 @@ PUBLIC_PATHS_EXACT = {
_OIDC_SCOPES = "openid profile email"
_STATE_COOKIE = "studio_oauth"
_SESSION_COOKIE = "hanzo_token"
_ACTIVE_ORG_COOKIE = "studio_active_org" # mirrors middleware.session.ACTIVE_ORG_COOKIE
_STATE_TTL = 600 # seconds a pending login may take
# Decoded-claims cache: hash(token) -> (user_info, expires_at_monotonic)
@@ -169,20 +172,61 @@ def _verify_state(blob: str, secret: str) -> dict | None:
_ALGS = ["RS256", "RS512", "ES256", "ES384", "ES512"]
def _orgs_from_claims(claims: dict) -> list[str]:
"""Every org the token authorizes, home org first. IAM may carry the set as
``organizations`` / ``orgs`` / ``groups`` (list or comma string); the home
org (``owner`` / ``organization``) is always included so the switcher has at
least one real entry."""
home = claims.get("owner") or claims.get("organization") or "default"
out = [home]
for key in ("organizations", "orgs", "groups"):
val = claims.get(key)
if isinstance(val, str):
val = [v.strip() for v in val.split(",") if v.strip()]
if isinstance(val, list):
for v in val:
v = v.get("name") if isinstance(v, dict) else v
if isinstance(v, str) and v and v not in out:
out.append(v)
return out
def _claims_to_user(claims: dict) -> dict:
orgs = _orgs_from_claims(claims)
return {
"sub": claims.get("sub", ""),
"name": claims.get("name") or claims.get("preferred_username", ""),
"email": claims.get("email", ""),
"org_id": claims.get("owner") or claims.get("organization") or "default",
"org_id": orgs[0],
"orgs": orgs,
"avatar": claims.get("picture", ""),
}
def verify_jwt(token: str, key, issuer: str, audience: str | None) -> dict | None:
def _with_active_org(user: dict, active_org_cookie: str | None) -> dict:
"""Return a shallow copy of ``user`` whose ``org_id`` reflects a validated
active-org selection. A copy (never a mutation) keeps the token cache from
being poisoned across requests that carry different selection cookies."""
from middleware.session import resolve_org
orgs = user.get("orgs") or [user.get("org_id", "default")]
resolved = resolve_org(user.get("org_id", "default"), orgs, active_org_cookie)
if resolved == user.get("org_id"):
return user
return {**user, "org_id": resolved}
def verify_jwt(token: str, key, issuer: str, audience: "str | list[str] | None") -> dict | None:
"""
Pure JWT verification: signature (via the resolved JWKS key), exp, iss and
aud. Returns normalized user info or None if the token is invalid. No I/O.
``audience`` may be a single string or a list of accepted audiences. A list
passes if the token's ``aud`` intersects it — this is how a first-party
Hanzo user token (minted for e.g. hanzo-console / hanzo-desktop) is accepted
by studio without being re-minted specifically for hanzo-studio. Org is
always derived from the ``owner``/``groups`` claims, independent of aud, so
widening the accepted audience set to the trusted first-party clients does
not change who can act as which org.
"""
import jwt
try:
@@ -211,6 +255,16 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
iam_url = iam_url.rstrip("/")
client_id = os.environ.get("STUDIO_IAM_CLIENT_ID", "hanzo-studio")
issuer = os.environ.get("STUDIO_IAM_ISSUER", iam_url)
# Accept the studio client_id plus any other first-party audiences (comma-
# separated STUDIO_IAM_ALLOWED_AUDIENCES). Lets a Hanzo user token minted for
# the console / desktop / CLI authorize an org-scoped upload here without a
# per-service re-mint. client_id is always included.
_extra_aud = [
a.strip()
for a in os.environ.get("STUDIO_IAM_ALLOWED_AUDIENCES", "").split(",")
if a.strip()
]
allowed_audiences = list(dict.fromkeys([client_id, *_extra_aud]))
jwks_uri = f"{iam_url}/v1/iam/.well-known/jwks"
jwks = _JwksCache(jwks_uri)
@@ -237,7 +291,7 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
if key is None:
return None
user = verify_jwt(token, key, issuer, client_id)
user = verify_jwt(token, key, issuer, allowed_audiences)
if user is None:
_token_cache.pop(ck, None)
return None
@@ -250,7 +304,7 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
_token_cache.pop(k, None)
return user
def _authorize_redirect(request: web.Request) -> web.Response:
def _authorize_redirect(request: web.Request, target: str | None = None) -> web.Response:
base = _external_base(request)
verifier = _b64url(secrets.token_bytes(32))
challenge = _b64url(hashlib.sha256(verifier.encode()).digest())
@@ -258,7 +312,7 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
state_payload = {
"n": nonce,
"v": verifier,
"t": str(request.rel_url),
"t": target or str(request.rel_url),
"exp": int(_time.time()) + _STATE_TTL,
}
state = _sign_state(state_payload, client_id) # HMAC key; secret optional
@@ -278,6 +332,29 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
)
return resp
async def handle_login(request: web.Request) -> web.Response:
"""Begin sign-in: the ONE server-owned entry point the login page links to.
The SPA's "Sign in with Hanzo" button targets this route so the OIDC
Authorization Code flow (PKCE + signed state + ``/callback``) is built
server-side there is exactly one way to start a login, and the client
never hand-rolls an authorize URL. Already-authenticated callers skip
straight to the app; everyone else is bounced to IAM and returns to ``/``.
"""
token = None
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
if not token:
token = request.cookies.get(_SESSION_COOKIE) or request.cookies.get("access_token")
if token:
try:
if await _validate(token):
return web.HTTPFound("/")
except Exception:
pass # IAM/JWKS hiccup — fall through and re-authenticate cleanly.
return _authorize_redirect(request, target="/")
async def handle_callback(request: web.Request) -> web.Response:
"""Exchange the authorization code for a token and set the session cookie."""
code = request.query.get("code")
@@ -334,10 +411,12 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
return await handler(request)
if localhost_bypass and _is_loopback_request(request):
request["iam_user"] = {
local_org = os.environ.get("STUDIO_ORG_ID", "default")
local_user = {
"sub": "local", "name": "Local User", "email": "",
"org_id": os.environ.get("STUDIO_ORG_ID", "default"), "avatar": "",
"org_id": local_org, "orgs": [local_org], "avatar": "",
}
request["iam_user"] = _with_active_org(local_user, request.cookies.get(_ACTIVE_ORG_COOKIE))
return await handler(request)
token = None
@@ -366,9 +445,16 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
return _authorize_redirect(request)
return web.json_response({"error": "Invalid or expired token"}, status=401)
request["iam_user"] = user
request["iam_user"] = _with_active_org(user, request.cookies.get(_ACTIVE_ORG_COOKIE))
# The verified raw access token. Studio records a completed render in the
# content lane AS THE REQUESTING USER (middleware/content_publish.py), so it
# lands in that user's org with that user's rights — no service account, no
# impersonation. Carried to the worker over the queue's `sensitive` channel,
# so it is never persisted into history.
request["iam_token"] = token
return await handler(request)
# Expose the callback handler so server.py can register the /callback route.
# Expose the login + callback handlers so server.py can register the routes.
iam_auth_middleware.handle_login = handle_login
iam_auth_middleware.handle_callback = handle_callback
return iam_auth_middleware
+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)
+11
View File
@@ -59,6 +59,17 @@ async def route_prompt(
if device_pref == "cpu":
return None
# Engine selector: if the org picked a specific worker as its default
# engine, route there. `local` (the default) leaves current behavior
# unchanged. See middleware/engine_selector.py.
from middleware.engine_selector import resolve_engine_worker
engine_worker = resolve_engine_worker(org_id)
if engine_worker is not None:
result = await forward_to_worker(engine_worker, json_data)
if result is not None:
return {"action": "forward", "worker": engine_worker, "response": result}
logger.warning("Default engine %s failed, falling through", engine_worker.worker_id)
config = load_config(org_id)
# No GPU enabled in profile — execute locally
+176
View File
@@ -0,0 +1,176 @@
"""Identity / session API for the Hanzo Studio user menu.
Thin ``/v1`` endpoints over the IAM-validated context the auth middleware
already produces (``request["iam_user"]``). One module owns the session shape
the frontend menu reads and the active-org / active-project selection; the auth
middleware owns token validation and imports :func:`resolve_org` so the switched
org drives the *same* ``iam_user["org_id"]`` the tenancy / output paths use.
GET /v1/session -> {user,email,org,orgs[],project,projects[],...}
POST /v1/session/org -> set active org (must be one the user belongs to)
POST /v1/session/project -> set active project (a workflow-workspace folder)
GET /logout -> clear the session + active-org/project cookies,
bounce to the IAM end-session then back to /login
Works with auth on (real IAM user) or off (local single-user): with auth off the
request carries no ``iam_user`` so the local user is synthesized from env +
cookies, and the menu still renders and switches for local development.
"""
import os
from urllib.parse import urlencode
from aiohttp import web
import folder_paths
# Cookies. The session (auth) cookie name mirrors the auth middleware.
SESSION_COOKIE = "hanzo_token"
STATE_COOKIE = "studio_oauth"
ACTIVE_ORG_COOKIE = "studio_active_org"
ACTIVE_PROJECT_COOKIE = "studio_active_project"
DEFAULT_PROJECT = "default"
def _cfg(attr: str, env: str, default: str) -> str:
"""One config read: the CLI arg (which itself defaults to the env var) wins,
with a plain env / literal fallback so unit tests need no arg parsing."""
try:
from studio.cli_args import args
val = getattr(args, attr, None)
if val:
return val
except Exception:
pass
return os.environ.get(env, default)
def resolve_org(base_org: str, orgs: list[str], cookie_val: str | None) -> str:
"""Return the effective org: the cookie-selected one iff the user belongs to
it, else the token's own org. This is the *single* rule the auth middleware
and the session API both apply, so a switch can never escape membership."""
if cookie_val and cookie_val in orgs:
return cookie_val
return base_org
def _external_base(request: web.Request) -> str:
proto = request.headers.get("X-Forwarded-Proto", request.scheme)
host = request.headers.get("X-Forwarded-Host", request.host)
return f"{proto}://{host}"
def _local_user() -> dict:
org = os.environ.get("STUDIO_ORG_ID") or "default"
return {
"sub": "local", "name": "Local User", "email": "",
"org_id": org, "avatar": "", "orgs": [org],
}
def _projects(org_id: str) -> list[str]:
"""Projects = the workflow-workspace subfolders under the org's user dir
(user/[orgs/<org>/]user/<uid>/workflows/<project>). Best-effort; the default
project is always present."""
names = {DEFAULT_PROJECT}
try:
base = folder_paths.get_org_user_directory(org_id)
for uid in os.listdir(base):
wf = os.path.join(base, uid, "workflows")
if not os.path.isdir(wf):
continue
for p in os.listdir(wf):
if os.path.isdir(os.path.join(wf, p)):
names.add(p)
except (OSError, FileNotFoundError):
pass
return sorted(names)
def _session(request: web.Request) -> dict:
user = request.get("iam_user") or _local_user()
orgs = user.get("orgs") or [user.get("org_id", "default")]
# When auth is on the middleware already applied the override; applying the
# same validated rule here is idempotent and covers the auth-off local path.
org = resolve_org(user.get("org_id", "default"), orgs, request.cookies.get(ACTIVE_ORG_COOKIE))
project = request.cookies.get(ACTIVE_PROJECT_COOKIE) or DEFAULT_PROJECT
return {
"authenticated": bool(request.get("iam_user")),
"sub": user.get("sub", ""),
"user": user.get("name") or user.get("email") or "User",
"email": user.get("email", ""),
"avatar": user.get("avatar", ""),
"org": org,
"orgs": orgs,
"project": project,
"projects": _projects(org),
"iam_url": _cfg("iam_url", "STUDIO_IAM_URL", "https://hanzo.id"),
"console_url": _cfg("console_url", "STUDIO_CONSOLE_URL", "https://console.hanzo.ai"),
}
def _is_https(request: web.Request) -> bool:
return _external_base(request).startswith("https")
def add_session_routes(routes: web.RouteTableDef, server) -> None:
"""Register the session / identity endpoints on the app route table."""
iam_url = _cfg("iam_url", "STUDIO_IAM_URL", "https://hanzo.id").rstrip("/")
@routes.get("/v1/session")
async def get_session(request):
return web.json_response(_session(request))
@routes.post("/v1/session/org")
async def set_org(request):
try:
body = await request.json()
except Exception:
return web.json_response({"error": "invalid JSON"}, status=400)
org = (body or {}).get("org")
if not org or not isinstance(org, str):
return web.json_response({"error": "org is required"}, status=400)
user = request.get("iam_user") or _local_user()
orgs = user.get("orgs") or [user.get("org_id", "default")]
if org not in orgs:
return web.json_response({"error": "not a member of that org", "orgs": orgs}, status=403)
resp = web.json_response({"ok": True, "org": org})
resp.set_cookie(
ACTIVE_ORG_COOKIE, org, max_age=31536000,
httponly=False, secure=_is_https(request), samesite="Lax", path="/",
)
# Switching org resets the project scope to the org's default.
resp.del_cookie(ACTIVE_PROJECT_COOKIE, path="/")
return resp
@routes.post("/v1/session/project")
async def set_project(request):
try:
body = await request.json()
except Exception:
return web.json_response({"error": "invalid JSON"}, status=400)
project = (body or {}).get("project")
if not project or not isinstance(project, str) or "/" in project or "\\" in project:
return web.json_response({"error": "valid project is required"}, status=400)
resp = web.json_response({"ok": True, "project": project})
resp.set_cookie(
ACTIVE_PROJECT_COOKIE, project, max_age=31536000,
httponly=False, secure=_is_https(request), samesite="Lax", path="/",
)
return resp
async def logout(request):
"""Clear the local session + selection cookies and bounce to the IAM
end-session endpoint (so SSO is really dropped, not silently re-issued),
which returns the browser to ``/login`` for a fresh sign-in."""
base = _external_base(request)
logout_url = os.environ.get("STUDIO_IAM_LOGOUT_URL", f"{iam_url}/v1/iam/oauth/logout")
target = f"{logout_url}?{urlencode({'post_logout_redirect_uri': base + '/login', 'redirect_uri': base + '/login'})}"
resp = web.HTTPFound(target)
for name in (SESSION_COOKIE, "access_token", ACTIVE_ORG_COOKIE, ACTIVE_PROJECT_COOKIE, STATE_COOKIE):
resp.del_cookie(name, path="/")
return resp
routes.get("/logout")(logout)
routes.post("/logout")(logout)
+852
View File
@@ -0,0 +1,852 @@
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Hanzo Studio</title>
<style>
: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.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}
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(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;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:.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:.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,.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}
#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>
<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>
<div class="user" id="user" title="Account"><span class="av" id="av">·</span><span class="nm" id="org"></span></div>
</header>
<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>
<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 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>
// ── 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'),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 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>
<div class="row"><span class="st ${a.status}">${a.status}</span></div>
${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!=='published'?`<button class="mini" onclick="setSt(${i},'published')">Publish</button>`:''}
${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>`;
}
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(){
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 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 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();
}
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';}
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>
+966
View File
@@ -0,0 +1,966 @@
"""Studio Home — the content-forward default view (flow-style), with the node
editor demoted to "Advanced mode".
The normal user works per DESIGN, not per node graph: see the assets a pipeline
produced, judge them (status chips make blotchy rejects vs approved picks
obvious), re-run or chat-fix a given output, and step into the graph editor only
when they mean to. This module adds that surface without inventing any new
storage or lifecycle:
GET /studio the home page (auth-gated like everything else)
GET /v1/library org's library.json + per-asset workflow provenance
POST /v1/library/status {path, status} draft|approved|queued|published
POST /v1/library/rerun {path, count?} re-queue the EXACT embedded graph, new seed
POST /v1/library/fix {path, instruction} chat-fix: Qwen edit pass on that output
POST /v1/library/compose {paths[], uploads[]?, instruction} multi-reference render
(library assets and/or images uploaded via /upload/image)
One source of truth per concern, all pre-existing:
* assets + curation state -> orgs/<org>/output/library.json (library_manifest.py)
* what made an image -> the API graph ComfyUI embeds in the PNG itself
* running work -> the ONE /prompt path (self-POST, so gpu_dispatch,
billing and content_publish all apply unchanged)
* identity -> the caller's own session; headers are forwarded
verbatim on the self-POST, no token is stored
* workflow editing -> the 0.15.1 deep-link opener (?workflow=&run=1)
Env:
STUDIO_HOME_DEFAULT "1"/"true": browser GET / redirects to /studio
(the SPA stays reachable at /?advanced=1 and all
non-root paths). Off by default so local dev and
worker pods are untouched.
"""
from __future__ import annotations
import json
import logging
import os
import random
import re
import time
from pathlib import Path
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", "deleted")
_PROV_CACHE: dict[str, tuple[float, dict]] = {} # abspath -> (mtime, provenance)
_NEG = ("ghosting, double exposure, duplicated body, transparent fabric, sheer "
"fabric, illustration, painterly, blotchy skin, mottled skin, jpeg "
"artifacts, pixelation, noise, deformed hands, extra limbs, text, watermark")
def _home_default() -> bool:
return os.environ.get("STUDIO_HOME_DEFAULT", "").lower() in ("1", "true", "yes")
def _org_of(request: web.Request) -> str:
user = request.get("iam_user") or {}
return user.get("org_id") or os.environ.get("STUDIO_ORG_ID") or "default"
def _library_root(org: str) -> Path | None:
"""The org's asset tree. Pod layout first (orgs/<org>/output via
folder_paths), then the flat-dev staging layout (output/orgs/<org>/output)."""
for cand in (
Path(folder_paths.get_org_output_directory(org)),
Path(folder_paths.get_output_directory()) / "orgs" / org / "output",
):
if (cand / "library.json").is_file():
return cand
return None
def _view_params(abs_path: Path) -> dict:
"""Query params for the existing org-scoped /view endpoint."""
out = Path(folder_paths.get_output_directory())
try:
rel = abs_path.relative_to(out)
except ValueError:
# org-scoped dir outside the flat output dir (pod layout): /view resolves
# the org dir itself in multi-tenant mode, so make the path org-relative.
for org_base in abs_path.parents:
if org_base.name == "output" and org_base.parent.parent.name == "orgs":
rel = abs_path.relative_to(org_base)
break
else:
rel = Path(abs_path.name)
return {"filename": rel.name, "subfolder": str(rel.parent) if str(rel.parent) != "." else "", "type": "output"}
def _provenance(abs_path: Path) -> dict:
"""What made this image, from the graph ComfyUI embeds in the PNG."""
try:
mtime = abs_path.stat().st_mtime
except OSError:
return {}
key = str(abs_path)
hit = _PROV_CACHE.get(key)
if hit and hit[0] == mtime:
return hit[1]
prov: dict = {}
if abs_path.suffix.lower() == ".png":
try:
from PIL import Image
info = Image.open(abs_path).info
graph = json.loads(info.get("prompt", "null"))
if isinstance(graph, dict):
for node in graph.values():
ct = node.get("class_type", "")
ins = node.get("inputs", {})
if ct == "SaveImage":
prov["workflow"] = ins.get("filename_prefix", "")
elif "KSampler" in ct:
prov.update(steps=ins.get("steps"), cfg=ins.get("cfg"),
seed=ins.get("seed", ins.get("noise_seed")))
prov["nodes"] = len(graph)
except Exception: # non-Comfy PNG, truncated file — provenance is optional
prov = {}
_PROV_CACHE[key] = (mtime, prov)
return prov
def _load_library(root: Path) -> dict:
try:
return json.loads((root / "library.json").read_text())
except Exception:
return {"assets": []}
def _save_library(root: Path, lib: dict) -> None:
tmp = root / "library.json.tmp"
tmp.write_text(json.dumps(lib, indent=1, sort_keys=False))
tmp.replace(root / "library.json")
def _safe_asset(root: Path, rel: str) -> Path | None:
p = (root / rel).resolve()
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", {})
for k in ("seed", "noise_seed"):
if isinstance(ins.get(k), (int, float)):
ins[k] = random.randrange(2**31)
return graph
def _fix_graph(ref_name: str, instruction: str, prefix: str) -> dict:
"""The proven Qwen-Image-Edit-2511 wiring, instruction-edit form: the chosen
output is the sole reference; the user's words are the edit."""
prompt = (f"{instruction.strip()}. Photorealistic, keep the same model, "
f"pose, framing, lighting and background; only apply the "
f"requested change.")
return {
"1": {"class_type": "LoadImage", "inputs": {"image": ref_name}},
"2": {"class_type": "FluxKontextImageScale", "inputs": {"image": ["1", 0]}},
"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"}},
"6": {"class_type": "TextEncodeQwenImageEditPlus", "inputs": {"clip": ["3", 0], "vae": ["5", 0], "image1": ["2", 0], "prompt": prompt}},
"7": {"class_type": "TextEncodeQwenImageEditPlus", "inputs": {"clip": ["3", 0], "prompt": _NEG}},
"8": {"class_type": "FluxKontextMultiReferenceLatentMethod", "inputs": {"conditioning": ["6", 0], "reference_latents_method": "index_timestep_zero"}},
"9": {"class_type": "FluxKontextMultiReferenceLatentMethod", "inputs": {"conditioning": ["7", 0], "reference_latents_method": "index_timestep_zero"}},
"10": {"class_type": "EmptySD3LatentImage", "inputs": {"width": 1024, "height": 1536, "batch_size": 1}},
"11": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["4", 0], "shift": 3.1}},
"12": {"class_type": "CFGNorm", "inputs": {"model": ["11", 0], "strength": 1.0}},
"13": {"class_type": "KSampler", "inputs": {"model": ["12", 0], "positive": ["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}},
"14": {"class_type": "VAEDecode", "inputs": {"samples": ["13", 0], "vae": ["5", 0]}},
"15": {"class_type": "SaveImage", "inputs": {"images": ["14", 0], "filename_prefix": prefix}},
}
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. 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]
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]:
"""Saved workflow files for the org (any user), for the Pipelines tab."""
base = Path(folder_paths.get_user_directory())
roots = [base / "orgs" / org / "user", base / "default"]
seen: set[str] = set()
for root in roots:
if not root.is_dir():
continue
for wf in root.rglob("workflows/*/*.json"):
seen.add(str(wf).split("workflows/", 1)[1])
for wf in root.rglob("workflows/*.json"):
seen.add(str(wf).split("workflows/", 1)[1])
return sorted(seen)
@web.middleware
async def home_redirect(request: web.Request, handler):
"""Deployed default: land on the content view. The editor stays one click
away (?advanced=1) and every non-root path is untouched."""
if (_home_default() and request.path == "/" and request.method == "GET"
and "advanced" not in request.query
and "text/html" in request.headers.get("Accept", "")):
raise web.HTTPFound("/studio")
return await handler(request)
def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
@routes.get("/studio")
async def studio_home(request: web.Request):
# 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):
org = _org_of(request)
root = _library_root(org)
if root is None:
return web.json_response({"org": org, "assets": [], "workflows": _workflows(org),
"note": "no library.json for this org yet"})
lib = _load_library(root)
assets = []
for a in lib.get("assets", []):
p = _safe_asset(root, a.get("path", ""))
if p is None:
continue
row = dict(a)
row["view"] = _view_params(p)
row["mtime"] = int(p.stat().st_mtime)
if p.suffix.lower() == ".png":
row["prov"] = _provenance(p)
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()
rel, status = body.get("path", ""), body.get("status", "")
if status not in _STATUSES:
return web.json_response({"error": f"status must be one of {_STATUSES}"}, status=400)
org = _org_of(request)
root = _library_root(org)
if root is None or _safe_asset(root, rel) is None:
return web.json_response({"error": "unknown asset"}, status=404)
lib = _load_library(root)
for a in lib.get("assets", []):
if a.get("path") == rel:
a["status"] = status
a["updatedAt"] = int(time.time())
_save_library(root, lib)
return web.json_response({"ok": True, "path": rel, "status": status})
return web.json_response({"error": "asset not in library"}, status=404)
@routes.post("/v1/library/rerun")
async def rerun(request: web.Request):
body = await request.json()
org = _org_of(request)
root = _library_root(org)
p = _safe_asset(root, body.get("path", "")) if root else None
if p is None or p.suffix.lower() != ".png":
return web.json_response({"error": "unknown asset"}, status=404)
try:
from PIL import Image
graph = json.loads(Image.open(p).info.get("prompt", "null"))
except Exception:
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 = []
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()
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})
+305
View File
@@ -0,0 +1,305 @@
"""
SqlitePromptQueue Hanzo Studio's crash-durable render queue.
Drop-in for execution.PromptQueue, backed by a single SQLite file (stdlib
sqlite3, WAL). When STUDIO_QUEUE_DB is set, render jobs survive a process
crash: pending and in-flight prompts live in the DB, and a killed render is
rescheduled for another claim instead of being lost. Zero external processes.
The PromptQueue seam maps straight onto a durable job state machine:
put(item) INSERT job (pending) # idempotent on prompt_id
get(timeout) claim next pending (claimed, leased)
task_done(...success) job done
task_done(...error) job pending (retry) | failed (per max attempts)
The existing prompt_worker thread is the claim loop unchanged: it calls get()
(claim) and task_done() (report). A background heartbeat renews the lease of
the in-flight job while the render runs; if this process dies, the lease
expires and the reap step (run on every get() and by the heartbeat thread)
returns the job to `pending` so it is re-claimed. A render retry is
idempotent SaveImage suffixes increment.
Multiple Studio processes pointing at the same STUDIO_QUEUE_DB compete safely:
claim runs under BEGIN IMMEDIATE, so exactly one process claims each job.
History and flags stay in the in-memory base (the Studio UI reads them); only
the queue mechanics become durable. This supersedes STUDIO_PERSIST_QUEUE (JSON
snapshot) as the local durable path; that remains the zero-dep fallback.
Standard library only no new hard dependency.
"""
from __future__ import annotations
import copy
import json
import logging
import os
import socket
import sqlite3
import threading
import time
from execution import PromptQueue
logger = logging.getLogger(__name__)
DEFAULT_QUEUE = "studio-render"
_SCHEMA = """
CREATE TABLE IF NOT EXISTS render_jobs (
id TEXT PRIMARY KEY,
queue TEXT NOT NULL,
payload TEXT NOT NULL,
state TEXT NOT NULL, -- pending|claimed|done|failed
attempt INTEGER NOT NULL DEFAULT 1,
max_attempts INTEGER NOT NULL DEFAULT 3,
worker TEXT,
lease_expiry REAL,
result TEXT,
cause TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_render_jobs_ready ON render_jobs(queue, state, created_at);
"""
def serialize_item(item: tuple) -> str:
"""Render-queue tuple → JSON job payload."""
number, prompt_id, prompt, extra_data, outputs, sensitive = item
return json.dumps({
"number": number,
"prompt_id": prompt_id,
"prompt": prompt,
"extra_data": extra_data,
"outputs": outputs,
"sensitive": sensitive,
})
def deserialize_item(payload: str) -> tuple:
"""JSON job payload → render-queue tuple (inverse of serialize_item)."""
d = json.loads(payload)
return (
d.get("number", 0),
d["prompt_id"],
d["prompt"],
d.get("extra_data") or {},
d.get("outputs") or [],
d.get("sensitive") or {},
)
def _worker_identity() -> str:
return os.environ.get("STUDIO_WORKER_ID") or f"{socket.gethostname()}-{os.getpid()}"
class SqlitePromptQueue(PromptQueue):
"""PromptQueue whose durability lives in a SQLite file."""
def __init__(self, server, db_path: str | None = None):
super().__init__(server)
# SQLite is the durable backend — disable the JSON snapshot path and
# drop anything a stray STUDIO_PERSIST_QUEUE restore left behind
# (precedence: SQLite > PERSIST > memory).
self._persist_path = None
self.queue = []
self.db_path = db_path or os.environ["STUDIO_QUEUE_DB"]
self.queue_name = os.environ.get("STUDIO_TASKS_QUEUE", DEFAULT_QUEUE)
self.worker_id = _worker_identity()
self.max_attempts = int(os.environ.get("STUDIO_TASKS_MAX_ATTEMPTS", "3"))
self.lease_s = float(os.environ.get("STUDIO_TASKS_LEASE_MS", "60000")) / 1000.0
self.poll_interval = float(os.environ.get("STUDIO_TASKS_POLL_INTERVAL", "1.0"))
os.makedirs(os.path.dirname(os.path.abspath(self.db_path)), exist_ok=True)
self._db_lock = threading.Lock()
self._conn = sqlite3.connect(self.db_path, check_same_thread=False, isolation_level=None)
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute("PRAGMA synchronous=NORMAL")
self._conn.execute("PRAGMA busy_timeout=5000")
self._conn.executescript(_SCHEMA)
# item_id → job_id so task_done reports to the right row.
self._job_by_item: dict[int, str] = {}
self._pending = 0
self._reap() # recover leases orphaned by a previous crash
logger.info(
"[sqlite-queue] durable render queue: db=%s queue=%s worker=%s lease=%.0fs",
self.db_path, self.queue_name, self.worker_id, self.lease_s,
)
self._hb_interval = max(self.lease_s / 3.0, 2.0)
self._hb_stop = threading.Event()
self._hb_thread = threading.Thread(target=self._heartbeat_loop, daemon=True)
self._hb_thread.start()
# ── producer: /prompt → durable job ──────────────────────────────
def put(self, item):
prompt_id = item[1]
now = time.time()
with self._db_lock:
self._conn.execute(
"INSERT OR IGNORE INTO render_jobs"
"(id, queue, payload, state, attempt, max_attempts, created_at, updated_at)"
" VALUES(?,?,?,'pending',1,?,?,?)",
(prompt_id, self.queue_name, serialize_item(item), self.max_attempts, now, now),
)
self.server.queue_updated()
# ── consumer: prompt_worker → claim ──────────────────────────────
def get(self, timeout=None):
deadline = None if timeout is None else time.monotonic() + timeout
while True:
self._reap()
claimed = self._claim()
if claimed is not None:
return claimed
if deadline is not None and time.monotonic() >= deadline:
return None
wait = self.poll_interval
if deadline is not None:
wait = min(wait, max(deadline - time.monotonic(), 0.0))
if self._hb_stop.wait(wait):
return None
def _claim(self):
now = time.time()
with self._db_lock:
self._conn.execute("BEGIN IMMEDIATE")
try:
row = self._conn.execute(
"SELECT id, payload FROM render_jobs"
" WHERE queue=? AND state='pending' ORDER BY created_at LIMIT 1",
(self.queue_name,),
).fetchone()
if row is None:
self._conn.execute("COMMIT")
return None
job_id, payload = row
self._conn.execute(
"UPDATE render_jobs SET state='claimed', worker=?, lease_expiry=?, updated_at=?"
" WHERE id=?",
(self.worker_id, now + self.lease_s, now, job_id),
)
self._conn.execute("COMMIT")
except Exception:
self._conn.execute("ROLLBACK")
raise
try:
item = deserialize_item(payload)
except Exception as e: # corrupt payload — fail terminally, don't loop.
logger.error("[sqlite-queue] undecodable job %s: %s", job_id, e)
self._terminal(job_id, "failed", cause=f"undecodable payload: {e}")
return None
with self.mutex:
item_id = self.task_counter
self.currently_running[item_id] = copy.deepcopy(item)
self._job_by_item[item_id] = job_id
self.task_counter += 1
self.server.queue_updated()
return (item, item_id)
# ── completion → durable state ───────────────────────────────────
def task_done(self, item_id, history_result, status, process_item=None):
with self.mutex:
job_id = self._job_by_item.pop(item_id, None)
if job_id is not None:
success = status is None or status.status_str == "success"
if success:
self._terminal(job_id, "done",
result=json.dumps({"outputs": _summarize_outputs(history_result)}))
else:
cause = "; ".join(status.messages) if status and status.messages else "render error"
self._fail(job_id, cause)
super().task_done(item_id, history_result, status, process_item)
def _terminal(self, job_id, state, result=None, cause=None):
with self._db_lock:
self._conn.execute(
"UPDATE render_jobs SET state=?, result=?, cause=?, worker=NULL,"
" lease_expiry=NULL, updated_at=? WHERE id=?",
(state, result, cause, time.time(), job_id),
)
def _fail(self, job_id, cause):
# Retry-aware: another attempt if the policy allows, else terminal.
with self._db_lock:
self._conn.execute(
"UPDATE render_jobs SET"
" attempt = attempt + 1,"
" state = CASE WHEN attempt + 1 > max_attempts THEN 'failed' ELSE 'pending' END,"
" worker=NULL, lease_expiry=NULL, cause=?, updated_at=?"
" WHERE id=? AND state='claimed'",
(cause, time.time(), job_id),
)
# ── crash-recovery: reclaim expired leases ───────────────────────
def _reap(self):
now = time.time()
with self._db_lock:
self._conn.execute(
"UPDATE render_jobs SET"
" attempt = attempt + 1,"
" state = CASE WHEN attempt + 1 > max_attempts THEN 'failed' ELSE 'pending' END,"
" cause = CASE WHEN attempt + 1 > max_attempts THEN 'lease expired' ELSE cause END,"
" worker=NULL, lease_expiry=NULL, updated_at=?"
" WHERE state='claimed' AND lease_expiry IS NOT NULL AND lease_expiry < ?",
(now, now),
)
# ── heartbeat / pending refresh ──────────────────────────────────
def _heartbeat_loop(self):
while not self._hb_stop.wait(self._hb_interval):
now = time.time()
with self.mutex:
inflight = list(self._job_by_item.values())
if inflight:
with self._db_lock:
for job_id in inflight:
self._conn.execute(
"UPDATE render_jobs SET lease_expiry=?, updated_at=?"
" WHERE id=? AND state='claimed'",
(now + self.lease_s, now, job_id),
)
self._reap()
with self._db_lock:
(self._pending,) = self._conn.execute(
"SELECT COUNT(*) FROM render_jobs WHERE queue=? AND state='pending'",
(self.queue_name,),
).fetchone()
def get_tasks_remaining(self):
with self.mutex:
return len(self.currently_running) + self._pending
def job_state(self, job_id: str):
"""Diagnostic: current (state, attempt) of a job, or None."""
with self._db_lock:
row = self._conn.execute(
"SELECT state, attempt FROM render_jobs WHERE id=?", (job_id,)
).fetchone()
return row
def stop(self):
self._hb_stop.set()
with self._db_lock:
self._conn.close()
def _summarize_outputs(history_result) -> list:
"""Extract output filenames from an execution's history_result."""
files = []
try:
outputs = (history_result or {}).get("outputs", {})
for node_output in outputs.values():
for key in ("images", "gifs", "audio", "video"):
for entry in node_output.get(key, []) or []:
name = entry.get("filename")
if name:
files.append({"filename": name, "subfolder": entry.get("subfolder", ""),
"type": entry.get("type", "output")})
except Exception:
pass
return files
+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}
+8 -3
View File
@@ -2258,9 +2258,14 @@ async def load_custom_node(module_path: str, ignore=set(), module_parent="custom
if hasattr(module, "NODE_DISPLAY_NAME_MAPPINGS") and getattr(module, "NODE_DISPLAY_NAME_MAPPINGS") is not None:
NODE_DISPLAY_NAME_MAPPINGS.update(module.NODE_DISPLAY_NAME_MAPPINGS)
return True
# V3 Extension Definition
elif hasattr(module, "studio_entrypoint"):
entrypoint = getattr(module, "studio_entrypoint")
# V3 Extension Definition. Honor the upstream ComfyUI `comfy_entrypoint`
# name too, so vendored packs register through the V3 extension API
# unmodified (their ComfyExtension/io.ComfyNode are aliased to the
# Studio* classes by studio_compat).
elif hasattr(module, "studio_entrypoint") or hasattr(module, "comfy_entrypoint"):
entrypoint = getattr(module, "studio_entrypoint", None)
if entrypoint is None:
entrypoint = getattr(module, "comfy_entrypoint")
if not callable(entrypoint):
logging.warning(f"studio_entrypoint in {module_path} is not callable, skipping.")
return False
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "hanzo-studio"
version = "0.14.1"
version = "0.17.3"
readme = "README.md"
license = { file = "LICENSE" }
requires-python = ">=3.10"
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# install_custom_nodes.sh -- vendor the Hanzo Studio custom-node packs.
#
# Reads custom_nodes/hanzo-packs.txt, clones each pack at its pinned commit (git,
# idempotent), then installs their hand-resolved Python deps
# (custom_nodes-requirements.txt) with the studio torch/numpy/transformers stack
# LOCKED so nothing gets downgraded. The lock is DERIVED FROM THE LIVE ENV (not a
# hardcoded file), so it is correct in both contexts:
# - Docker image build (CPU torch): STUDIO_PY=python pip install
# - Local uv venv (GB10, cu130 torch):
# STUDIO_PY=.venv/bin/python \
# STUDIO_PIP="uv pip install --python .venv/bin/python" \
# scripts/install_custom_nodes.sh
#
# Upstream `comfy.*` imports are aliased to `studio.*` at boot by studio_compat,
# so packs run unmodified. Idempotent; safe to re-run to reconcile pins.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CN="$REPO/custom_nodes"
MANIFEST="$CN/hanzo-packs.txt"
REQS="$REPO/custom_nodes-requirements.txt"
PY="${STUDIO_PY:-python}"
PIP="${STUDIO_PIP:-python -m pip install}"
CGEN="$(mktemp)"
trap 'rm -f "$CGEN"' EXIT
log() { printf '[packs] %s\n' "$*"; }
# 0. Derive a constraints file locking the studio-critical packages to whatever
# versions are ALREADY installed, so pack deps can never move the torch stack,
# NumPy 2.x or Transformers 5.x. A dep that is incompatible surfaces as a
# resolver error instead of silently breaking the runtime.
"$PY" - > "$CGEN" <<'PY'
import importlib.metadata as md
CRIT = ["torch", "torchvision", "torchaudio", "torchsde", "numpy",
"transformers", "tokenizers", "pillow", "scipy", "timm",
"safetensors", "huggingface-hub"]
for name in CRIT:
try:
print(f"{name}=={md.version(name)}")
except md.PackageNotFoundError:
pass
PY
log "locked stack:"; sed 's/^/[packs] /' "$CGEN"
# 1. Clone / pin every pack from the manifest (comment + blank lines skipped),
# then apply any local compat patches (scripts/patches/<pack>-*.patch). The
# forced checkout discards previously-applied patches so the pin stays
# authoritative and re-runs are idempotent.
log "reconciling packs from $MANIFEST"
while read -r name url sha _rest; do
case "$name" in ''|\#*) continue ;; esac
dir="$CN/$name"
if [ -d "$dir/.git" ]; then
git -C "$dir" fetch -q origin "$sha" 2>/dev/null || git -C "$dir" fetch -q --all --tags
else
git clone -q --filter=blob:none "$url" "$dir"
fi
git -C "$dir" checkout -qf "$sha"
log "pinned $name @ $(git -C "$dir" rev-parse --short HEAD)"
for p in "$REPO/scripts/patches/$name"-*.patch; do
[ -f "$p" ] || continue
git -C "$dir" apply "$p"
log "applied $(basename "$p")"
done
done < "$MANIFEST"
# 2. Install pack dependencies with the derived stack lock.
log "installing pack dependencies"
$PIP -c "$CGEN" -r "$REQS"
# 3. ultralytics (AGPL detectors for Impact-Subpack): --no-deps so it cannot pull
# the non-headless opencv-python that would fight opencv-*-headless.
log "installing ultralytics (--no-deps)"
$PIP -c "$CGEN" --no-deps ultralytics
log "done -- $(grep -cvE '^\s*(#|$)' "$MANIFEST") packs pinned"
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""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 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.
library root (== S3 prefix s3://hanzo-studio/orgs/karma/output/
== studio pod /app/orgs/karma/output/
== spark local /home/z/work/hanzo/studio/output/orgs/karma/output/)
designs/<slug>/<kind>_<role>.png catalog assets (ecom|product|lifestyle|hover)
marketing/<channel>/<name>.png queue assets (social|blog|campaign)
library.json <- this file, regenerated here
What this does, and nothing more:
* walk the tree, classify each image by path -> {design, kind, role}
* PRESERVE status/caption/tags from the existing library.json (keyed by path)
* brand-new files enter as status="draft"; deleted files drop out
* write library.json atomically (tmp + rename) so a reader never sees a
half-written file, and the S3 mirror sidecar carries it up on its next pass
It writes ONLY library.json. It never touches images and never regenerates
products.json (that file carries human-curated pricing/copy the site owns).
Runs one-shot, or with --watch as the studio pod's `build-manifest` sidecar.
Pure stdlib no PIL, no deps so it runs in the studio image or anywhere.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from datetime import datetime, timezone
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
# Written marketing content (blog posts, campaign briefs, social ad copy) is
# first-class in the ONE index too — a .md under marketing/ is a queue asset
# whose body is the post/brief and whose caption/hashtags(=tags) are set with
# karma-queue.py. Restricted to marketing/ so catalog stays image-only.
MARKETING_TEXT_EXTS = {".md"}
MANIFEST_NAME = "library.json"
# The kinds a catalog asset may carry (designs/<slug>/<kind>_<role>.png). The
# storefront consumes ecom + product + lifestyle; hover is a card-hover alt.
CATALOG_KINDS = {"ecom", "product", "lifestyle", "hover"}
# Marketing channels double as the kind for marketing/<channel>/* assets.
MARKETING_KINDS = {"social", "blog", "campaign"}
def iso(ts: float) -> str:
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def classify(relpath: str, slugs: set[str]) -> dict | None:
"""Map a path relative to the library root -> {design, kind, role}.
Returns None for anything that is not a manifestable asset.
"""
parts = relpath.split("/")
stem = os.path.splitext(parts[-1])[0]
if parts[0] == "marketing" and len(parts) >= 3:
channel = parts[1]
kind = channel if channel in MARKETING_KINDS else "campaign"
# design = leading slug token if it names a real design, else cross-cut.
head, _, rest = stem.partition("_")
if head in slugs and rest:
design, role = head, rest
else:
design, role = None, stem
return {"design": design, "kind": kind, "role": role}
if parts[0] == "designs" and len(parts) >= 3:
# 4k/print masters live alongside but are not catalog/queue items.
if "4k" in parts[2:-1]:
return None
design = parts[1]
kind, sep, role = stem.partition("_")
if not sep: # e.g. bare "front.png" -> treat stem as role, kind unknown
kind, role = "other", stem
if kind not in CATALOG_KINDS:
kind = kind if kind in ("other",) else "other"
return {"design": design, "kind": kind, "role": role}
return None
def scan(root: str) -> list[str]:
"""All manifestable image paths (relative to root), sorted for stable diffs."""
found: list[str] = []
for base, _dirs, files in os.walk(root):
for f in files:
ext = os.path.splitext(f)[1].lower()
full = os.path.join(base, f)
rel = os.path.relpath(full, root)
is_image = ext in IMAGE_EXTS
is_mktg_text = (ext in MARKETING_TEXT_EXTS
and rel.replace(os.sep, "/").startswith("marketing/"))
if not (is_image or is_mktg_text):
continue
try:
if os.path.getsize(full) == 0: # skip a render caught mid-write
continue
except OSError:
continue
found.append(rel)
return sorted(found)
def load_prior(path: str) -> dict[str, dict]:
"""Existing entries keyed by path, so status/caption/tags survive a rebuild."""
try:
with open(path, "r") as fh:
doc = json.load(fh)
except (OSError, json.JSONDecodeError):
return {}
return {a["path"]: a for a in doc.get("assets", []) if "path" in a}
def build(root: str) -> dict:
manifest_path = os.path.join(root, MANIFEST_NAME)
prior = load_prior(manifest_path)
designs_dir = os.path.join(root, "designs")
slugs = {d for d in os.listdir(designs_dir)} if os.path.isdir(designs_dir) else set()
assets: list[dict] = []
for rel in scan(root):
norm = rel.replace(os.sep, "/")
if norm == MANIFEST_NAME:
continue
info = classify(norm, slugs)
if info is None:
continue
prev = prior.get(norm, {})
entry = {
"path": norm,
"design": info["design"],
"kind": info["kind"],
"role": info["role"],
"status": prev.get("status", "draft"),
"tags": prev.get("tags", []),
"updatedAt": iso(os.path.getmtime(os.path.join(root, rel))),
}
caption = prev.get("caption")
if caption:
entry["caption"] = caption
assets.append(entry)
by_status: dict[str, int] = {}
by_kind: dict[str, int] = {}
for a in assets:
by_status[a["status"]] = by_status.get(a["status"], 0) + 1
by_kind[a["kind"]] = by_kind.get(a["kind"], 0) + 1
return {
"_meta": {
"note": "Canonical Karma asset library. ONE index the storefront + "
"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()),
"count": len(assets),
"byStatus": by_status,
"byKind": by_kind,
},
"assets": assets,
}
def write_atomic(root: str, doc: dict) -> str:
manifest_path = os.path.join(root, MANIFEST_NAME)
tmp = os.path.join(root, ".library.json.tmp")
with open(tmp, "w") as fh:
json.dump(doc, fh, indent=2, ensure_ascii=False)
fh.write("\n")
os.replace(tmp, manifest_path)
return manifest_path
def run_once(root: str, quiet: bool = False, strict: bool = True) -> dict | None:
if not os.path.isdir(root):
msg = f"library_manifest: root does not exist yet: {root}"
if strict:
sys.exit(msg)
if not quiet:
print(msg, file=sys.stderr)
return None # watch mode: tolerate a tenant with no renders yet
doc = build(root)
path = write_atomic(root, doc)
if not quiet:
m = doc["_meta"]
print(f"library_manifest: {m['count']} assets -> {path} {m['byStatus']}")
return doc
def main() -> None:
ap = argparse.ArgumentParser(description="Regenerate the Karma library.json manifest.")
ap.add_argument("--root", default=os.environ.get(
"KARMA_LIBRARY_ROOT", "/app/orgs/karma/output"),
help="Library root (contains designs/ and marketing/).")
ap.add_argument("--watch", action="store_true", help="Loop forever.")
ap.add_argument("--interval", type=int, default=30, help="--watch seconds.")
args = ap.parse_args()
if not args.watch:
run_once(args.root)
return
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"library_manifest: cycle error: {e}", file=sys.stderr, flush=True)
time.sleep(args.interval)
if __name__ == "__main__":
main()
@@ -0,0 +1,262 @@
diff --git i/local_groundingdino/models/GroundingDINO/bertwarper.py w/local_groundingdino/models/GroundingDINO/bertwarper.py
index e209a39..2a87d42 100644
--- i/local_groundingdino/models/GroundingDINO/bertwarper.py
+++ w/local_groundingdino/models/GroundingDINO/bertwarper.py
@@ -7,22 +7,23 @@
import torch
from torch import nn
-from transformers.modeling_outputs import BaseModelOutputWithPoolingAndCrossAttentions
class BertModelWarper(nn.Module):
+ """Thin delegating wrapper around a HuggingFace ``BertModel``.
+
+ GroundingDINO feeds BERT a custom 3D ``[bs, seq, seq]`` self-attention mask
+ plus explicit ``position_ids``. Modern ``transformers`` handle both natively,
+ so we delegate to the real ``BertModel.forward`` instead of reimplementing
+ BERT internals (whose private call signatures drift between releases). The
+ fine-tuned checkpoint weights are remapped ``bert.*`` -> ``bert.bert_model.*``
+ at load time (see ``load_groundingdino_model`` in node.py).
+ """
+
def __init__(self, bert_model):
super().__init__()
- # self.bert = bert_modelc
-
+ self.bert_model = bert_model
self.config = bert_model.config
- self.embeddings = bert_model.embeddings
- self.encoder = bert_model.encoder
- self.pooler = bert_model.pooler
-
- self.get_extended_attention_mask = bert_model.get_extended_attention_mask
- self.invert_attention_mask = bert_model.invert_attention_mask
- self.get_head_mask = bert_model.get_head_mask
def forward(
self,
@@ -30,135 +31,26 @@ class BertModelWarper(nn.Module):
attention_mask=None,
token_type_ids=None,
position_ids=None,
- head_mask=None,
- inputs_embeds=None,
- encoder_hidden_states=None,
- encoder_attention_mask=None,
- past_key_values=None,
- use_cache=None,
- output_attentions=None,
output_hidden_states=None,
- return_dict=None,
+ **kwargs,
):
- r"""
- encoder_hidden_states (:obj:`torch.FloatTensor` of shape :obj:`(batch_size, sequence_length, hidden_size)`, `optional`):
- Sequence of hidden-states at the output of the last layer of the encoder. Used in the cross-attention if
- the model is configured as a decoder.
- encoder_attention_mask (:obj:`torch.FloatTensor` of shape :obj:`(batch_size, sequence_length)`, `optional`):
- Mask to avoid performing attention on the padding token indices of the encoder input. This mask is used in
- the cross-attention if the model is configured as a decoder. Mask values selected in ``[0, 1]``:
-
- - 1 for tokens that are **not masked**,
- - 0 for tokens that are **masked**.
- past_key_values (:obj:`tuple(tuple(torch.FloatTensor))` of length :obj:`config.n_layers` with each tuple having 4 tensors of shape :obj:`(batch_size, num_heads, sequence_length - 1, embed_size_per_head)`):
- Contains precomputed key and value hidden states of the attention blocks. Can be used to speed up decoding.
-
- If :obj:`past_key_values` are used, the user can optionally input only the last :obj:`decoder_input_ids`
- (those that don't have their past key value states given to this model) of shape :obj:`(batch_size, 1)`
- instead of all :obj:`decoder_input_ids` of shape :obj:`(batch_size, sequence_length)`.
- use_cache (:obj:`bool`, `optional`):
- If set to :obj:`True`, :obj:`past_key_values` key value states are returned and can be used to speed up
- decoding (see :obj:`past_key_values`).
- """
- output_attentions = (
- output_attentions if output_attentions is not None else self.config.output_attentions
- )
- output_hidden_states = (
- output_hidden_states
- if output_hidden_states is not None
- else self.config.output_hidden_states
- )
- return_dict = return_dict if return_dict is not None else self.config.use_return_dict
-
- if self.config.is_decoder:
- use_cache = use_cache if use_cache is not None else self.config.use_cache
- else:
- use_cache = False
-
- if input_ids is not None and inputs_embeds is not None:
- raise ValueError("You cannot specify both input_ids and inputs_embeds at the same time")
- elif input_ids is not None:
- input_shape = input_ids.size()
- batch_size, seq_length = input_shape
- elif inputs_embeds is not None:
- input_shape = inputs_embeds.size()[:-1]
- batch_size, seq_length = input_shape
- else:
- raise ValueError("You have to specify either input_ids or inputs_embeds")
-
- device = input_ids.device if input_ids is not None else inputs_embeds.device
-
- # past_key_values_length
- past_key_values_length = (
- past_key_values[0][0].shape[2] if past_key_values is not None else 0
- )
-
- if attention_mask is None:
- attention_mask = torch.ones(
- ((batch_size, seq_length + past_key_values_length)), device=device
+ if attention_mask is not None and attention_mask.dim() == 3:
+ # GroundingDINO supplies a custom [bs, seq, seq] self-attention mask
+ # (True = attend) to block cross-phrase attention. transformers>=5
+ # accepts an already-prepared 4D additive float mask
+ # [bs, 1, q_len, kv_len] and returns it as-is, so we convert here.
+ dtype = self.bert_model.dtype
+ keep = attention_mask[:, None, :, :].bool()
+ attention_mask = torch.zeros_like(keep, dtype=dtype).masked_fill(
+ ~keep, torch.finfo(dtype).min
)
- if token_type_ids is None:
- token_type_ids = torch.zeros(input_shape, dtype=torch.long, device=device)
-
- # We can provide a self-attention mask of dimensions [batch_size, from_seq_length, to_seq_length]
- # ourselves in which case we just need to make it broadcastable to all heads.
- extended_attention_mask: torch.Tensor = self.get_extended_attention_mask(
- attention_mask, input_shape, device
- )
-
- # If a 2D or 3D attention mask is provided for the cross-attention
- # we need to make broadcastable to [batch_size, num_heads, seq_length, seq_length]
- if self.config.is_decoder and encoder_hidden_states is not None:
- encoder_batch_size, encoder_sequence_length, _ = encoder_hidden_states.size()
- encoder_hidden_shape = (encoder_batch_size, encoder_sequence_length)
- if encoder_attention_mask is None:
- encoder_attention_mask = torch.ones(encoder_hidden_shape, device=device)
- encoder_extended_attention_mask = self.invert_attention_mask(encoder_attention_mask)
- else:
- encoder_extended_attention_mask = None
- # if os.environ.get('IPDB_SHILONG_DEBUG', None) == 'INFO':
- # import ipdb; ipdb.set_trace()
-
- # Prepare head mask if needed
- # 1.0 in head_mask indicate we keep the head
- # attention_probs has shape bsz x n_heads x N x N
- # input head_mask has shape [num_heads] or [num_hidden_layers x num_heads]
- # and head_mask is converted to shape [num_hidden_layers x batch x num_heads x seq_length x seq_length]
- head_mask = self.get_head_mask(head_mask, self.config.num_hidden_layers)
-
- embedding_output = self.embeddings(
+ return self.bert_model(
input_ids=input_ids,
- position_ids=position_ids,
+ attention_mask=attention_mask,
token_type_ids=token_type_ids,
- inputs_embeds=inputs_embeds,
- past_key_values_length=past_key_values_length,
- )
-
- encoder_outputs = self.encoder(
- embedding_output,
- attention_mask=extended_attention_mask,
- head_mask=head_mask,
- encoder_hidden_states=encoder_hidden_states,
- encoder_attention_mask=encoder_extended_attention_mask,
- past_key_values=past_key_values,
- use_cache=use_cache,
- output_attentions=output_attentions,
+ position_ids=position_ids,
output_hidden_states=output_hidden_states,
- return_dict=return_dict,
- )
- sequence_output = encoder_outputs[0]
- pooled_output = self.pooler(sequence_output) if self.pooler is not None else None
-
- if not return_dict:
- return (sequence_output, pooled_output) + encoder_outputs[1:]
-
- return BaseModelOutputWithPoolingAndCrossAttentions(
- last_hidden_state=sequence_output,
- pooler_output=pooled_output,
- past_key_values=encoder_outputs.past_key_values,
- hidden_states=encoder_outputs.hidden_states,
- attentions=encoder_outputs.attentions,
- cross_attentions=encoder_outputs.cross_attentions,
+ return_dict=True,
)
diff --git i/node.py w/node.py
index cd88718..0cc48ed 100644
--- i/node.py
+++ w/node.py
@@ -12,7 +12,7 @@ import logging
from torch.hub import download_url_to_file
from urllib.parse import urlparse
import folder_paths
-import comfy.model_management
+import studio.model_management
from sam_hq.predictor import SamPredictorHQ
from sam_hq.build_sam_hq import sam_model_registry
from local_groundingdino.datasets import transforms as T
@@ -84,7 +84,7 @@ def load_sam_model(model_name):
if 'hq' not in model_type and 'mobile' not in model_type:
model_type = '_'.join(model_type.split('_')[:-1])
sam = sam_model_registry[model_type](checkpoint=sam_checkpoint_path)
- sam_device = comfy.model_management.get_torch_device()
+ sam_device = studio.model_management.get_torch_device()
sam.to(device=sam_device)
sam.eval()
sam.model_name = model_file_name
@@ -129,10 +129,17 @@ def load_groundingdino_model(model_name):
groundingdino_model_list[model_name]["model_url"],
groundingdino_model_dir_name,
),
+ weights_only=False,
)
- dino.load_state_dict(local_groundingdino_clean_state_dict(
- checkpoint['model']), strict=False)
- device = comfy.model_management.get_torch_device()
+ state_dict = local_groundingdino_clean_state_dict(checkpoint['model'])
+ # BertModelWarper now delegates to a nested `bert_model`; remap the
+ # fine-tuned BERT weights `bert.*` -> `bert.bert_model.*` so they load.
+ state_dict = {
+ (f'bert.bert_model.{k[len("bert."):]}' if k.startswith('bert.') else k): v
+ for k, v in state_dict.items()
+ }
+ dino.load_state_dict(state_dict, strict=False)
+ device = studio.model_management.get_torch_device()
dino.to(device=device)
dino.eval()
return dino
@@ -164,7 +171,7 @@ def groundingdino_predict(
caption = caption.strip()
if not caption.endswith("."):
caption = caption + "."
- device = comfy.model_management.get_torch_device()
+ device = studio.model_management.get_torch_device()
image = image.to(device)
with torch.no_grad():
outputs = model(image[None], captions=[caption])
@@ -243,7 +250,7 @@ def sam_segment(
predictor.set_image(image_np_rgb)
transformed_boxes = predictor.transform.apply_boxes_torch(
boxes, image_np.shape[:2])
- sam_device = comfy.model_management.get_torch_device()
+ sam_device = studio.model_management.get_torch_device()
masks, _, _ = predictor.predict_torch(
point_coords=None,
point_labels=None,
diff --git i/sam_hq/build_sam_hq.py w/sam_hq/build_sam_hq.py
index c8f41f2..153b819 100644
--- i/sam_hq/build_sam_hq.py
+++ w/sam_hq/build_sam_hq.py
@@ -64,7 +64,7 @@ def _load_sam_checkpoint(sam: Sam, checkpoint=None):
sam.eval()
if checkpoint is not None:
with open(checkpoint, "rb") as f:
- state_dict = torch.load(f)
+ state_dict = torch.load(f, weights_only=False)
info = sam.load_state_dict(state_dict, strict=False)
print(info)
for _, p in sam.named_parameters():
+148 -20
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)
@@ -215,13 +250,22 @@ class PromptServer():
self.node_replace_manager = NodeReplaceManager()
self.internal_routes = InternalRoutes(self)
self.supports = ["custom_nodes_from_web"]
self.prompt_queue = execution.PromptQueue(self)
# Queue backend precedence: SQLite (durable, zero external process) >
# PERSIST (JSON snapshot) > memory. STUDIO_QUEUE_DB opts into the
# crash-durable SQLite queue; without it Studio behaves exactly as
# before (in-memory + optional JSON snapshot).
if os.environ.get("STUDIO_QUEUE_DB"):
from middleware.tasks_queue import SqlitePromptQueue
self.prompt_queue = SqlitePromptQueue(self)
else:
self.prompt_queue = execution.PromptQueue(self)
self.loop = loop
self.messages = asyncio.Queue()
self.client_session:Optional[aiohttp.ClientSession] = None
self.number = 0
middlewares = [cache_control, deprecation_warning]
from middleware.studio_home import home_redirect
middlewares = [cache_control, deprecation_warning, home_redirect]
self._iam_auth = None
if args.enable_iam_auth:
@@ -251,8 +295,10 @@ class PromptServer():
max_upload_size = round(args.max_upload_size * 1024 * 1024)
self.app = web.Application(client_max_size=max_upload_size, middlewares=middlewares)
# OIDC Authorization Code callback (standard code flow, standalone app).
# OIDC Authorization Code flow (standard code flow, standalone app):
# /login begins it (the login page's sole entry point), /callback ends it.
if self._iam_auth is not None:
self.app.router.add_get("/login", self._iam_auth.handle_login)
self.app.router.add_get("/callback", self._iam_auth.handle_callback)
self.sockets = dict()
self.sockets_metadata = dict()
@@ -379,7 +425,6 @@ class PromptServer():
if os.path.isfile(login_page):
with open(login_page, "r") as f:
html = f.read()
html = html.replace("{{IAM_URL}}", args.iam_url.rstrip("/"))
return web.Response(
text=html,
content_type="text/html",
@@ -463,13 +508,15 @@ class PromptServer():
return a.hexdigest() == b.hexdigest()
return False
def image_upload(post, image_save_function=None):
def image_upload(post, image_save_function=None, request=None):
image = post.get("image")
overwrite = post.get("overwrite")
image_is_duplicate = False
image_upload_type = post.get("type")
upload_dir, image_upload_type = get_dir_by_type(image_upload_type)
# Forward the request so uploads land in the caller's org-scoped
# directory (orgs/{org}/output) in multi-tenant mode.
upload_dir, image_upload_type = get_dir_by_type(image_upload_type, request)
if image and image.file:
filename = image.filename
@@ -514,8 +561,23 @@ class PromptServer():
@routes.post("/upload/image")
async def upload_image(request):
post = await request.post()
return image_upload(post)
return image_upload(post, request=request)
@routes.post("/upload/output")
async def upload_output(request):
# Token-authorized gallery ingest for BYO-GPU workers. A remote worker
# (hanzod's gpu-fleet loop) renders a studio.render job on its own GPU
# and POSTs each finished image here with the user's IAM bearer. The IAM
# middleware validates the token and sets request["iam_user"]; org is
# derived from that (owner/groups), so the file lands in this org's
# orgs/{org}/output — which the PVC + S3 mirror persist into the gallery.
# No S3/rclone credentials ever touch the worker box: the user's session
# token is the only credential. Replaces the old rclone/S3 sidecar sync.
post = await request.post()
# Force type=output regardless of what the client sent.
forced = dict(post)
forced["type"] = "output"
return image_upload(forced, request=request)
@routes.post("/upload/mask")
async def upload_mask(request):
@@ -561,7 +623,7 @@ class PromptServer():
original_pil.putalpha(new_alpha)
original_pil.save(filepath, compress_level=4, pnginfo=metadata)
return image_upload(post, image_save_function)
return image_upload(post, image_save_function, request=request)
@routes.get("/view")
async def view_image(request):
@@ -942,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")
@@ -1024,6 +1091,21 @@ class PromptServer():
self.node_replace_manager.apply_replacements(prompt)
org_id = self._get_org_id(request) # IAM org — scopes outputs + billing
# BYO-GPU dispatch: cloud pods only. A worker-mode node IS the
# render backend — it must queue locally, never re-dispatch.
# Runs BEFORE local validation: the job executes on the org's GPU
# box, which validates against ITS node classes and model files. A
# GPU-less pod has an empty model list and would wrongly reject
# every checkpoint-referencing graph ("not in []").
if not args.worker_mode:
try:
from middleware.gpu_dispatch import dispatch_if_worker
except ImportError: # package-style checkout
from .middleware.gpu_dispatch import dispatch_if_worker
if dispatch_if_worker(request, org_id, prompt_id, prompt):
return web.json_response({"prompt_id": prompt_id, "number": number, "node_errors": {}})
valid = await execution.validate_prompt(prompt_id, prompt, partial_execution_targets)
extra_data = {}
if "extra_data" in json_data:
@@ -1038,7 +1120,14 @@ class PromptServer():
if sensitive_val in extra_data:
sensitive[sensitive_val] = extra_data.pop(sensitive_val)
extra_data["create_time"] = int(time.time() * 1000) # timestamp in milliseconds
extra_data["org_id"] = self._get_org_id(request) # tenancy: scope outputs to org
extra_data["org_id"] = org_id
# Carry the requester's verified IAM token to the worker so the
# finished render is recorded in the content lane as this user,
# into this user's org. `sensitive` is stripped from history (see
# SENSITIVE_EXTRA_DATA_KEYS) — the token is never persisted.
iam_token = request.get("iam_token")
if iam_token:
sensitive["iam_token"] = iam_token
self.prompt_queue.put((number, prompt_id, prompt, extra_data, outputs_to_execute, sensitive))
response = {"prompt_id": prompt_id, "number": number, "node_errors": valid[3]}
return web.json_response(response)
@@ -1057,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)
@@ -1075,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
@@ -1092,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)
@@ -1273,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)
@@ -1281,6 +1397,8 @@ class PromptServer():
async def _on_shutdown(app):
await prompt_router.close_session()
await visor_client.close_session()
from middleware import copilot
await copilot.close_session()
wc = getattr(self, "_worker_client", None)
if wc:
await wc.stop()
@@ -1292,6 +1410,16 @@ class PromptServer():
self.custom_node_manager.add_routes(self.routes, self.app, nodes.LOADED_MODULE_DIRS.items())
self.subgraph_manager.add_routes(self.routes, nodes.LOADED_MODULE_DIRS.items())
self.node_replace_manager.add_routes(self.routes)
from middleware.engine_selector import add_engine_routes
add_engine_routes(self.routes, self)
from middleware.copilot import add_copilot_routes
add_copilot_routes(self.routes, self)
from middleware.session import add_session_routes
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
View File
@@ -224,6 +224,7 @@ parser.add_argument("--enable-compress-response-body", action="store_true", help
# --- IAM Authentication ---
parser.add_argument("--enable-iam-auth", action="store_true", help="Enable IAM authentication via hanzo.id. Requires valid Bearer token for API access. Localhost is exempt by default.")
parser.add_argument("--iam-url", type=str, default=os.environ.get("STUDIO_IAM_URL", "https://hanzo.id"), help="IAM server URL for token validation (default: https://hanzo.id).")
parser.add_argument("--console-url", type=str, default=os.environ.get("STUDIO_CONSOLE_URL", "https://console.hanzo.ai"), help="Hanzo Console URL the Studio user menu links back to (default: https://console.hanzo.ai).")
parser.add_argument("--no-localhost-bypass", action="store_true", help="Disable localhost auth bypass (require IAM tokens even for local connections).")
# --- Multi-tenant ---
File diff suppressed because one or more lines are too long
+133
View File
@@ -0,0 +1,133 @@
"""
studio_compat -- import-compat shim for upstream ComfyUI custom nodes.
Hanzo Studio renamed ComfyUI's internal packages (``comfy`` -> ``studio``,
``comfy_extras`` -> ``studio_extras``, ... and ``comfy/comfy_types`` ->
``studio/node_types``; see ``scripts/rename_modules.py``). Third-party custom
node packs import the upstream ``comfy*`` names, so they fail to import against
this fork.
This module installs ONE meta-path finder that lazily aliases every ``comfy*``
import to its ``studio*`` counterpart, so vendored packs run unmodified -- no
forking, no source edits, one place, one way. It is the compatibility redirect
that ``scripts/rename_modules.py`` deferred (its phase 4), implemented with the
modern ``find_spec`` API (``find_module``/``load_module`` were removed in
Python 3.12).
Activated once, from the top of ``main.py``::
import studio_compat # noqa: F401
"""
import importlib
import importlib.abc
import importlib.machinery
import sys
# Root package aliases: upstream ComfyUI name -> Hanzo Studio name.
_ROOTS = {
"comfy": "studio",
"comfy_extras": "studio_extras",
"comfy_api": "studio_api",
"comfy_api_nodes": "studio_api_nodes",
"comfy_config": "studio_config",
"comfy_execution": "studio_execution",
}
# Submodule renames applied to the target name after the root swap.
# ComfyUI's ``comfy/comfy_types`` lives at ``studio/node_types`` in this fork.
_SUBMODULE_RENAMES = {
"studio.comfy_types": "studio.node_types",
}
# External pip packages that share the ``comfy*``/``comfyui*`` prefix but are
# NOT part of the renamed core -- never alias these (they resolve normally).
# Mirrors the EXTERNAL_PACKAGES set in scripts/rename_modules.py.
_EXTERNAL = frozenset({
"comfy_kitchen",
"comfy_aimdo",
"comfyui_manager",
"comfyui_frontend_package",
"comfyui_workflow_templates",
"comfyui_embedded_docs",
})
def _target_name(fullname):
"""Return the ``studio*`` module name that ``fullname`` aliases, or None."""
root = fullname.split(".", 1)[0]
if root in _EXTERNAL or root not in _ROOTS:
return None
target = _ROOTS[root] + fullname[len(root):]
for src, dst in _SUBMODULE_RENAMES.items():
if target == src or target.startswith(src + "."):
target = dst + target[len(src):]
break
return target
# ---------------------------------------------------------------------------
# Class-name compatibility.
#
# The fork mechanically swapped the ``Comfy`` prefix for ``Studio`` on the
# public node-API classes (comfy_api.latest.ComfyExtension -> StudioExtension;
# comfy_api.latest.io.ComfyNode -> studio_api.latest.io.StudioNode, ...).
# Newer packs subclass ``io.ComfyNode`` / return ``ComfyExtension``. We apply
# the inverse swap as attribute aliases, lazily, the first time a pack imports
# the aliased module -- by which point studio_api.latest is safely importable.
# ---------------------------------------------------------------------------
def _mirror_comfy_prefix(module):
"""Bind ``Comfy<X>`` aliases for every public ``Studio<X>`` in ``module``."""
for name in list(vars(module)):
if name.startswith("Studio"):
alias = "Comfy" + name[len("Studio"):]
if not hasattr(module, alias):
setattr(module, alias, getattr(module, name))
def _mirror_studio_api_latest(module):
_mirror_comfy_prefix(module)
io_mod = getattr(module, "io", None) # studio_api.latest._io_public
if io_mod is not None:
_mirror_comfy_prefix(io_mod)
# target module name -> hook(module) run once, right after it is aliased.
_POST_IMPORT = {
"studio_api.latest": _mirror_studio_api_latest,
}
class _AliasLoader(importlib.abc.Loader):
def __init__(self, target):
self._target = target
def create_module(self, spec):
# Import (or reuse) the real module and hand it back as the alias.
# Returning an already-initialized module is why exec_module is a no-op;
# _init_module_attrs(override=False) preserves the target's __name__ etc.
module = importlib.import_module(self._target)
hook = _POST_IMPORT.get(self._target)
if hook is not None:
hook(module)
return module
def exec_module(self, module):
pass
class _AliasFinder(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
name = _target_name(fullname)
if name is None:
return None
return importlib.machinery.ModuleSpec(fullname, _AliasLoader(name))
def install():
"""Insert the alias finder at the head of ``sys.meta_path`` (idempotent)."""
if not any(isinstance(f, _AliasFinder) for f in sys.meta_path):
sys.meta_path.insert(0, _AliasFinder())
install()
+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
+160
View File
@@ -0,0 +1,160 @@
"""
Unit tests for the render-completion billing hook (middleware.billing_middleware).
No network, no torch, no GPU: aiohttp.ClientSession is replaced with a fake that
records every POST and lets each test dictate the response (200/4xx/5xx) or raise
a timeout. Covers the four things that matter for money-safe fire-and-forget
metering: a clean success emits exactly one correctly-shaped meter event, the
prompt_id rides through as the idempotency key, a timeout is swallowed (never
raises, never blocks) and retried once, and the local-dev guarantee an
unconfigured environment emits nothing at all.
"""
import asyncio
import pytest
from middleware import billing_middleware as bm
class _FakeResp:
def __init__(self, status=201, body=""):
self.status = status
self._body = body
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def text(self):
return self._body
class _FakeSession:
"""Drop-in for aiohttp.ClientSession. Captures posts; behavior is class state."""
calls: list = []
respond = staticmethod(lambda payload: _FakeResp(201)) # payload -> _FakeResp | raises
def __init__(self, *a, **kw):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
def post(self, url, json=None, headers=None):
_FakeSession.calls.append({"url": url, "json": json, "headers": headers})
return _FakeSession.respond(json) # may return a resp CM or raise
@pytest.fixture
def fake_http(monkeypatch):
_FakeSession.calls = []
_FakeSession.respond = staticmethod(lambda payload: _FakeResp(201))
monkeypatch.setattr(bm.aiohttp, "ClientSession", _FakeSession)
return _FakeSession
@pytest.fixture
def configured(monkeypatch):
monkeypatch.setenv("STUDIO_BILLING_URL", "http://commerce.test/v1/billing/meter-events")
monkeypatch.setenv("STUDIO_COMMERCE_TOKEN", "tok-123")
monkeypatch.setenv("STUDIO_BILLING_METER", "meter-render")
def _run(coro):
return asyncio.run(coro)
# ---------------------------------------------------------------------------
# count_outputs — quantity is the number of artifacts a completed render made
# ---------------------------------------------------------------------------
def test_count_outputs_sums_media_across_nodes():
hist = {
"outputs": {
"9": {"images": [{"filename": "a.png"}, {"filename": "b.png"}]},
"12": {"gifs": [{"filename": "c.webp"}]},
}
}
assert bm.count_outputs(hist) == 3
def test_count_outputs_handles_empty_and_none():
assert bm.count_outputs(None) == 0
assert bm.count_outputs({}) == 0
assert bm.count_outputs({"outputs": {"1": {"text": "no media here"}}}) == 0
# ---------------------------------------------------------------------------
# record_render — the fire-and-forget metered event
# ---------------------------------------------------------------------------
def test_success_emits_one_correct_event(fake_http, configured):
_run(bm.record_render(org_id="acme", prompt_id="p-1", n_outputs=2))
assert len(fake_http.calls) == 1
call = fake_http.calls[0]
assert call["url"] == "http://commerce.test/v1/billing/meter-events"
assert call["headers"]["Authorization"] == "Bearer tok-123"
events = call["json"]["events"]
assert len(events) == 1
evt = events[0]
assert evt["meterId"] == "meter-render"
assert evt["userId"] == "acme" # billing subject = IAM org
assert evt["value"] == 2 # quantity = n_outputs
assert evt["timestamp"].endswith("Z") # RFC3339 UTC
def test_idempotency_key_is_prompt_id(fake_http, configured):
_run(bm.record_render(org_id="acme", prompt_id="prompt-abc", n_outputs=1))
assert fake_http.calls[0]["json"]["events"][0]["idempotency"] == "prompt-abc"
def test_timeout_does_not_block_and_retries_once(fake_http, configured):
def _boom(payload):
raise asyncio.TimeoutError("simulated commerce timeout")
fake_http.respond = staticmethod(_boom)
# Must NOT raise — a render is never failed for billing.
_run(bm.record_render(org_id="acme", prompt_id="p-2", n_outputs=1))
# Fired the initial attempt + exactly one retry, then dropped.
assert len(fake_http.calls) == 2
def test_client_error_is_not_retried(fake_http, configured):
fake_http.respond = staticmethod(lambda payload: _FakeResp(400, "bad meter"))
_run(bm.record_render(org_id="acme", prompt_id="p-3", n_outputs=1))
assert len(fake_http.calls) == 1 # 4xx is deterministic — no retry
def test_server_error_is_retried_once(fake_http, configured):
fake_http.respond = staticmethod(lambda payload: _FakeResp(503, "unavailable"))
_run(bm.record_render(org_id="acme", prompt_id="p-4", n_outputs=1))
assert len(fake_http.calls) == 2 # 5xx is transient — retry once, then drop
def test_unconfigured_env_is_a_noop(fake_http, monkeypatch):
# Local dev: none of the STUDIO_BILLING_* vars set → zero behavior change.
monkeypatch.delenv("STUDIO_BILLING_URL", raising=False)
monkeypatch.delenv("STUDIO_COMMERCE_TOKEN", raising=False)
monkeypatch.delenv("STUDIO_BILLING_METER", raising=False)
_run(bm.record_render(org_id="acme", prompt_id="p-5", n_outputs=9))
assert fake_http.calls == [] # no HTTP attempted
def test_partial_config_is_a_noop(fake_http, monkeypatch):
# URL + token but no meter id → still a no-op (all three are required).
monkeypatch.setenv("STUDIO_BILLING_URL", "http://commerce.test/v1/billing/meter-events")
monkeypatch.setenv("STUDIO_COMMERCE_TOKEN", "tok-123")
monkeypatch.delenv("STUDIO_BILLING_METER", raising=False)
_run(bm.record_render(org_id="acme", prompt_id="p-6", n_outputs=1))
assert fake_http.calls == []
@@ -0,0 +1,189 @@
"""Tests for recording a completed render as an Asset in the Hanzo content lane.
These are CONTRACT tests. They stand up a real aiohttp server that speaks the
exact contract hanzoai/cloud serves the generic framework surface
(POST /v1/framework/Asset) and drive the real publisher against it over real
HTTP. The server side of that contract (the Asset DocType, its draft default, the
lifecycle hook that owns every status edge, cross-org isolation) is proven
independently against the real engine and a real SQLite by the content lane's own
Go tests in hanzoai/cloud (clients/content/content_test.go).
What is asserted here is Studio's half: that a finished render produces exactly one
Asset per output artifact, in the SAME field shape clients/content itself writes
(so a studio-initiated render is indistinguishable from a lane-initiated one); that
`file` is the org-scoped output key (the bytes Studio already persisted this
module uploads nothing); that every call is authenticated AS THE REQUESTING USER;
that assets land as drafts (never auto-published to a storefront); and that a
render is never failed when the lane is unreachable.
"""
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from middleware import content_publish
pytestmark = pytest.mark.asyncio # module is all-async
TOKEN = "the-users-iam-access-token"
PROMPT_ID = "b6f1c0e2-0000-4000-8000-000000000001"
WORKFLOW = {"3": {"class_type": "KSampler", "inputs": {"steps": 30}},
"4": {"class_type": "UNETLoader", "inputs": {"unet_name": "qwen-image-edit-2511.safetensors"}}}
HISTORY = {"outputs": {"9": {"images": [
{"filename": "life1_00001_.png", "subfolder": "swimwear/lifestyle2/valentina", "type": "output"},
{"filename": "life1_00002_.png", "subfolder": "swimwear/lifestyle2/valentina", "type": "output"},
]}, "10": {"images": [
{"filename": "preview.png", "subfolder": "", "type": "temp"}, # a preview is never an artifact
]}}}
EXTRA = {"design": "valentina", "kind": "lifestyle", "role": "life1",
"prompt": "poolside, golden hour", "project": "fashion", "tags": "valentina,life1"}
class Content:
"""A stand-in for hanzoai/cloud's framework surface, module 'marketing'."""
def __init__(self, fail: bool = False):
self.assets: list[dict] = []
self.fail = fail
def app(self) -> web.Application:
app = web.Application()
app.router.add_post("/v1/framework/Asset", self._asset)
return app
async def _asset(self, request):
if request.headers.get("Authorization") != f"Bearer {TOKEN}":
return web.json_response({"error": "valid principal required"}, status=403)
body = await request.json()
if self.fail:
return web.json_response({"error": "boom"}, status=500)
# The lane defaults status to draft; the client does not (and must not) set it.
doc = {**body, "name": "hash-id", "doctype": "Asset", "status": "draft"}
self.assets.append(doc)
return web.json_response(doc, status=201)
async def _publish(content, aiohttp_client, monkeypatch, **kw):
monkeypatch.setenv("STUDIO_CONTENT_PUBLISH", "1")
client: TestClient = await aiohttp_client(TestServer(content.app()))
monkeypatch.setattr(content_publish, "CLOUD_URL", str(client.make_url("")).rstrip("/"))
args = {"org_id": "karma", "prompt_id": PROMPT_ID, "workflow": WORKFLOW,
"history_result": HISTORY, "iam_token": TOKEN, "extra_data": EXTRA}
args.update(kw)
return await content_publish.publish_render(**args)
# --- pure helpers --------------------------------------------------------
async def test_artifacts_are_outputs_only_never_previews():
refs = content_publish.artifacts(HISTORY)
assert [r["filename"] for r in refs] == ["life1_00001_.png", "life1_00002_.png"]
async def test_object_key_is_the_org_scoped_output_key():
ref = {"filename": "life1_00001_.png", "subfolder": "swimwear/lifestyle2/valentina", "type": "output"}
assert content_publish.object_key("karma", ref) == \
"orgs/karma/output/swimwear/lifestyle2/valentina/life1_00001_.png"
# no subfolder collapses cleanly (no doubled slash)
assert content_publish.object_key("karma", {"filename": "x.png", "subfolder": "", "type": "output"}) == \
"orgs/karma/output/x.png"
async def test_kind_must_be_a_valid_select_value():
assert content_publish.kind_of({"kind": "lifestyle"}) == "lifestyle"
assert content_publish.kind_of({"kind": "nonsense"}) == "product" # off-list -> the default
assert content_publish.kind_of({}) == "product"
# --- the happy path ------------------------------------------------------
async def test_records_one_asset_per_artifact(aiohttp_client, monkeypatch):
content = Content()
assert await _publish(content, aiohttp_client, monkeypatch) == 2
assert len(content.assets) == 2
assert [a["title"] for a in content.assets] == ["life1_00001_.png", "life1_00002_.png"]
async def test_asset_matches_the_content_lane_field_shape(aiohttp_client, monkeypatch):
content = Content()
await _publish(content, aiohttp_client, monkeypatch)
a = content.assets[0]
# The exact fields clients/content writes (studio_render.go draftAsset).
assert a["file"] == "orgs/karma/output/swimwear/lifestyle2/valentina/life1_00001_.png"
assert a["kind"] == "lifestyle"
assert a["design"] == "valentina"
assert a["role"] == "life1"
assert a["source_prompt_id"] == PROMPT_ID
assert a["render_params"] == WORKFLOW # the graph reproduces it
assert a["generator"] == "qwen-image-edit-2511.safetensors"
assert a["project"] == "fashion" and a["tags"] == "valentina,life1"
async def test_assets_land_as_draft_never_auto_published(aiohttp_client, monkeypatch):
"""A render must never push an image to a storefront. The client never sends a
status; the lane defaults it to draft; a human moves it through the lifecycle."""
content = Content()
await _publish(content, aiohttp_client, monkeypatch)
assert all("status" not in c for c in [content_publish.asset_doc(
org_id="karma", prompt_id=PROMPT_ID, workflow=WORKFLOW, ref=r, extra_data=EXTRA
) for r in content_publish.artifacts(HISTORY)])
assert all(a["status"] == "draft" for a in content.assets)
# --- auth ----------------------------------------------------------------
async def test_written_as_the_requesting_user(aiohttp_client, monkeypatch):
# The stub 403s any call without the user's exact bearer, so a full publish is
# itself proof every write carried it.
content = Content()
assert await _publish(content, aiohttp_client, monkeypatch) == 2
async def test_without_a_user_token_nothing_is_written(aiohttp_client, monkeypatch):
content = Content()
assert await _publish(content, aiohttp_client, monkeypatch, iam_token=None) == 0
assert content.assets == []
# --- a render is never failed --------------------------------------------
async def test_disabled_is_a_no_op(aiohttp_client, monkeypatch):
content = Content()
monkeypatch.delenv("STUDIO_CONTENT_PUBLISH", raising=False)
monkeypatch.setattr(content_publish, "CLOUD_URL", "http://127.0.0.1:1")
# enabled() reads the env directly, so call the real entrypoint with it unset.
assert await content_publish.publish_render(
org_id="karma", prompt_id=PROMPT_ID, workflow=WORKFLOW,
history_result=HISTORY, iam_token=TOKEN, extra_data=EXTRA,
) == 0
assert content.assets == []
async def test_lane_error_is_survivable(aiohttp_client, monkeypatch):
content = Content(fail=True)
assert await _publish(content, aiohttp_client, monkeypatch) == 0 # nothing landed, no raise
async def test_unreachable_lane_never_raises(monkeypatch):
monkeypatch.setenv("STUDIO_CONTENT_PUBLISH", "1")
monkeypatch.setattr(content_publish, "CLOUD_URL", "http://127.0.0.1:1") # nothing listening
assert await content_publish.publish_render(
org_id="karma", prompt_id=PROMPT_ID, workflow=WORKFLOW,
history_result=HISTORY, iam_token=TOKEN, extra_data=EXTRA,
) == 0
async def test_empty_render_writes_nothing(aiohttp_client, monkeypatch):
content = Content()
assert await _publish(content, aiohttp_client, monkeypatch, history_result={"outputs": {}}) == 0
assert content.assets == []
# --- the token never leaks into history ----------------------------------
async def test_iam_token_is_a_sensitive_key_so_it_is_stripped_from_history():
# Force the CPU device path before the heavy import: model_management probes
# CUDA at import time and a GPU-less CI runner has no NVIDIA driver.
from studio.cli_args import args
args.cpu = True
import execution
assert "iam_token" in execution.SENSITIVE_EXTRA_DATA_KEYS
+160
View File
@@ -0,0 +1,160 @@
"""Tests for the Copilot backend: op validation + the tool-calling round trip.
No network and no node graph the LLM call (_post_chat) is monkeypatched and
node types are passed in as a set, so this stays as light as the other
middleware tests. Async paths run via asyncio.run to avoid a plugin dependency.
"""
import asyncio
import json
from middleware import copilot
VALID = {"KSampler", "SaveImage", "UpscaleModelLoader", "ImageUpscaleWithModel", "CLIPTextEncode"}
# --------------------------------------------------------------- validate_ops
def test_valid_ops_pass_through():
ops = [
{"op": "add_node", "type": "SaveImage", "id": "s1"},
{"op": "connect", "from_node": 3, "from_slot": "IMAGE", "to_node": "s1", "to_slot": "images"},
{"op": "set_widget", "node_id": 3, "name": "seed", "value": 42},
{"op": "layout"},
{"op": "queue"},
]
clean, warnings = copilot.validate_ops(ops, VALID)
assert clean == ops
assert warnings == []
def test_unknown_node_type_dropped():
clean, warnings = copilot.validate_ops(
[{"op": "add_node", "type": "TotallyFakeNode"}], VALID
)
assert clean == []
assert any("TotallyFakeNode" in w for w in warnings)
def test_unknown_op_dropped():
clean, warnings = copilot.validate_ops([{"op": "teleport", "node_id": 1}], VALID)
assert clean == []
assert any("unknown op" in w for w in warnings)
def test_missing_required_field_dropped():
clean, warnings = copilot.validate_ops([{"op": "set_widget", "node_id": 1}], VALID)
assert clean == []
assert any("missing name" in w for w in warnings)
def test_set_widget_zero_value_is_kept():
# value 0 / "" is meaningful and must not be treated as missing.
ops = [{"op": "set_widget", "node_id": 1, "name": "denoise", "value": 0}]
clean, warnings = copilot.validate_ops(ops, VALID)
assert clean == ops and warnings == []
def test_non_list_ops_safe():
assert copilot.validate_ops(None, VALID) == ([], [])
assert copilot.validate_ops("nope", VALID) == ([], [])
# ------------------------------------------------------------- _parse_completion
def _tool_completion(reply, ops):
return {
"choices": [
{
"message": {
"tool_calls": [
{
"function": {
"name": "edit_graph",
"arguments": json.dumps({"reply": reply, "ops": ops}),
}
}
]
}
}
]
}
def test_parse_tool_call():
reply, ops = copilot._parse_completion(
_tool_completion("done", [{"op": "queue"}])
)
assert reply == "done"
assert ops == [{"op": "queue"}]
def test_parse_content_json_fallback():
content = 'Sure! {"reply": "added", "ops": [{"op": "layout"}]}'
data = {"choices": [{"message": {"content": content}}]}
reply, ops = copilot._parse_completion(data)
assert ops == [{"op": "layout"}]
def test_parse_prose_only():
data = {"choices": [{"message": {"content": "That node upscales images."}}]}
reply, ops = copilot._parse_completion(data)
assert reply == "That node upscales images."
assert ops == []
def test_parse_malformed_completion():
assert copilot._parse_completion({}) == ("", [])
# ---------------------------------------------------------------- run_copilot
def test_run_copilot_validates_after_model(monkeypatch):
async def fake_post(url, token, payload):
# The model asks for a good op and a hallucinated node type.
return _tool_completion(
"added an upscale",
[
{"op": "add_node", "type": "ImageUpscaleWithModel", "id": "u1"},
{"op": "add_node", "type": "MadeUpNode"},
],
)
monkeypatch.setattr(copilot, "_post_chat", fake_post)
body = {"messages": [{"role": "user", "content": "add a 4x upscale"}], "graph_json": {}}
result = asyncio.run(
copilot.run_copilot(body, VALID, url="http://x", model="zen", token="")
)
assert result["ops"] == [{"op": "add_node", "type": "ImageUpscaleWithModel", "id": "u1"}]
assert "MadeUpNode" in result["reply"]
assert "added an upscale" in result["reply"]
def test_run_copilot_prose_no_ops(monkeypatch):
async def fake_post(url, token, payload):
return {"choices": [{"message": {"content": "KSampler denoises latents."}}]}
monkeypatch.setattr(copilot, "_post_chat", fake_post)
body = {"messages": [{"role": "user", "content": "what does ksampler do?"}]}
result = asyncio.run(
copilot.run_copilot(body, VALID, url="http://x", model="zen", token="")
)
assert result["ops"] == []
assert "KSampler" in result["reply"]
def test_run_copilot_sends_tool_and_system(monkeypatch):
captured = {}
async def fake_post(url, token, payload):
captured.update(payload)
return _tool_completion("ok", [])
monkeypatch.setattr(copilot, "_post_chat", fake_post)
body = {
"messages": [{"role": "user", "content": "hi"}],
"graph_json": {"1": {"class_type": "KSampler", "inputs": {}}},
"catalog": {"types": ["KSampler"], "nodes": {}},
}
asyncio.run(copilot.run_copilot(body, VALID, url="http://x", model="zen", token=""))
assert captured["tools"][0]["function"]["name"] == "edit_graph"
assert captured["messages"][0]["role"] == "system"
assert "KSampler" in captured["messages"][0]["content"]
# user turn is forwarded after the system prompt
assert captured["messages"][-1] == {"role": "user", "content": "hi"}
@@ -0,0 +1,54 @@
"""
Standalone tests for the engine selector (/v1/engines backing logic).
Thin wrapper over the per-org compute registry no GPU, no torch, no network.
"""
import pytest
import folder_paths
from middleware import compute_config, engine_selector
@pytest.fixture
def org(tmp_path, monkeypatch):
monkeypatch.setattr(folder_paths, "get_user_directory", lambda: str(tmp_path))
return "acme"
def test_local_always_present(org):
out = engine_selector.list_engines(org)
ids = [e["id"] for e in out["engines"]]
assert ids == ["local"]
assert out["default"] == "local"
def test_registered_worker_listed(org):
compute_config.register_worker(org, {
"worker_id": "gb10-1", "url": "http://gb10:8188",
"device": "cuda", "status": "ready", "gpu_model": "GB10", "vram_gb": 96,
})
out = engine_selector.list_engines(org)
ids = {e["id"] for e in out["engines"]}
assert ids == {"local", "gb10-1"}
gb10 = next(e for e in out["engines"] if e["id"] == "gb10-1")
assert gb10["type"] == "worker" and gb10["status"] == "ready" and gb10["device"] == "cuda"
def test_set_and_resolve_default(org):
compute_config.register_worker(org, {
"worker_id": "gb10-1", "url": "http://gb10:8188", "device": "cuda", "status": "ready",
})
compute_config.update_config(org, {"default_engine": "gb10-1"})
assert engine_selector.list_engines(org)["default"] == "gb10-1"
w = engine_selector.resolve_engine_worker(org)
assert w is not None and w.worker_id == "gb10-1"
def test_default_local_resolves_none(org):
assert engine_selector.resolve_engine_worker(org) is None
def test_stale_default_falls_back_to_local(org):
# Default names a worker that never registered → list reports local, no 500.
compute_config.update_config(org, {"default_engine": "ghost"})
assert engine_selector.list_engines(org)["default"] == "local"
assert engine_selector.resolve_engine_worker(org) is None
@@ -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
@@ -142,3 +142,57 @@ def test_public_paths(path):
@pytest.mark.parametrize("path", ["/prompt", "/api/prompt", "/userdata/x", "/v1/compute/config"])
def test_protected_paths(path):
assert m._is_public_path(path) is False
# --- server-owned login entry point (/login → OIDC code flow) ---
def test_login_path_is_public():
# /login is served by the middleware's own handler, so it must bypass the
# token gate (it is the very thing an unauthenticated user clicks).
assert m._is_public_path("/login") is True
def test_middleware_exposes_login_and_callback_handlers():
mw = m.create_iam_auth_middleware("https://hanzo.id/")
assert callable(getattr(mw, "handle_login", None))
assert callable(getattr(mw, "handle_callback", None))
def test_handle_login_unauthenticated_starts_oidc_code_flow():
"""The SPA button hits /login; the server builds the ONE authorize URL —
client_id=hanzo-studio, redirect_uri=<origin>/callback never a root redirect."""
import asyncio
from aiohttp.test_utils import make_mocked_request
mw = m.create_iam_auth_middleware("https://hanzo.id")
req = make_mocked_request(
"GET", "/login",
headers={"X-Forwarded-Proto": "https", "X-Forwarded-Host": "studio.hanzo.ai"},
)
resp = asyncio.run(mw.handle_login(req))
assert resp.status == 302
loc = resp.headers["Location"]
assert loc.startswith("https://hanzo.id/v1/iam/oauth/authorize?")
assert "client_id=hanzo-studio" in loc
# aiohttp returns the Location header URL-decoded; the wire value is %-encoded.
assert "redirect_uri=https://studio.hanzo.ai/callback" in loc
# The pending-login state cookie is set so /callback can verify PKCE + CSRF.
assert m._STATE_COOKIE in resp.cookies
def test_handle_login_returns_to_app_root_not_the_login_path():
"""Post-login must land on the app root ('/'), never bounce back to '/login'
(which would re-trigger the flow). The signed return path proves it."""
import asyncio
from aiohttp.test_utils import make_mocked_request
mw = m.create_iam_auth_middleware("https://hanzo.id")
req = make_mocked_request(
"GET", "/login",
headers={"X-Forwarded-Proto": "https", "X-Forwarded-Host": "studio.hanzo.ai"},
)
resp = asyncio.run(mw.handle_login(req))
cookie = resp.cookies[m._STATE_COOKIE].value
# State is HMAC-signed with the client_id key (see _sign_state usage).
payload = m._verify_state(cookie, "hanzo-studio")
assert payload is not None and payload["t"] == "/"
+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
+180
View File
@@ -0,0 +1,180 @@
"""Tests for the identity / session API and the active-org override.
The pure org-resolution rule is checked directly; the ``/v1/session`` +
``/v1/session/org`` + ``/v1/session/project`` + ``/logout`` routes are exercised
against a real aiohttp app with a middleware that injects the IAM user the auth
layer would have set, so cookie round-trips and status codes are the real thing.
"""
import asyncio
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from middleware import iam_auth_middleware as m
from middleware import session as s
# --- pure org resolution -------------------------------------------------
def test_resolve_org_switches_to_member_org():
assert s.resolve_org("acme", ["acme", "globex"], "globex") == "globex"
def test_resolve_org_rejects_non_member():
assert s.resolve_org("acme", ["acme"], "globex") == "acme"
def test_resolve_org_no_cookie_keeps_base():
assert s.resolve_org("acme", ["acme", "globex"], None) == "acme"
# --- org list extraction from claims ------------------------------------
def test_orgs_home_first_and_deduped():
orgs = m._orgs_from_claims({"owner": "acme", "organizations": ["acme", "globex"]})
assert orgs == ["acme", "globex"]
def test_orgs_from_comma_string():
assert m._orgs_from_claims({"owner": "acme", "orgs": "globex, initech"}) == ["acme", "globex", "initech"]
def test_orgs_from_group_dicts():
orgs = m._orgs_from_claims({"organization": "acme", "groups": [{"name": "globex"}, {"name": "acme"}]})
assert orgs == ["acme", "globex"]
def test_orgs_defaults_to_default():
assert m._orgs_from_claims({}) == ["default"]
def test_claims_to_user_carries_orgs():
user = m._claims_to_user({"owner": "acme", "organizations": ["globex"], "name": "A", "email": "a@x"})
assert user["org_id"] == "acme"
assert user["orgs"] == ["acme", "globex"]
# --- active-org override (auth-on path, copy not mutation) ---------------
def test_with_active_org_switches_and_copies():
base = {"org_id": "acme", "orgs": ["acme", "globex"], "sub": "u"}
got = m._with_active_org(base, "globex")
assert got["org_id"] == "globex"
assert base["org_id"] == "acme" # original never mutated -> cache not poisoned
def test_with_active_org_non_member_is_noop():
base = {"org_id": "acme", "orgs": ["acme"]}
assert m._with_active_org(base, "globex") is base
# --- route integration ---------------------------------------------------
def _app(iam_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()
s.add_session_routes(routes, None)
app.add_routes(routes)
return app
def _run(coro):
return asyncio.run(coro)
ANTJE = {"sub": "hanzo/antje", "name": "Antje", "email": "a@hanzo.ai",
"org_id": "hanzo", "orgs": ["hanzo", "labs"], "avatar": "https://cdn/a.png"}
def test_session_returns_identity():
async def go():
async with TestClient(TestServer(_app(ANTJE))) as c:
r = await c.get("/v1/session")
assert r.status == 200
return await r.json()
body = _run(go())
assert body["authenticated"] is True
assert body["user"] == "Antje"
assert body["email"] == "a@hanzo.ai"
assert body["org"] == "hanzo"
assert body["orgs"] == ["hanzo", "labs"]
assert body["project"] == "default"
assert body["console_url"].startswith("http")
def test_session_local_stub_when_unauthenticated():
async def go():
async with TestClient(TestServer(_app(None))) as c:
r = await c.get("/v1/session")
return r.status, await r.json()
status, body = _run(go())
assert status == 200
assert body["authenticated"] is False
assert body["user"] == "Local User"
def test_org_switch_sets_cookie_and_rescopes():
async def go():
async with TestClient(TestServer(_app(ANTJE))) as c:
r = await c.post("/v1/session/org", json={"org": "labs"})
assert r.status == 200
assert (await r.json())["org"] == "labs"
# The cookie the switch set now re-scopes the session response.
r2 = await c.get("/v1/session")
return (await r2.json())["org"]
assert _run(go()) == "labs"
def test_org_switch_rejects_non_member():
async def go():
async with TestClient(TestServer(_app(ANTJE))) as c:
r = await c.post("/v1/session/org", json={"org": "evilcorp"})
return r.status
assert _run(go()) == 403
def test_org_switch_requires_org():
async def go():
async with TestClient(TestServer(_app(ANTJE))) as c:
r = await c.post("/v1/session/org", json={})
return r.status
assert _run(go()) == 400
def test_project_switch_roundtrips():
async def go():
async with TestClient(TestServer(_app(ANTJE))) as c:
r = await c.post("/v1/session/project", json={"project": "fashion"})
assert r.status == 200
r2 = await c.get("/v1/session")
return (await r2.json())["project"]
assert _run(go()) == "fashion"
def test_project_switch_rejects_path_traversal():
async def go():
async with TestClient(TestServer(_app(ANTJE))) as c:
r = await c.post("/v1/session/project", json={"project": "../etc"})
return r.status
assert _run(go()) == 400
def test_logout_clears_session_cookie_and_redirects():
async def go():
async with TestClient(TestServer(_app(ANTJE))) as c:
r = await c.get("/logout", allow_redirects=False)
return r.status, r.headers.get("Location", ""), r.headers
status, location, headers = _run(go())
assert status == 302
assert "/login" in location
# The auth cookie is expired (cleared) by the logout response.
set_cookies = "; ".join(headers.getall("Set-Cookie", []))
assert s.SESSION_COOKIE in set_cookies
@@ -0,0 +1,170 @@
"""
Standalone tests for SqlitePromptQueue Hanzo Studio's crash-durable render
queue. A fake torch-free `execution` module supplies the PromptQueue base, so
this runs with no GPU, no torch, and no external process (stdlib sqlite3 only).
Covers the queue seam (put/get/task_done), exactly-once claim, retry-on-error,
and the headline crash-recovery: a job claimed and then abandoned by a
"crashed" queue instance is reaped and re-run to completion by a fresh instance
on the same DB file.
"""
import sys
import threading
import types
from collections import namedtuple
import pytest
ExecStatus = namedtuple("ExecStatus", ["status_str", "completed", "messages"])
def _fake_execution_module():
mod = types.ModuleType("execution")
class PromptQueue:
def __init__(self, server):
self.server = server
self.mutex = threading.RLock()
self.task_counter = 0
self.queue = []
self.currently_running = {}
self.history = {}
self.flags = {}
self._persist_path = None
def task_done(self, item_id, history_result, status, process_item=None):
self.currently_running.pop(item_id, None)
mod.PromptQueue = PromptQueue
return mod
class FakeServer:
def __init__(self):
self.updates = 0
def queue_updated(self):
self.updates += 1
@pytest.fixture
def tqmod(monkeypatch):
monkeypatch.setenv("STUDIO_TASKS_POLL_INTERVAL", "0.05")
monkeypatch.setenv("STUDIO_TASKS_LEASE_MS", "300") # short lease → fast reap
monkeypatch.setenv("STUDIO_TASKS_MAX_ATTEMPTS", "3")
saved = {k: sys.modules.get(k) for k in ("execution", "middleware.tasks_queue")}
sys.modules["execution"] = _fake_execution_module()
sys.modules.pop("middleware.tasks_queue", None)
import middleware.tasks_queue as tq
try:
yield tq
finally:
for k, v in saved.items():
if v is None:
sys.modules.pop(k, None)
else:
sys.modules[k] = v
def _item(pid="prompt-1"):
return (0, pid, {"1": {"class_type": "EmptyImage"}}, {"org_id": "acme"}, ["1"], {})
def _queue(tqmod, tmp_path, freeze_hb=True):
import os
os.environ["STUDIO_QUEUE_DB"] = str(tmp_path / "queue.db")
q = tqmod.SqlitePromptQueue(FakeServer())
if freeze_hb:
q._hb_stop.set() # deterministic: no background reaper/heartbeat
return q
def test_serde_roundtrip(tqmod):
assert tqmod.deserialize_item(tqmod.serialize_item(_item())) == _item()
def test_put_get_task_done(tqmod, tmp_path):
q = _queue(tqmod, tmp_path)
q.put(_item())
assert q.server.updates == 1
item, item_id = q.get(timeout=1.0)
assert item == _item()
assert q.currently_running[item_id] == _item()
assert q.job_state("prompt-1")[0] == "claimed"
q.task_done(item_id, {"outputs": {"2": {"images": [{"filename": "x_00001_.png"}]}}},
ExecStatus("success", True, []))
assert q.job_state("prompt-1")[0] == "done"
assert item_id not in q._job_by_item
def test_put_is_idempotent(tqmod, tmp_path):
q = _queue(tqmod, tmp_path)
q.put(_item())
q.put(_item()) # same prompt_id → INSERT OR IGNORE, no duplicate
q.get(timeout=1.0)
assert q.get(timeout=0.2) is None # only one job existed
def test_exactly_once_claim(tqmod, tmp_path):
q = _queue(tqmod, tmp_path)
q.put(_item())
first = q.get(timeout=1.0)
assert first is not None
assert q.get(timeout=0.2) is None # already claimed — no double dispatch
def test_error_retries_then_fails(tqmod, tmp_path):
q = _queue(tqmod, tmp_path)
q.put(_item()) # max_attempts=3
# attempt 1 → error → back to pending (attempt 2)
_, item_id = q.get(timeout=1.0)
q.task_done(item_id, {}, ExecStatus("error", False, ["boom-1"]))
assert q.job_state("prompt-1") == ("pending", 2)
# attempt 2 → error → pending (attempt 3)
_, item_id = q.get(timeout=1.0)
q.task_done(item_id, {}, ExecStatus("error", False, ["boom-2"]))
assert q.job_state("prompt-1") == ("pending", 3)
# attempt 3 → error → terminal failed
_, item_id = q.get(timeout=1.0)
q.task_done(item_id, {}, ExecStatus("error", False, ["boom-3"]))
assert q.job_state("prompt-1")[0] == "failed"
def test_crash_recovery_reap(tqmod, tmp_path):
import time
q = _queue(tqmod, tmp_path)
q.put(_item())
_, _item_id = q.get(timeout=1.0) # claimed, leased
assert q.job_state("prompt-1")[0] == "claimed"
time.sleep(0.4) # lease (0.3s) expires: worker "crashed"
assert q.get(timeout=1.0) is not None # get() reaps + re-claims for a new attempt
assert q.job_state("prompt-1") == ("claimed", 2)
def test_crash_recovery_across_instances(tqmod, tmp_path):
"""The headline: a job in flight when a queue instance dies is picked up
and completed by a fresh instance on the same DB file."""
import time
db = str(tmp_path / "queue.db")
import os
os.environ["STUDIO_QUEUE_DB"] = db
# Instance A: submit, claim, then "crash" (drop the instance without completing).
a = tqmod.SqlitePromptQueue(FakeServer())
a._hb_stop.set()
a.put(_item())
a.get(timeout=1.0)
assert a.job_state("prompt-1")[0] == "claimed"
a.stop() # process dies mid-render
time.sleep(0.4) # lease expires
# Instance B: same DB. Recovers the orphaned job and finishes it.
b = tqmod.SqlitePromptQueue(FakeServer()) # __init__ reaps on boot
b._hb_stop.set()
item, item_id = b.get(timeout=1.0)
assert item == _item()
assert b.job_state("prompt-1") == ("claimed", 2) # attempt 2 on the new worker
b.task_done(item_id, {"outputs": {}}, ExecStatus("success", True, []))
assert b.job_state("prompt-1")[0] == "done"
b.stop()
+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"
@@ -0,0 +1,342 @@
"""Unit tests for the Studio Home surface (middleware/studio_home.py)."""
import json
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from middleware import studio_home as sh
def test_reseed_randomizes_every_sampler_seed():
graph = {
"a": {"class_type": "KSampler", "inputs": {"seed": 7, "steps": 30}},
"b": {"class_type": "KSamplerAdvanced", "inputs": {"noise_seed": 7}},
"c": {"class_type": "SaveImage", "inputs": {"filename_prefix": "x"}},
}
out = sh._reseed(json.loads(json.dumps(graph)))
assert out["a"]["inputs"]["steps"] == 30
# 2**31 space: a collision with the old seed twice over is not a thing.
assert (out["a"]["inputs"]["seed"], out["b"]["inputs"]["noise_seed"]) != (7, 7)
def test_fix_graph_keeps_recipe_invariants():
g = sh._fix_graph("ref.png", "make the straps thicker", "fixes/test")
ks = g["13"]["inputs"]
assert ks["cfg"] == 4.0 and ks["steps"] == 30 # proven floor, not the weak 3.0
assert g["12"]["class_type"] == "CFGNorm"
assert g["8"]["inputs"]["reference_latents_method"] == "index_timestep_zero"
assert g["6"]["inputs"]["image1"] == ["2", 0] # scaled ref, never raw
assert "straps thicker" in g["6"]["inputs"]["prompt"]
assert g["15"]["inputs"]["filename_prefix"] == "fixes/test"
def test_safe_asset_blocks_traversal(tmp_path):
root = tmp_path / "out"
(root / "designs").mkdir(parents=True)
(root / "designs" / "a.png").write_bytes(b"x")
(tmp_path / "secret.txt").write_text("no")
assert sh._safe_asset(root, "designs/a.png") is not None
assert sh._safe_asset(root, "../secret.txt") is None
assert sh._safe_asset(root, "designs/missing.png") is None
def test_status_vocab_matches_library_lifecycle():
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
async def test_home_redirect_only_hits_root_html(monkeypatch):
from aiohttp import web
from aiohttp.test_utils import make_mocked_request
monkeypatch.setenv("STUDIO_HOME_DEFAULT", "1")
async def handler(_):
return web.Response(text="spa")
# root + html accept -> redirect to /studio
req = make_mocked_request("GET", "/", headers={"Accept": "text/html"})
with pytest.raises(web.HTTPFound) as e:
await sh.home_redirect(req, handler)
assert e.value.location == "/studio"
# advanced escape hatch and non-root paths pass through untouched
for path, accept in [("/?advanced=1", "text/html"), ("/assets/x.js", "*/*"), ("/", "application/json")]:
req = make_mocked_request("GET", path, headers={"Accept": accept})
resp = await sh.home_redirect(req, handler)
assert resp.text == "spa"
# env off (local dev / worker pods): root is untouched
monkeypatch.delenv("STUDIO_HOME_DEFAULT")
req = make_mocked_request("GET", "/", headers={"Accept": "text/html"})
resp = await sh.home_redirect(req, handler)
assert resp.text == "spa"
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -1 +1 @@
{"id": "ade0e429-67bf-4e6d-adf4-88747094a690", "revision": 0, "last_node_id": 11, "last_link_id": 10, "nodes": [{"id": 1, "type": "UNETLoader", "pos": [40, 40], "size": [270, 120], "flags": {}, "order": 0, "mode": 0, "inputs": [], "outputs": [{"name": "MODEL", "type": "MODEL", "links": [4], "slot_index": 0}], "properties": {"Node name for S&R": "UNETLoader"}, "widgets_values": ["flux-2-klein-4b.safetensors", "default"]}, {"id": 2, "type": "CLIPLoader", "pos": [40, 200], "size": [270, 120], "flags": {}, "order": 1, "mode": 0, "inputs": [], "outputs": [{"name": "CLIP", "type": "CLIP", "links": [1, 2], "slot_index": 0}], "properties": {"Node name for S&R": "CLIPLoader"}, "widgets_values": ["flux2-text-encoder.safetensors", "flux2", "default"]}, {"id": 3, "type": "VAELoader", "pos": [40, 360], "size": [270, 120], "flags": {}, "order": 2, "mode": 0, "inputs": [], "outputs": [{"name": "VAE", "type": "VAE", "links": [9], "slot_index": 0}], "properties": {"Node name for S&R": "VAELoader"}, "widgets_values": ["flux2-vae.safetensors"]}, {"id": 4, "type": "CLIPTextEncode", "pos": [360, 40], "size": [400, 200], "flags": {}, "order": 3, "mode": 0, "inputs": [{"name": "clip", "type": "CLIP", "link": 1}], "outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [3], "slot_index": 0}], "properties": {"Node name for S&R": "CLIPTextEncode"}, "widgets_values": ["professional swimwear product photography, single-piece designer bikini on a model, beach at golden hour, editorial fashion lighting, crisp detail, high-end catalog, 85mm, shallow depth of field"], "title": "Positive"}, {"id": 5, "type": "FluxGuidance", "pos": [360, 280], "size": [270, 120], "flags": {}, "order": 4, "mode": 0, "inputs": [{"name": "conditioning", "type": "CONDITIONING", "link": 3}], "outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [5], "slot_index": 0}], "properties": {"Node name for S&R": "FluxGuidance"}, "widgets_values": [3.5]}, {"id": 6, "type": "CLIPTextEncode", "pos": [360, 400], "size": [400, 120], "flags": {}, "order": 5, "mode": 0, "inputs": [{"name": "clip", "type": "CLIP", "link": 2}], "outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [6], "slot_index": 0}], "properties": {"Node name for S&R": "CLIPTextEncode"}, "widgets_values": [""], "title": "Negative"}, {"id": 7, "type": "EmptyFlux2LatentImage", "pos": [360, 560], "size": [270, 120], "flags": {}, "order": 6, "mode": 0, "inputs": [], "outputs": [{"name": "LATENT", "type": "LATENT", "links": [7], "slot_index": 0}], "properties": {"Node name for S&R": "EmptyFlux2LatentImage"}, "widgets_values": [1024, 1536, 1]}, {"id": 8, "type": "KSampler", "pos": [800, 40], "size": [270, 120], "flags": {}, "order": 7, "mode": 0, "inputs": [{"name": "model", "type": "MODEL", "link": 4}, {"name": "positive", "type": "CONDITIONING", "link": 5}, {"name": "negative", "type": "CONDITIONING", "link": 6}, {"name": "latent_image", "type": "LATENT", "link": 7}], "outputs": [{"name": "LATENT", "type": "LATENT", "links": [8], "slot_index": 0}], "properties": {"Node name for S&R": "KSampler"}, "widgets_values": [0, "fixed", 24, 1.0, "euler", "simple", 1.0]}, {"id": 9, "type": "VAEDecode", "pos": [1160, 40], "size": [270, 120], "flags": {}, "order": 8, "mode": 0, "inputs": [{"name": "samples", "type": "LATENT", "link": 8}, {"name": "vae", "type": "VAE", "link": 9}], "outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [10], "slot_index": 0}], "properties": {"Node name for S&R": "VAEDecode"}, "widgets_values": []}, {"id": 10, "type": "SaveImage", "pos": [1360, 40], "size": [380, 380], "flags": {}, "order": 9, "mode": 0, "inputs": [{"name": "images", "type": "IMAGE", "link": 10}], "outputs": [], "properties": {"Node name for S&R": "SaveImage"}, "widgets_values": ["swimwear/product"]}, {"id": 11, "type": "Note", "pos": [40, 520], "size": [400, 300], "flags": {}, "order": 10, "mode": 0, "inputs": [], "outputs": [], "properties": {}, "widgets_values": ["FASHION PRODUCT SHOT \u2014 FLUX.2 klein (generic text-to-image)\n\nAll model files present (UNET/text-encoder/VAE). This is the FAST generic\nconcepting model \u2014 for ANTJE'S DESIGNS use the per-design tabs instead:\n shoot_<design> = editorial lifestyle scene from her CAD\n product_<design> = catalog shot from her CAD\nThose run Qwen-Image-Edit-2511 (design-exact). This klein tab is for\nquick mood/layout ideas only \u2014 small model, weak on hard poses."], "color": "#432", "bgcolor": "#653"}], "links": [[1, 2, 0, 4, 0, "CLIP"], [2, 2, 0, 6, 0, "CLIP"], [3, 4, 0, 5, 0, "CONDITIONING"], [4, 1, 0, 8, 0, "MODEL"], [5, 5, 0, 8, 1, "CONDITIONING"], [6, 6, 0, 8, 2, "CONDITIONING"], [7, 7, 0, 8, 3, "LATENT"], [8, 8, 0, 9, 0, "LATENT"], [9, 3, 0, 9, 1, "VAE"], [10, 9, 0, 10, 0, "IMAGE"]], "groups": [], "config": {}, "extra": {"ds": {"scale": 0.8, "offset": [0, 0]}}, "version": 0.4}
{"id": "ade0e429-67bf-4e6d-adf4-88747094a690", "revision": 0, "last_node_id": 11, "last_link_id": 10, "nodes": [{"id": 1, "type": "UNETLoader", "pos": [40, 620], "size": [320, 82], "flags": {"collapsed": false}, "order": 0, "mode": 0, "inputs": [], "outputs": [{"name": "MODEL", "type": "MODEL", "links": [4], "slot_index": 0}], "properties": {"Node name for S&R": "UNETLoader"}, "widgets_values": ["flux-2-klein-4b.safetensors", "default"]}, {"id": 2, "type": "CLIPLoader", "pos": [40, 740], "size": [320, 106], "flags": {"collapsed": false}, "order": 1, "mode": 0, "inputs": [], "outputs": [{"name": "CLIP", "type": "CLIP", "links": [1, 2], "slot_index": 0}], "properties": {"Node name for S&R": "CLIPLoader"}, "widgets_values": ["flux2-text-encoder.safetensors", "flux2", "default"]}, {"id": 3, "type": "VAELoader", "pos": [40, 890], "size": [320, 58], "flags": {"collapsed": false}, "order": 2, "mode": 0, "inputs": [], "outputs": [{"name": "VAE", "type": "VAE", "links": [9], "slot_index": 0}], "properties": {"Node name for S&R": "VAELoader"}, "widgets_values": ["flux2-vae.safetensors"]}, {"id": 4, "type": "CLIPTextEncode", "pos": [360, 40], "size": [400, 200], "flags": {}, "order": 3, "mode": 0, "inputs": [{"name": "clip", "type": "CLIP", "link": 1}], "outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [3], "slot_index": 0}], "properties": {"Node name for S&R": "CLIPTextEncode"}, "widgets_values": ["professional swimwear product photography, single-piece designer bikini on a model, beach at golden hour, editorial fashion lighting, crisp detail, high-end catalog, 85mm, shallow depth of field"], "title": "Positive"}, {"id": 5, "type": "FluxGuidance", "pos": [360, 280], "size": [270, 120], "flags": {}, "order": 4, "mode": 0, "inputs": [{"name": "conditioning", "type": "CONDITIONING", "link": 3}], "outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [5], "slot_index": 0}], "properties": {"Node name for S&R": "FluxGuidance"}, "widgets_values": [3.5]}, {"id": 6, "type": "CLIPTextEncode", "pos": [360, 400], "size": [400, 120], "flags": {}, "order": 5, "mode": 0, "inputs": [{"name": "clip", "type": "CLIP", "link": 2}], "outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [6], "slot_index": 0}], "properties": {"Node name for S&R": "CLIPTextEncode"}, "widgets_values": [""], "title": "Negative"}, {"id": 7, "type": "EmptyFlux2LatentImage", "pos": [360, 560], "size": [270, 120], "flags": {}, "order": 6, "mode": 0, "inputs": [], "outputs": [{"name": "LATENT", "type": "LATENT", "links": [7], "slot_index": 0}], "properties": {"Node name for S&R": "EmptyFlux2LatentImage"}, "widgets_values": [1024, 1536, 1]}, {"id": 8, "type": "KSampler", "pos": [1280, 300], "size": [320, 262], "flags": {"collapsed": false}, "order": 7, "mode": 0, "inputs": [{"name": "model", "type": "MODEL", "link": 4}, {"name": "positive", "type": "CONDITIONING", "link": 5}, {"name": "negative", "type": "CONDITIONING", "link": 6}, {"name": "latent_image", "type": "LATENT", "link": 7}], "outputs": [{"name": "LATENT", "type": "LATENT", "links": [8], "slot_index": 0}], "properties": {"Node name for S&R": "KSampler"}, "widgets_values": [0, "fixed", 24, 1.0, "euler", "simple", 1.0]}, {"id": 9, "type": "VAEDecode", "pos": [1680, 300], "size": [240, 58], "flags": {"collapsed": false}, "order": 8, "mode": 0, "inputs": [{"name": "samples", "type": "LATENT", "link": 8}, {"name": "vae", "type": "VAE", "link": 9}], "outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [10], "slot_index": 0}], "properties": {"Node name for S&R": "VAEDecode"}, "widgets_values": []}, {"id": 10, "type": "SaveImage", "pos": [1680, 420], "size": [360, 300], "flags": {"collapsed": false}, "order": 9, "mode": 0, "inputs": [{"name": "images", "type": "IMAGE", "link": 10}], "outputs": [], "properties": {"Node name for S&R": "SaveImage"}, "widgets_values": ["swimwear/product"]}, {"id": 11, "type": "Note", "pos": [40, 0], "size": [340, 220], "flags": {"collapsed": false}, "order": 10, "mode": 0, "inputs": [], "outputs": [], "properties": {}, "widgets_values": ["FASHION PRODUCT SHOT \u2014 FLUX.2 klein (generic text-to-image)\n\nAll model files present (UNET/text-encoder/VAE). This is the FAST generic\nconcepting model \u2014 for ANTJE'S DESIGNS use the per-design tabs instead:\n shoot_<design> = editorial lifestyle scene from her CAD\n product_<design> = catalog shot from her CAD\nThose run Qwen-Image-Edit-2511 (design-exact). This klein tab is for\nquick mood/layout ideas only \u2014 small model, weak on hard poses."], "color": "#432", "bgcolor": "#653"}], "links": [[1, 2, 0, 4, 0, "CLIP"], [2, 2, 0, 6, 0, "CLIP"], [3, 4, 0, 5, 0, "CONDITIONING"], [4, 1, 0, 8, 0, "MODEL"], [5, 5, 0, 8, 1, "CONDITIONING"], [6, 6, 0, 8, 2, "CONDITIONING"], [7, 7, 0, 8, 3, "LATENT"], [8, 8, 0, 9, 0, "LATENT"], [9, 3, 0, 9, 1, "VAE"], [10, 9, 0, 10, 0, "IMAGE"]], "groups": [], "config": {}, "extra": {"ds": {"scale": 0.8, "offset": [0, 0]}}, "version": 0.4}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More