P1 — the primary Create now produces something, and Chat reaches the gateway: - middleware/gateway_auth.py: ONE fail-closed allowlist for the browser-edge gateway bearer — a server key first, else pk-/asymmetric-JWT only; the HS256 hanzo.id session token and sk-/hk-/rk- secrets are NEVER forwarded (the old "unsupported signing method: HS256" 502). copilot._resolve_target adopts it. - Z-Image-Turbo text-to-image lane: _image_graph + dispatch_image + POST /v1/library/image. The composer's default up-arrow (words, no photo) now generates an image instead of the old "attach a photo first" dead end. - composer defaults to an IMAGE model (hz_create_model, decoupled from the chat model); routes by modality (assistant -> Chat via window.HanzoChat, else image/video/fix/compose); create() ALWAYS shows a generating state + friendly retry, never a silent no-op. - shell.js: copilot errors render one friendly line (never raw JSON); chat empty state (assistant identity + 3 starters); one public HanzoChat.send hook. P2 — friction on the common path: - first-class Download on every tile AND in the viewer (PNG / JPG / WebP) - opaque fixed header + sticky live-bar (content no longer scrolls under them) - ARCHIVED badge only on truly deleted/flagged items (active work is unbadged) - mobile: ~30 filter pills collapse to one scrollable row; tab-bar scroll fade P3 — composer pill tooltips, per-model descriptions, code model hidden, and the </> request-preview gated behind Developers mode. Tests: gateway_auth_test (allowlist refuses HS256/sk-, accepts pk-/asymmetric), copilot_test (never forwards the HS256 session token), create_image_test (Z-Image graph shape + served-HTML invariants: default-model-is-image, Create-always-shows-a-state, first-class Download). Green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
22 KiB
Hanzo Studio
Overview
The most powerful and modular visual AI engine and application.
Tech Stack
- Language: Python
Build & Run
uv sync
uv run pytest
Structure
studio/
CODEOWNERS
CONTRIBUTING.md
Dockerfile
LICENSE
QUANTIZATION.md
README.md
alembic.ini
alembic_db/
api_server/
app/
blueprints/
branding/
comfyui.log
comfyui.prev.log
comfyui.prev2.log
Key Files
README.md-- Project documentationpyproject.toml-- Python project configDockerfile-- Container build (appliesbranding/then runsmain.py)hanzo.yml+.github/workflows/cicd.yml-- canonical CI/CD (hanzoai/ci); buildsghcr.io/hanzoai/studio, rolls thestudiooperator Service CR atstudio.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 inrequirements.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.shclones 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).ultralyticsis 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:
Dockerfileruns the installer afterCOPY . .;.dockerignorere-includes only the manifest + installer (custom_nodes/*/scripts/*form). Sogit push origin main→ CI buildsghcr.io/hanzoai/studiowith 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.
- Auth (
middleware/iam_auth_middleware.py): local JWKS JWT validation against${STUDIO_IAM_URL}/v1/iam/.well-known/jwks— signature +exp+iss+aud(client_idhanzo-studio). Org from theownerclaim (organizationfallback) →request["iam_user"]["org_id"]. Anonymous mode: off by default locally (loopback bypass); enable in cloud withSTUDIO_ENABLE_IAM_AUTH=true(the one auth switch — this is the "STUDIO_AUTH=off default" behavior). Browser flow: unauth HTML → IAMauthorize(code+PKCE) →/callbackexchanges the code, sets an httpOnly session cookie (no frontend changes).verify_jwt(...)is a pure, unit-tested function. - Tenancy (
folder_paths.py,app/user_manager.py): withSTUDIO_MULTI_TENANT=true, userdata isuser/orgs/{org}/user/{user}/…and outputs areoutput/orgs/{org}/….get_public_user_directory(user, org)is org-rooted (fixed the bug where org userdata always resolved toNone). Execution outputs are scoped viafolder_paths.set_execution_org(), bound by the prompt worker from the queue item'sorg_id. - GPU federation (
middleware/{worker_client,prompt_router,compute_config, visor_client}.py):/v1/workers/register(heartbeat),/v1/workers(list),/v1/worker/execute(in-cluster push fast path). Worker↔coordinator trust viaX-Worker-Token(STUDIO_WORKER_TOKEN, KMS-sourced). - Per-GPU gpu-jobs queue (
middleware/{gpu_dispatch,studio_home}.py,server.py) — the ONE submit path onto a GPU. A model-less coordinator'sPOST /promptenqueues to the cloud tasks engine (POST {STUDIO_CLOUD_API_URL}/v1/tasks/namespaces/gpu-jobs/activities, user bearer → org-scoped); a BYO worker (hanzo gpu connect) claims + runs it. Targeting: the Home "Run on " chip setsX-Target-GPU: <identity>;dispatch_if_workerenqueues on that machine's own lanetaskQueue="gpu:<identity>"(namespace staysgpu-jobs), else the sharedgpu-jobslane any worker drains —X-Target-GPUrides through_queue_promptso fix/compose/video/rerun all honor it. Authoritative visible queue:/v1/render-queue+/v1/nodesread the tasks engine's activity list — REAL claimingidentity, status SCHEDULED/STARTED/COMPLETED/FAILED → queued/running/done/failed,lastHeartbeatTimeliveness, per-node depth — not a local shadow;render_jobs.jsonsurvives only as a ~2s pre-claim placeholder. No hidden runs: in--worker-modea direct/promptPOST is refused 403 unless it carriesX-Worker-Token— the only accepted execution is a claimed job handed in overPOST /v1/worker/execute(worker_client.reject_untrusted_worker_submit, one policy gate). Legacy push (prompt_router) is OFF unlessSTUDIO_LEGACY_PUSH_ROUTER=1. Tests:middleware_test/{gpu_dispatch_test,worker_seam_test}.py. - Durable render queue (
middleware/tasks_queue.py,docs/federation.md):SqlitePromptQueue— crash-durable render queue in a single SQLite file (stdlibsqlite3, WAL, zero external processes).STUDIO_QUEUE_DB=<path>→server.pyswapsPromptQueuefor it (precedence STUDIO_QUEUE_DB > STUDIO_PERSIST_QUEUE > memory; default local behavior unchanged). ThePromptQueueseam 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 topending(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. - Stacks (
middleware/stacks_store.py,middleware/studio_home.py): Procreate- style gallery folders — a per-org SQLite store (orgs/<org>/stacks.db, WAL, every mutation underBEGIN IMMEDIATE, the sametasks_queue.pypattern) that is ONLY an organizational layer; assets stay the source of truth (library.json + files). Schema = the spec'sstacks+stack_assetsjoin;UNIQUE(asset_id)⇒ one Stack per asset. Tenancy is structural (one DB file per org → cross-org 404s by construction). REST (/v1house style):GET/POST /v1/stacks,GET/PATCH/DELETE /v1/stacks/:id(?mode=only|images),POST|DELETE /v1/stacks/:id/assets,PATCH /v1/stacks/:id/order,POST /v1/stacks/:id/unstack,GET/PATCH /v1/stacks/settings; registered via injected resolvers (org/owner/workspace + the library soft-deleter) so the module stays decoupled. Remove ≠ delete (returns to the gallery); Delete-Stack defaults to preserving; "…and Images" maps to the library soft-delete (status=deleted, files kept). Generation integration:fix/compose/rerun/templateaccept astackid and the ingest point (_index_upload→stacks_store.absorb) assigns membership when outputs land (ALL outputs of a multi-output job); a per-orgauto_stack_batchessetting auto-creates"<prompt> — <date>"Stacks. UI is the vanilla-JS home (studio_home.html): layered cards, drag/multi-select/mobile-tap parity (no DnD ever required), a11y labels + live region. Tested:studio_home_test/test_stacks.py(routes, tenancy, one-per-asset, order persistence, delete/unstack, ingest→absorb). - Engine selector (
middleware/engine_selector.py):/v1/engineslists execution targets for the org (local+ registered compute_config workers) andPUT /v1/engines/defaultsets a per-org default (stored onComputeProfile.default_engine).prompt_router.route_promptroutes to the chosen worker;local= unchanged. Leased cloud machines (VisorGET /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. BackendPOST /v1/copilot/chatproxies one OpenAI tool-calling round to a chat endpoint (STUDIO_COPILOT_URL; default = local engine127.0.0.1:1234if its port is open, elseapi.hanzo.ai;STUDIO_COPILOT_MODELdefaultzen; token fromSTUDIO_COPILOT_TOKEN→STUDIO_COMMERCE_TOKEN→request bearer). It returns{reply, ops};add_nodeops are validated againstnodes.NODE_CLASS_MAPPINGSserver-side so the model can't invent node types. The frontend applies ops againstapp.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 bybranding/patch_copilot.py(copies the bundle into static/, CSS<link>in<head>, deferred<script>before</body>; idempotent, wired as step 9 ofapply-branding.sh) — survives frontend upgrades.window.HanzoCopilot.applyOpsis exposed for tests. Tab registered viaapp.extensionManager.registerSidebarTab(idhanzo-copilot). Tested:middleware_test/copilot_test.py(op-validation + mocked tool loop) andbranding/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,/v1house style, registered inadd_routesso it works auth-on and auth-off):GET /v1/session→{user,email,org,orgs[],project,projects[],console_url,iam_url,authenticated}read fromrequest["iam_user"];POST /v1/session/orgsets the active org (validated against membership →studio_active_orgcookie);POST /v1/session/projectsets the workflow-workspace project scope;GET|POST /logoutclears the session + selection cookies and bounces to the IAM end-session →/login. Org switching is real, one hook: the auth middleware appliessession.resolve_org(...)to a COPY of the validated user (never the cached dict) soiam_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 bybranding/patch_identity.py(step 10 ofapply-branding.sh, same seam as the copilot).window.HanzoIdentityis 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/sessionroutes 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 inpatch_frontend.pystructurally can't reach. Rule 1: whole-wordComfyUI→Hanzo Studioonly 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-Comfyvalues as exactkey: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 aboveLOCALE_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-Englishmain-*/nodeDefs-*/commands-*chunks are left as-is (noted, out of scope). Idempotent. Verified: served English displayComfyUI63→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).
Office documents (the non-GPU pipeline)
Studio's creative side is ComfyUI graphs → GPU. Office documents are the orthogonal
subsystem: structured content (a template's typed fields, filled by a person OR by the AI)
→ a pure-Python renderer → a real .pdf/.docx/.xlsx/.pptx/.md. No graph, no model, no GPU.
NOT bolted onto graphs; its own module, store, endpoints, and Home tab.
- Renderers (
middleware/doc_render.py, pure — no HTTP/storage/org): one neutral IR per archetype (docprose blocks ·sheetsheets ·deckslides) and EXACTLY ONE renderer per format. Adding a template = onebuild(fields)->(kind,ir); adding a format = one renderer. Deps imported lazily so the module loads stdlib-only. PDF prefers WeasyPrint (full-CSS, monochrome print stylesheet); when its native pango/cairo stack is absent (the light CI unit venv, model-less pods) a built-in pure-Python PDF writer (base-14 Helvetica, WinAnsi/cp1252, auto-pagination,/Infotitle) produces a valid%PDF— so every format renders with just the wheels. DOCX/XLSX/PPTX = python-docx / openpyxl / python-pptx (real, always). Native libs in theDockerfile(libpango/…). - Catalog + AI-fill + store + REST (
middleware/documents.py): ONE declarativeCATALOGof 22 common templates (Documents 15 → pdf/docx/md · Spreadsheets 4 → xlsx/pdf/docx/md · Presentations 3 → pptx/pdf/md). Each template's typedfieldsdrive the form, the AI-fill JSON, AND the renderer inputs (one schema, three uses). Registered from withinadd_studio_home_routeswith the SAME injected tenancy resolvers asstacks_store(org_of/owner_of). Per-org storeorgs/<org>/documents/(atomicindex.json+<id>/doc.json), doc.json is the source of truth, files rendered on demand at download. Endpoints (/v1house style):GET /v1/documents(grouped catalog),POST /v1/documents/generate{templateId, description?|fields?},GET /v1/documents/library,GET|PATCH|DELETE /v1/documents/{id},GET /v1/documents/{id}/download?format=. - AI-fill is a LAYERED ENHANCEMENT — the structured-form path renders a valid document
with NO LLM, so a broken/unauthorized gateway can NEVER block creation (
422 needFields→ the UI reveals the prefilled form). It reuses the SAME working token the shipped Chat uses: the browser sendswindow.HZ.pk(the publishablepk-key_publishable/_hz_config_scriptemit) as the bearer to/v1/documents/generate; the backend forwards it, fail-closed, in order: (1) a server-side gateway key (STUDIO_DOC_TOKEN→STUDIO_COPILOT_TOKEN→STUDIO_COMMERCE_TOKEN); (2) else the caller's own token, forwarded ONLY when gateway-verifiable — a publishablepk-key or an asymmetric/JWKS JWT (RS/ES/PS/EdDSA). A symmetric HS256 hanzo.id session token is NEVER forwarded (the gateway rejects it: "unsupported signing method: HS256"), nor any secretsk-/hk-key. Model =STUDIO_DOC_MODEL→STUDIO_CHAT_MODEL→enso(matches window.HZ.model). - UI (
middleware/studio_home.html, vanilla JS, monochrome/Geist): a 📄 Documents Home tab — searchable server-driven catalog + "Your documents" grid with Download PDF/Word/Excel/PowerPoint. Thedocdlgdialog: describe in one line → Generate with AI (sends window.HZ.pk), or fill the short typed form → Generate; edit fields before/after; re-open a saved doc to edit (PATCH). The deaddoc/sheet/papercomposer KIND placeholders were replaced with one "Office documents" card that opens the tab.
Create composer + gateway auth (v0.18.1 — the intuitive first Create)
The "What should we create?" composer must PRODUCE something on the first ↑, and Chat must reach the gateway. Both were fixed here.
- Gateway auth is ONE fail-closed seam (
middleware/gateway_auth.py): the canonical allowlist for handing a browser-edge bearer to api.hanzo.ai.ai_bearer(request, server_env=…)= a server key first (never in page source), else the caller's own token ONLY when the gateway can verify it — a publishablepk-key or an asymmetric/JWKS JWT (RS/ES/PS/EdDSA). A symmetric HS256 hanzo.id session token is NEVER forwarded (the gateway rejects it → "unsupported signing method: HS256" → the old 502), nor any secretsk-/hk-/rk-key.copilot.py._resolve_targetnow uses it (_SERVER_TOKEN_ENV=(STUDIO_COPILOT_TOKEN, STUDIO_COMMERCE_TOKEN)), so the editor-ops chat no longer 502s on the session token. The shipped Chat sidebar (shell.js) still streams enso directly from the gateway withwindow.HZ.pkwhen present; its copilot fallback now renders ONE friendly line on failure — never the raw backend error/JSON. (documents.py's AI-fill has an equivalent private copy; it should adoptgateway_authonce its concurrent review lands — the shared module is the going-forward one way.) - Text-to-image is the default create lane (
studio_home.py:_image_graph+dispatch_image+POST /v1/library/image): the proven Z-Image-Turbo graph (z_image_turbo_bf16/qwen_3_4blumina2 /aevae; EmptySD3LatentImage → ModelSamplingAuraFlow shift 3 → KSampler res_multistep/simple 4 steps cfg 1.0 → SaveImage), lifted from the shipped blueprint, dispatched through the ONE funnel like video/fix. Words in → an image out; a model-less pod surfaces the real DispatchError, not a silent nothing. - Composer routes ↑ by the selected model's modality (
studio_home.html): defaultMODEL='zen-image'fromhz_create_model— DECOUPLED from the chat model (hz_model), so an image pick never breaks the assistant (andshell.js chatModel()ignores a non-chathz_model).composerPlan(): assistant model → hand to the ONE chat viawindow.HanzoChat.send(exposed by shell.js); no photo → text-to-image; 1 photo → fix; 2+ → compose; video model/lane → video.create()ALWAYS pulses a "Preparing…" live-bar state and, on failure, shows a friendly retry (prompt kept) — never a silent no-op. The code model (zen5-coder) is hidden from the creative composer; each model has a one-line description; the</>request-preview is gated behind Developers mode (body.hzdev, set by the shell's Developers pill). - P2 friction fixes (
studio_home.html+shell.css): a first-class Download on every tile AND in the viewer (PNG original · JPG/WebP client-side canvas convert, the popover flips up near the viewport bottom); the fixed header + sticky live-bar are now OPAQUE (were translucent → content scrolled under them); the ARCHIVED badge shows ONLY for truly deleted/flagged items (active work is unbadged); on mobile the ~30 filter pills collapse to one horizontally-scrollable row and the tab bar gets a scroll fade affordance. The Chat empty state (shell.js) shows an assistant identity + one line + 3 tappable starters.
Tests
tests-unit/{folder_paths_test,middleware_test,app_test,prompt_server_test,studio_home_test,documents_test} —
run uv run pytest tests-unit/folder_paths_test tests-unit/middleware_test tests-unit/app_test tests-unit/prompt_server_test tests-unit/studio_home_test tests-unit/documents_test -q. The auth/tenancy work is
covered by middleware_test/iam_auth_test.py,
middleware_test/session_test.py and
folder_paths_test/org_scoping_test.py. Office documents:
documents_test/test_documents.py (catalog invariants, the full template×format render
matrix asserted by magic numbers, AI-fill parse + fail-closed bearer allowlist, and the
REST surface incl. per-org isolation on a real aiohttp app). Create composer + gateway
auth: middleware_test/gateway_auth_test.py (the fail-closed allowlist — HS256/sk- refused,
pk-/asymmetric forwarded, server-key precedence, session-cookie path),
middleware_test/copilot_test.py::test_resolve_target_never_forwards_hs256_session_token,
and studio_home_test/create_image_test.py (the Z-Image-Turbo graph shape, dispatch_image
validation, and served-HTML invariants: default-model-is-image, text→image route,
Create-always-shows-a-state, first-class Download, no ARCHIVED badge on active items, opaque
bars, gated </>, collapsed mobile filters).