Compare commits

...
Author SHA1 Message Date
Hanzo AI 1fe7df76a5 worklog: widen ?q= search to paths + refs, not just prompt
An ingested render row has an empty prompt, so filtering Queue & History by
filename found nothing. Widen the /v1/worklog ?q= filter to also match kind,
output_prefix, output (the landed path), and refs — so a render is findable by
its filename or source image, not only by a typed prompt. Test covers filename,
ref, and prompt matches.
2026-07-16 23:48:32 -07:00
be313e8068 Library ingest: POST /v1/library/upload — every render lands in the library (#13)
* Library ingest: POST /v1/library/upload — every render lands in the library

Tenant-scoped ingest so EVERY render is stored in studio.hanzo.ai, including
renders produced on a GPU node outside the job path (the node's mirror loop
POSTs here). Org via _org_of like every route.

Body: multipart (field image/file) OR raw bytes + ?name=, optional ?subpath=
(default renders). Writes orgs/<org>/output/<subpath>/<name> atomically
(tmp+replace, same pattern as the work log). Rejects traversal (basename-only
name + allowlisted subpath chars), caps size at 30MB, and skips a byte-identical
existing file (name+size match → 200 {ok, existed:true}); else 200 {ok, path}.
The manifest sidecar already indexes output/ — no other wiring.

Tests (studio_home_test): round-trip via GET /v1/library/file, dedup, tenant
isolation (org A upload never visible to org B, no cross-tenant overwrite),
subpath-traversal rejected, traversal-y name neutralized to basename, non-image
rejected, oversize rejected. 259 passed.

* Ship scripts/ whole (unbreak manifest sidecar) + index renders at ingest

(A) Regression: .dockerignore `scripts/*` + `!install_custom_nodes.sh` dropped
scripts/library_manifest.py from the image, so the build-manifest sidecar's
`[ -f ]` guard silently no-op'd and NOTHING got indexed into any org library.
Ship scripts/ whole again.

(B) Make indexing event-driven at the ingest point: POST /v1/library/upload, after
writing the asset, atomically appends the org library.json entry ({path, design?,
kind, role?, status:"draft", tags:[], updatedAt}) AND a worklog row ({kind:"render",
node, prompt:"", refs:[], parents:[], output_prefix:<path>, status:"done"}) — so
every mirrored render is instantly findable in Assets AND Queue & History with its
source node, filterable by run/flow, the moment it exists. Optional ?design= ?kind=
?role= ?node= with safe defaults (node→"upload"). Upserts by path (no dup on
re-render); the existed/dedup path skips indexing. The manifest sidecar stays the
sweeper for files that appear outside the route. _row_output resolves an ingested
render's output (its stored path) so it shows a thumbnail + opens its graph.

Tests: upload → library.json entry + worklog row appear (node, done, resolved
output), tenancy holds, dedup adds no second entry. 260 passed.

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 22:19:20 -07:00
6b5597d8cb home: account menu — the chip advertised one and did nothing (#14)
cursor:pointer + title=Account with no handler. Tap now opens a menu:
identity, Manage account (hanzo.id), Sign out (/logout — existing route).

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 21:52:03 -07:00
Hanzo AI dd1f9e7138 studio-chat: call /v1/agent (was /v1/chat), canonical preset field + conversation threading
The orchestrator lives at POST /v1/agent (github.com/hanzoai/agent in the cloud
binary), not /v1/chat (which ai owns as its completions route). Use the canonical
`preset` selector (not the legacy `capability` alias) and thread conversationId so
the per-org history the orchestrator persists actually continues across turns.
VITE_CHAT_API → VITE_AGENT_API.
2026-07-16 21:10:06 -07:00
86bb0a3cad Shell: Studio/Editor toggle, sidebar chat, templates, wallet, GPUs (#11)
One shared shell asset (middleware/shell.{js,css}, served at /studio/shell.*)
is loaded by BOTH views — the Studio home <script>-includes it, and the Editor
gets a single tag injected into the engine index.html by server.get_root. It
renders one chrome in both: a Studio/Editor toggle, wallet chip, GPUs badge +
panel, and a dockable right chat sidebar backed by the ONE chat path
(/v1/copilot/chat). Same-origin cookies carry the IAM session, so login/org are
inherited — no auth is rebuilt.

New tenant-scoped routes (all via _org_of):
  GET  /studio/shell.js|css
  GET  /v1/workflow?id=<worklog id>   graph from the item's output PNG (404 if
                                      not owned / not landed) — Open in Editor
  POST /v1/templates                  save {name, graph} → orgs/<org>/templates
  GET  /v1/templates                  list
  GET  /v1/templates/{name}           fetch
  POST /v1/templates/render           queue through the ONE dispatch funnel
  GET  /v1/wallet                     display-only proxy of the user token to the
                                      cloud finance balance → {org, balance, currency}

Studio home: "Advanced mode" link replaced by the shell toggle; dead chatfab CSS
removed; Templates tab lists org templates (Open in Editor + Render); work-item
context gets Open in Editor; favorite→template now writes a REAL template.

Mobile (verified 390x844 + 768x1024): chat collapses to a full-width bottom
sheet; the pill bar stays one compact line (icon-only Chat/GPUs) so all controls
stay reachable and clear the content; zero horizontal overflow.

Wallet is display + Top up link (pay.hanzo.ai) only — no card fields, no minting.
Tests extend studio_home_test for the new routes (tenant isolation + shape).

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 20:44:50 -07:00
96a633d3ce render: cap startToClose at 4h — the tasks default (~1h) reaped live renders (#12)
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.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 19:31:45 -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
65 changed files with 9360 additions and 78 deletions
+4 -3
View File
@@ -23,9 +23,10 @@ venv/
.ruff_cache/
tests/
tests-unit/
scripts/*
# ...except the pack installer, which the image build invokes.
!scripts/install_custom_nodes.sh
# scripts/ ships WHOLE: the image build runs scripts/install_custom_nodes.sh and
# the studio pod runs scripts/library_manifest.py as the build-manifest sidecar
# (indexes every org's output/ into library.json). Excluding it silently no-ops
# the sidecar's `[ -f ]` guard, so nothing gets indexed — keep the tree intact.
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:
+29
View File
@@ -33,6 +33,35 @@ 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
+7 -1
View File
@@ -105,7 +105,13 @@ python3 "$BRANDING_DIR/patch_destring.py" "$STATIC_DIR"
# 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/12] Forcing Nodes 2.0 on (no toggle)..."
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 ==="
+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");
})();
+17
View File
@@ -110,6 +110,12 @@
}
rows.push(el("div", { class: "hzid-sep" }));
// Exit the advanced node editor back to the content-forward Studio Home.
// Without this there is NO way out of Advanced mode (the node editor is the
// full-screen ComfyUI SPA) — the user gets stuck.
rows.push(el("a", { class: "hzid-item hzid-link", href: "/studio" }, [
el("span", { class: "hzid-tick", text: "←" }), el("span", { text: "Exit to Studio Home" }),
]));
rows.push(el("a", { class: "hzid-item hzid-link", href: s.console_url || "https://console.hanzo.ai", target: "_blank", rel: "noopener" }, [
el("span", { class: "hzid-tick", text: "↗" }), el("span", { text: "Console" }),
]));
@@ -151,6 +157,17 @@
// ------------------------------------------------------------- boot
function mount() {
if (document.getElementById("hanzo-identity")) return;
// Always-visible "Exit to Studio" button so Advanced mode is never a trap.
// Fixed top-left, above the node editor; independent of the user menu.
if (!document.getElementById("hanzo-exit-advanced")) {
var exit = el("a", {
id: "hanzo-exit-advanced", href: "/studio", title: "Exit Advanced — back to Studio Home",
style: "position:fixed;top:10px;left:12px;z-index:100000;display:flex;align-items:center;gap:6px;" +
"background:rgba(20,20,24,.92);color:#ececf1;border:1px solid #33333c;border-radius:8px;" +
"padding:6px 11px;font:600 12px/1 Inter,system-ui,sans-serif;text-decoration:none;cursor:pointer;backdrop-filter:blur(8px)"
}, [el("span", { text: "←" }), el("span", { text: "Studio" })]);
document.body.appendChild(exit);
}
var root = el("div", { id: "hanzo-identity", class: "hanzo-identity" });
document.body.appendChild(root);
getSession().then(function (s) {
+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()
+1 -1
View File
@@ -21,7 +21,7 @@ import sys
STATIC_DIR = sys.argv[1]
BRANDING_DIR = sys.argv[2]
BUNDLE_VERSION = "3"
BUNDLE_VERSION = "4"
JS = "hanzo-identity.js"
CSS = "hanzo-identity.css"
LINK_TAG = f'<link rel="stylesheet" href="{CSS}?v={BUNDLE_VERSION}"/>'
+32 -11
View File
@@ -27,11 +27,13 @@ server keeps running.
| **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` -> mesh path |
| **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` |
- **Chat** streams tokens over SSE, driving the node progress bar, and returns
@@ -39,21 +41,40 @@ server keeps running.
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/ASR** speak the Studio `AUDIO` type (`{waveform, sample_rate}`) and
compose with the built-in Load/Save/Preview Audio nodes.
- **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
(music, dub, ...) the day it ships, then promote it to a dedicated node here.
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.
(`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
Only libraries already in Hanzo Studio: `requests`, `Pillow`, `numpy`, `torch`
(always), plus `av`/`torchaudio` (loaded lazily, for the audio/video nodes).
No extra install.
(always), plus `av` (loaded lazily, for the audio/video decode paths). WAV
encode/decode uses the stdlib `wave` module. No extra install.
## Example
## Verification
`user/default/workflows/hanzo-native-pipeline.json` -- Chat writes an image
prompt, Image Gen renders it, the image and the prompt text are saved. All
compute runs on the native engine.
`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.
+17 -3
View File
@@ -293,12 +293,26 @@ def audio_bytes_to_studio(data: bytes) -> dict:
def studio_audio_to_wav_bytes(audio: dict) -> bytes:
"""Encode a Studio AUDIO dict to WAV bytes for multipart upload (ASR)."""
import torchaudio
"""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()
torchaudio.save(buf, wav.cpu(), int(audio["sample_rate"]), format="wav")
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()
+81 -6
View File
@@ -8,11 +8,13 @@ http://127.0.0.1:1234, override with HANZO_ENGINE_URL). Paths are always
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
@@ -129,7 +131,7 @@ class HanzoTTS:
},
"optional": {
"model": (_model_combo(),),
"response_format": (["wav", "flac", "mp3", "opus", "aac"], {"default": "wav"}),
"response_format": (["wav", "pcm"], {"default": "wav"}),
},
}
@@ -302,15 +304,17 @@ class HanzoTextToVideo:
class HanzoImageTo3D:
"""Image-to-3D via the async job /v1/3d (native Rust). Saves the GLB/PLY to
the output dir and returns its path for a 3D-viewer node."""
"""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"], {"default": "glb"}),
"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}),
},
@@ -322,10 +326,11 @@ class HanzoImageTo3D:
CATEGORY = CATEGORY
OUTPUT_NODE = True
def generate(self, image, format, seed, steps):
def generate(self, image, format, texture, seed, steps):
raw = client.run_job(
"3d",
{"image": client.tensor_to_data_url(image), "format": format, "seed": seed, "steps": steps},
{"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(
@@ -337,6 +342,72 @@ class HanzoImageTo3D:
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 output/*.txt (image/caption pairs for dataset prep)
and preview it in the node."""
@@ -372,11 +443,13 @@ 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,
}
@@ -384,10 +457,12 @@ 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",
}
+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
+21
View File
@@ -245,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)
@@ -307,6 +308,26 @@ def prompt_worker(q, server_instance):
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
if execution_time > 600:
execution_time = time.strftime("%H:%M:%S", time.gmtime(execution_time))
+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
+84 -5
View File
@@ -76,11 +76,36 @@ def _bearer(request) -> str:
return f"Bearer {tok}" if tok else ""
def _has_online_gpu(tok: str) -> bool:
"""True if the caller's org has an online GPU machine in the cloud fleet."""
# Identity keys a fleet machine might carry, most human-friendly first. We READ
# whatever the gpu-jobs lane publishes (home-lab labels like spark/dbc/evo land in
# `name`/`hostname`); we never invent a parallel registry.
_NODE_KEYS = ("name", "hostname", "label", "node", "worker_id", "machine_id", "id", "gpu_model")
def _node_label(m: dict) -> str:
for k in _NODE_KEYS:
v = m.get(k)
if isinstance(v, str) and v.strip():
return v.strip()
return "gpu"
def _fleet_machines(tok: str) -> list:
"""The caller-org's machines from the cloud fleet. The token scopes the fleet to
the caller's org, so this is inherently tenant-isolated."""
req = urllib.request.Request(f"{CLOUD}/v1/machines", headers={"Authorization": tok})
d = json.loads(urllib.request.urlopen(req, timeout=10).read())
return any(m.get("status") == "online" and m.get("gpu") for m in d.get("machines", []))
ms = d.get("machines", [])
return ms if isinstance(ms, list) else []
def _online_gpu_nodes(tok: str) -> list:
return [m for m in _fleet_machines(tok) if m.get("status") == "online" and m.get("gpu")]
def _has_online_gpu(tok: str) -> bool:
"""True if the caller's org has an online GPU machine in the cloud fleet."""
return bool(_online_gpu_nodes(tok))
def dispatch_if_worker(request, org_id: str, prompt_id: str, prompt: dict) -> bool:
@@ -89,14 +114,25 @@ def dispatch_if_worker(request, org_id: str, prompt_id: str, prompt: dict) -> bo
Never raises — any failure falls back to local."""
try:
tok = _bearer(request)
if not tok or not _has_online_gpu(tok):
online = _online_gpu_nodes(tok) if tok else []
if not online:
return False
# Which node runs it: the single online GPU is definitive; with several the
# claimer is decided at pick-up time, so record the ambiguous "gpu".
node = _node_label(online[0]) if len(online) == 1 else "gpu"
body = json.dumps({
"activityId": prompt_id,
"runId": prompt_id,
"activityType": {"name": "studio.render"},
"taskQueue": JOBS_NS,
"heartbeatTimeout": "600s",
# 1200s: a heavy Qwen edit on a cold GB10 (model reload + full 30-step
# sample) can exceed 600s; the worker heartbeats each step, so this is
# the ceiling for a genuinely long single render, not idle slack.
"heartbeatTimeout": "1200s",
# Liveness is the heartbeat above; this cap only bounds a live-but-stuck
# render. Unset, the tasks default (~1h) reaped real renders mid-run and
# the queue re-ran them for hours.
"startToCloseTimeout": "14400s",
"input": {
"prompt": prompt,
"org": org_id,
@@ -113,6 +149,49 @@ def dispatch_if_worker(request, org_id: str, prompt_id: str, prompt: dict) -> bo
headers={"Authorization": tok, "Content-Type": "application/json"},
)
urllib.request.urlopen(req, timeout=15).read()
_track_dispatched(org_id, prompt_id, prompt, node=node)
return True
except Exception:
return False
def _track_dispatched(org_id: str, prompt_id: str, prompt: dict, node: str = "gpu") -> None:
"""Record a BYO-GPU render dispatched to gpu-jobs so the studio UI can SHOW it
in the queue. BYO-GPU jobs render on the worker (gpu-jobs), not the pod's local
/queue — so without this the user's fix/compose 'never appears in the queue'.
A tiny per-org JSON the /v1/render-queue endpoint reads; entries drop off when
their output lands in the library or they age out. Never raises."""
try:
import time
import folder_paths
# human title from the SaveImage prefix; refs from LoadImage for a thumbnail
full_prefix, refs = "render", []
for n in prompt.values():
if not isinstance(n, dict):
continue
ct = n.get("class_type", "")
if ct == "SaveImage":
full_prefix = n.get("inputs", {}).get("filename_prefix") or "render"
elif "LoadImage" in ct:
im = n.get("inputs", {}).get("image")
if im:
refs.append(im)
out_dir = folder_paths.get_org_output_directory(org_id)
os.makedirs(out_dir, exist_ok=True)
jf = os.path.join(out_dir, "render_jobs.json")
try:
jobs = json.loads(open(jf).read())
except Exception:
jobs = []
jobs = [j for j in jobs if j.get("id") != prompt_id][-40:] # dedupe + cap
# store the FULL output prefix (e.g. "fixes/model01_..._00001_") so the queue
# endpoint can check for the ACTUAL output file, not a fuzzy base match that
# collides with the source image and makes the job vanish after 30s.
jobs.append({"id": prompt_id, "prefix": full_prefix.split("/")[-1],
"outPrefix": full_prefix, "refs": refs[:1], "node": node,
"ts": int(time.time()), "status": "rendering"})
tmp = jf + ".tmp"
open(tmp, "w").write(json.dumps(jobs))
os.replace(tmp, jf)
except Exception:
pass
+6
View File
@@ -446,6 +446,12 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
return web.json_response({"error": "Invalid or expired token"}, status=401)
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 login + callback handlers so server.py can register the routes.
+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)
+63
View File
@@ -0,0 +1,63 @@
/* Hanzo Studio shell — the ONE shared chrome, loaded by BOTH the Studio home
* (middleware/studio_home.html) and the Editor (injected into the engine's
* index.html by server.get_root). Self-contained dark palette + `hz` id/class
* prefix so it never collides with the editor's own theme. */
#hzbar{position:fixed;bottom:18px;right:18px;z-index:2147483000;display:flex;align-items:center;gap:8px;
flex-wrap:wrap;justify-content:flex-end;max-width:calc(100vw - 36px);
font:400 14px/1.4 Inter,system-ui,-apple-system,sans-serif}
#hzbar button,#hzbar a{font:inherit}
.hz-pill{display:inline-flex;align-items:center;gap:7px;background:#131317;color:#ececf1;border:1px solid #232329;
border-radius:999px;padding:7px 13px;cursor:pointer;text-decoration:none;white-space:nowrap}
.hz-pill:hover{border-color:#3a3a48}
.hz-pill .hz-dot{width:8px;height:8px;border-radius:50%;background:#555;flex:0 0 auto}
.hz-pill.on .hz-dot{background:#3ddc84}
.hz-pill.hz-chat{background:#f4f4f6;color:#111;border-color:#f4f4f6;font-weight:600}
.hz-topup{color:#6aa5ff;text-decoration:none;font-size:.82em;border-left:1px solid #232329;padding-left:7px}
.hz-topup:hover{text-decoration:underline}
/* right-docked chat sidebar (both views) */
#hzchat{position:fixed;top:0;right:0;height:100vh;width:380px;max-width:94vw;background:#0e0e12;color:#ececf1;
border-left:1px solid #232329;z-index:2147483001;display:flex;flex-direction:column;
transform:translateX(100%);transition:transform .22s ease;box-shadow:-14px 0 44px #0007}
#hzchat.on{transform:translateX(0)}
#hzchat .hz-h{display:flex;align-items:center;gap:9px;padding:13px 15px;border-bottom:1px solid #232329;flex-wrap:wrap}
#hzchat .hz-h .hz-av{width:26px;height:26px;border-radius:50%;background:#26262e;display:flex;align-items:center;
justify-content:center;font-size:.72rem;font-weight:600;flex:0 0 auto}
#hzchat .hz-h .hz-who{font-size:.82rem;min-width:0}
#hzchat .hz-h .hz-who b{display:block;font-size:.86rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#hzchat .hz-h .hz-who span{color:#8a8a95;font-size:.72rem}
#hzchat .hz-h .hz-x{margin-left:auto;cursor:pointer;color:#8a8a95;font-size:1.3rem;line-height:1;background:none;border:none}
#hzmsgs{flex:1;overflow:auto;padding:14px;display:flex;flex-direction:column;gap:10px}
.hz-m{max-width:88%;padding:9px 12px;border-radius:12px;font-size:.86rem;line-height:1.45;white-space:pre-wrap;word-break:break-word}
.hz-m.u{align-self:flex-end;background:#f4f4f6;color:#111;border-bottom-right-radius:3px}
.hz-m.a{align-self:flex-start;background:#1a1a20;border:1px solid #232329;border-bottom-left-radius:3px}
.hz-m.sys{align-self:center;color:#8a8a95;font-size:.78rem;background:none}
#hzform{display:flex;gap:8px;padding:12px;border-top:1px solid #232329}
#hzform input{flex:1;background:#0a0a0d;border:1px solid #232329;border-radius:9px;color:#ececf1;padding:9px 11px;font:inherit;font-size:.85rem;outline:none}
#hzform button{background:#f4f4f6;color:#111;border:none;border-radius:9px;padding:0 14px;font-weight:600;cursor:pointer}
/* GPUs panel (both views) */
#hzgpus{position:fixed;bottom:64px;right:18px;z-index:2147483001;width:320px;max-width:92vw;background:#131317;
color:#ececf1;border:1px solid #232329;border-radius:12px;padding:12px 14px;display:none;box-shadow:0 18px 50px #0009}
#hzgpus.on{display:block}
#hzgpus h4{font-size:.82rem;font-weight:600;margin:0 0 8px;display:flex;align-items:center;gap:7px}
.hz-node{display:flex;align-items:flex-start;gap:9px;padding:7px 0;border-top:1px solid #1e1e24;font-size:.8rem}
.hz-node:first-of-type{border-top:none}
.hz-node .hz-dot{width:8px;height:8px;border-radius:50%;background:#555;margin-top:5px;flex:0 0 auto}
.hz-node.on .hz-dot{background:#3ddc84}
.hz-node .hz-nn{flex:1;min-width:0}
.hz-node .hz-nn b{font-weight:500}
.hz-node .hz-nsub{color:#8a8a95;font-size:.72rem;margin-top:2px}
/* Tablet: a touch narrower sidebar; the pill bar + panels already fit. */
@media (max-width:900px){#hzchat{width:340px}}
/* Phone: chat becomes a full-width bottom sheet (same pattern the old chatpanel
used); the pill bar wraps upward from the bottom-right so the toggle, wallet
and GPUs badge all stay reachable; the GPUs panel spans the width. */
@media (max-width:600px){
#hzbar{bottom:14px;right:14px;gap:7px}
.hz-pill{padding:7px 11px}
.hz-pill .hz-lbl{display:none} /* icon-only Chat/GPUs → one line, clears content */
#hzchat{top:auto;bottom:0;width:100vw;max-width:100vw;height:85vh;
border-left:none;border-top:1px solid #232329;border-radius:16px 16px 0 0;
transform:translateY(100%);box-shadow:0 -14px 44px #0007}
#hzchat.on{transform:translateY(0)}
#hzgpus{left:12px;right:12px;width:auto;bottom:66px}
}
+201
View File
@@ -0,0 +1,201 @@
/* Hanzo Studio shell — the ONE shared chrome for BOTH views.
*
* Studio home (/studio) includes this via <script src="/studio/shell.js">
* Editor (/ ?advanced=1) gets the same tag injected into the engine's
* index.html by server.get_root
*
* It renders (identically in both views): a Studio/Editor toggle, a wallet chip,
* a GPUs badge + panel, and a dockable right chat SIDEBAR backed by the ONE chat
* path (POST /v1/copilot/chat). Same-origin cookies carry the IAM session, so org
* + login are inherited — no auth is rebuilt here. In the editor it also honours
* ?load=<worklog id> / ?template=<name> to open a graph, and offers Save as
* template. Guarded end to end: any failure logs and no-ops, never throws. */
(function () {
"use strict";
if (window.__hzShell) return;
window.__hzShell = 1;
var STUDIO = location.pathname === "/studio" || location.pathname === "/studio/";
var $ = function (id) { return document.getElementById(id); };
var el = function (tag, attrs, html) {
var e = document.createElement(tag);
if (attrs) for (var k in attrs) e.setAttribute(k, attrs[k]);
if (html != null) e.innerHTML = html;
return e;
};
var esc = function (s) { return String(s == null ? "" : s).replace(/[<>&]/g, function (c) { return { "<": "&lt;", ">": "&gt;", "&": "&amp;" }[c]; }); };
async function getJSON(url, opt) {
var r = await fetch(url, Object.assign({ credentials: "same-origin" }, opt || {}));
var j = {}; try { j = await r.json(); } catch (_) {}
return { ok: r.ok, status: r.status, body: j };
}
// ── chrome: bottom-right pill cluster + docked chat sidebar + GPUs panel ──────
var bar = el("div", { id: "hzbar" });
var toggle = el("a", { class: "hz-pill", href: STUDIO ? "/?advanced=1" : "/studio",
title: STUDIO ? "Open the node editor" : "Back to Studio" }, STUDIO ? "⇄ Editor" : "⇄ Studio");
var tmplBtn = STUDIO ? null : el("button", { class: "hz-pill", title: "Save the current graph as a reusable template" }, "★ Template");
var wallet = el("a", { class: "hz-pill", id: "hzwallet", href: "https://pay.hanzo.ai", target: "_blank", rel: "noopener",
title: "Wallet — top up at pay.hanzo.ai" }, "wallet …");
var gpus = el("button", { class: "hz-pill", id: "hzgpubtn", title: "GPUs" }, '<span class="hz-dot"></span><span class="hz-lbl">GPUs</span>');
var chatBtn = el("button", { class: "hz-pill hz-chat", title: "Chat" }, '💬<span class="hz-lbl"> Chat</span>');
bar.appendChild(toggle);
if (tmplBtn) bar.appendChild(tmplBtn);
bar.appendChild(wallet); bar.appendChild(gpus); bar.appendChild(chatBtn);
var gpuPanel = el("div", { id: "hzgpus" }, '<h4>GPUs</h4><div id="hznodes"></div>');
var chat = el("aside", { id: "hzchat" });
chat.innerHTML =
'<div class="hz-h"><span class="hz-av" id="hzav">·</span>' +
'<span class="hz-who"><b id="hzuser">…</b><span id="hzorg"></span></span>' +
'<button class="hz-x" id="hzclose" title="Close">×</button></div>' +
'<div id="hzmsgs"></div>' +
'<form id="hzform"><input id="hzin" placeholder="Ask the copilot…" autocomplete="off"><button type="submit">Send</button></form>';
function mount() {
document.body.appendChild(bar);
document.body.appendChild(gpuPanel);
document.body.appendChild(chat);
wire();
loadSession(); loadWallet(); loadGpus();
if (!STUDIO) editorBoot();
}
// ── chat: the ONE path — POST /v1/copilot/chat; ops applied via the editor's
// existing applier when present (no second chat mechanism) ────────────────────
var HISTORY = [];
function addMsg(role, text) {
var m = el("div", { class: "hz-m " + role }, esc(text));
$("hzmsgs").appendChild(m); $("hzmsgs").scrollTop = $("hzmsgs").scrollHeight;
return m;
}
async function graphContext() {
var app = window.app;
if (!app || typeof app.graphToPrompt !== "function") return {};
try {
var p = await app.graphToPrompt();
var ctx = { graph_json: p.output || {} };
try { ctx.catalog = { types: Object.keys((window.LiteGraph && window.LiteGraph.registered_node_types) || {}) }; } catch (_) {}
return ctx;
} catch (_) { return {}; }
}
async function send(text) {
HISTORY.push({ role: "user", content: text });
addMsg("u", text);
var pending = addMsg("sys", "…");
var payload = Object.assign({ messages: HISTORY.slice(-20) }, await graphContext());
var r = await getJSON("/v1/copilot/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
pending.remove();
var reply = (r.body && r.body.reply) || (r.body && r.body.error) || (r.ok ? "" : "chat failed (" + r.status + ")");
if (reply) { HISTORY.push({ role: "assistant", content: reply }); addMsg("a", reply); }
var ops = (r.body && r.body.ops) || [];
if (ops.length && window.HanzoCopilot && typeof window.HanzoCopilot.applyOps === "function") {
try { window.HanzoCopilot.applyOps(ops); } catch (e) { addMsg("sys", "couldn't apply changes: " + e.message); }
} else if (ops.length && STUDIO) {
addMsg("sys", "Open the Editor to apply " + ops.length + " graph change" + (ops.length > 1 ? "s" : "") + ".");
}
}
function wire() {
chatBtn.onclick = function () { chat.classList.toggle("on"); if (chat.classList.contains("on")) $("hzin").focus(); };
$("hzclose").onclick = function () { chat.classList.remove("on"); };
$("hzform").onsubmit = function (e) {
e.preventDefault();
var v = $("hzin").value.trim(); if (!v) return;
$("hzin").value = ""; send(v).catch(function (err) { addMsg("sys", "error: " + err.message); });
};
gpus.onclick = function () { gpuPanel.classList.toggle("on"); if (gpuPanel.classList.contains("on")) loadGpus(); };
if (tmplBtn) tmplBtn.onclick = saveTemplate;
}
// ── session · wallet · GPUs ───────────────────────────────────────────────────
async function loadSession() {
var r = await getJSON("/v1/session"); var s = r.body || {};
var nm = (s.user || s.email || "You").trim();
$("hzav").textContent = (nm[0] || "·").toUpperCase();
$("hzuser").textContent = nm;
$("hzorg").textContent = s.org ? ("org · " + s.org) : "";
}
function money(n, cur) {
if (n == null || isNaN(n)) return "—";
var sym = (cur || "").toLowerCase() === "usd" ? "$" : "";
return sym + Number(n).toFixed(2) + (sym ? "" : " " + (cur || "").toUpperCase());
}
async function loadWallet() {
var r = await getJSON("/v1/wallet"); var w = r.body || {};
wallet.innerHTML = esc(money(w.balance, w.currency)) + '<span class="hz-topup">Top up ↗</span>';
wallet.title = "Wallet" + (w.org ? " · " + w.org : "") + " — top up at pay.hanzo.ai";
}
async function loadGpus() {
var nres = await getJSON("/v1/nodes"); var qres = await getJSON("/v1/queue/status");
var nodes = (nres.body && nres.body.nodes) || [];
var pod = (nres.body && nres.body.pod) || null;
if (pod) nodes = nodes.concat([pod]);
var items = (qres.body && qres.body.items) || {};
var byNode = {};
Object.keys(items).forEach(function (id) {
var it = items[id]; var k = it.node || "gpu";
(byNode[k] = byNode[k] || { running: null, depth: 0 });
byNode[k].depth++;
if (it.status === "running") byNode[k].running = it.prompt || it.kind || "a render";
});
var online = nodes.filter(function (n) { return n.online; }).length;
gpus.innerHTML = '<span class="hz-dot"></span><span class="hz-lbl">GPUs </span>' + online + "/" + nodes.length;
gpus.classList.toggle("on", online > 0);
$("hznodes").innerHTML = nodes.length ? nodes.map(function (n) {
var s = byNode[n.name] || { running: null, depth: 0 };
var sub = (n.online ? "online" : "offline") +
(s.running ? " · running: " + esc(String(s.running).slice(0, 40)) : (n.online ? " · idle" : "")) +
" · queue " + s.depth;
return '<div class="hz-node ' + (n.online ? "on" : "") + '"><span class="hz-dot"></span>' +
'<div class="hz-nn"><b>' + esc(n.name) + '</b><div class="hz-nsub">' + sub + "</div></div></div>";
}).join("") : '<div class="hz-nsub">No GPU nodes connected. Run <b>hanzo gpu connect</b> on a box to add one.</div>';
}
// ── editor-only: open a graph (?load / ?template), Save as template ────────────
function waitForApp(ms) {
return new Promise(function (resolve, reject) {
var t0 = Date.now();
(function poll() {
if (window.app && typeof window.app.loadGraphData === "function") return resolve(window.app);
if (Date.now() - t0 > ms) return reject(new Error("editor not ready"));
setTimeout(poll, 150);
})();
});
}
function editorLoad(g) {
var app = window.app; if (!app || !g) return false;
if (g.workflow && typeof app.loadGraphData === "function") { app.loadGraphData(g.workflow); return true; }
if (g.output && typeof app.loadApiJson === "function") { app.loadApiJson(g.output, "hanzo"); return true; }
if ((g.nodes || g.last_node_id != null) && typeof app.loadGraphData === "function") { app.loadGraphData(g); return true; }
if (typeof app.loadApiJson === "function") { app.loadApiJson(g, "hanzo"); return true; }
if (typeof app.loadGraphData === "function") { app.loadGraphData(g); return true; }
return false;
}
async function editorBoot() {
var q = new URLSearchParams(location.search);
var load = q.get("load"), template = q.get("template");
if (!load && !template) return;
try {
await waitForApp(30000);
var url = load ? "/v1/workflow?id=" + encodeURIComponent(load) : "/v1/templates/" + encodeURIComponent(template);
var r = await getJSON(url);
if (!r.ok) { addMsg("sys", "couldn't open: " + ((r.body && r.body.error) || r.status)); return; }
editorLoad(r.body.graph || r.body);
} catch (e) { console.warn("[hz shell]", e); }
}
async function saveTemplate() {
var app = window.app;
if (!app || typeof app.graphToPrompt !== "function") { alert("Open a workflow in the editor first."); return; }
var name = prompt("Save this graph as a template named:");
if (!name || !name.trim()) return;
var g;
try { g = await app.graphToPrompt(); } catch (e) { alert("Couldn't read the graph: " + e.message); return; }
var r = await getJSON("/v1/templates", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), graph: { workflow: g.workflow, output: g.output } }) });
alert(r.ok ? '★ Saved template "' + (r.body.name || name.trim()) + '"' : "Save failed: " + ((r.body && r.body.error) || r.status));
}
if (document.body) mount();
else document.addEventListener("DOMContentLoaded", mount);
})();
+879
View File
@@ -0,0 +1,879 @@
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Hanzo Studio</title>
<link rel="stylesheet" href="/studio/shell.css">
<script src="/studio/shell.js" defer></script>
<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}
#acctmenu{position:fixed;top:52px;right:12px;background:#0e0e12;border:1px solid var(--line);border-radius:12px;padding:6px;display:none;z-index:60;min-width:210px;box-shadow:0 16px 44px #000a}
#acctmenu.on{display:block}
#acctmenu .hd{padding:8px 12px;color:var(--dim);font-size:.78rem;border-bottom:1px solid var(--line);margin-bottom:4px}
#acctmenu .mi{display:flex;align-items:center;gap:8px;padding:10px 12px;border-radius:8px;cursor:pointer;font-size:.88rem;color:var(--txt);text-decoration:none}
#acctmenu .mi:hover{background:#ffffff10}
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}
/* The Studio/Editor toggle, wallet, GPUs and chat sidebar are the shared shell
(/studio/shell.css + shell.js), loaded in both views. */
/* ── 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}
}
@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}
.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>
<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="acctmenu"><div class="hd" id="acctwho">Account</div><a class="mi" href="https://hanzo.id" target="_blank" rel="noopener">Manage account ↗</a><a class="mi" href="/logout">Sign out</a></div>
<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="sec-l">Your templates</div>
<div class="tgrid" id="orgtmpl"></div>
<div class="sec-l">Start from a kind</div>
<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="ctxeditor" onclick="editorCtx()" title="Load this graph into the node editor" style="display:none">⇄ Open in Editor</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(); if(name==='templates')loadOrgTemplates();}
// ── Org templates: saved graphs, Open in Editor + Render (the ONE dispatch path) ──
async function loadOrgTemplates(){
let d={templates:[]}; try{d=await (await fetch('/v1/templates')).json();}catch(_){}
const ts=d.templates||[];
$('orgtmpl').innerHTML=ts.length?ts.map(t=>{const nm=(t.name||t.slug).replace(/</g,'&lt;'),ne=(t.name||t.slug).replace(/'/g,"\\'");
return `<div class="tcard"><div class="ic">★</div><div class="tl">${nm}</div>
<div class="td">${t.ts?new Date(t.ts*1000).toLocaleDateString():''}</div>
<div class="row" style="padding:0 11px 11px;gap:6px">
<a class="mini" href="/?advanced=1&template=${encodeURIComponent(t.slug)}">Open in Editor</a>
${t.renderable?`<button class="mini hot" onclick="renderTemplate('${t.slug}','${ne}')">Render</button>`:''}
</div></div>`;}).join(''):'<div class="empty" style="grid-column:1/-1;padding:20px">No templates yet. In the Editor, build a graph and click ★ Template to save one.</div>';
}
async function renderTemplate(slug,name){
toast('Queueing '+name+'…');
const r=await fetch('/v1/templates/render',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:slug})});
const j=await r.json().catch(()=>({}));
toast(r.ok?'Render queued → see Queue & History':'Failed: '+(errMsg(j)||('error '+r.status)));
}
function editorCtx(){ if(CTX&&CTX.id)location.href='/?advanced=1&load='+encodeURIComponent(CTX.id); }
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';
$('ctxeditor').style.display=(it.output&&it.id&&!String(it.id).startsWith('failed-'))?'':'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=''; $('ctxeditor').style.display='none'; $('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: the favorited run's graph is embedded in its output (fetched via
// /v1/workflow by the run's id) → POST /v1/templates, the ONE template store the
// Editor's ★ Template also writes to.
let g=null; try{const wr=await fetch('/v1/workflow?id='+encodeURIComponent(FAVKEY)); if(wr.ok)g=(await wr.json()).graph;}catch(_){}
if(!g){$('tmpldlg').close();toast('No graph on this run — open it in the Editor and use ★ Template');return;}
const r=await fetch('/v1/templates',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:nm,graph:g})});
const j=await r.json().catch(()=>({}));
$('tmpldlg').close();toast(r.ok?('★ Saved "'+nm+'" — see Templates'):('Failed: '+(errMsg(j)||('error '+r.status))));
if(r.ok&&!$('templates').hidden)loadOrgTemplates();
}
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 uc=$('user'); if(uc){uc.addEventListener('click',e=>{e.stopPropagation();$('acctwho').textContent=($('org').textContent||'').trim()||'Account';$('acctmenu').classList.toggle('on');});
document.addEventListener('click',e=>{const m=$('acctmenu');if(m&&m.classList.contains('on')&&!m.contains(e.target))m.classList.remove('on');});}
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>
File diff suppressed because it is too large Load Diff
+283
View File
@@ -0,0 +1,283 @@
"""Per-org work log — the ONE record of every render this org dispatched.
The single dispatch funnel (``studio_home.dispatch_fix`` / ``dispatch_compose`` /
``rerun``) writes one row here at dispatch time, so the studio surface can show:
* the queue/history with REAL status queued · running · done · failed ·
cancelled including gpu-lane jobs the local /queue can't see (that was the
'nothing showed as queued' report) and dispatches that failed before queuing;
* the full context that produced each item prompt, reference images/uploads,
the workflow family (fix / compose / ), params, and the process trail;
* parent child lineage: each derived asset records the library asset(s) it was
derived from, so any item's versions (ancestors + later fixes) form a chain.
Storage is one JSON array per org at ``orgs/<org>/output/worklog.json`` the same
per-org metadata pattern as ``library.json`` / ``render_jobs.json`` / ``ratings.json``
(atomic tmp+replace). No new database. No backfill: history starts when this ships;
items generated earlier have no recorded row (and derived items no recorded parent).
Tenant isolation: every function takes an already-resolved ``org`` (the caller's
IAM org, resolved server-side by ``studio_home._org_of``). A row lives only in its
org's file, so one org can never read or mutate another's log.
"""
from __future__ import annotations
import json
import time
import uuid
from pathlib import Path
import folder_paths
# Cap the file so a busy org can't grow it without bound; oldest rows drop off.
_CAP = 1000
_TERMINAL = ("done", "failed", "cancelled")
def _dir(org: str) -> Path:
return Path(folder_paths.get_org_output_directory(org))
def _path(org: str) -> Path:
return _dir(org) / "worklog.json"
def load(org: str) -> list[dict]:
try:
rows = json.loads(_path(org).read_text())
return rows if isinstance(rows, list) else []
except Exception:
return []
def _save(org: str, rows: list[dict]) -> None:
d = _dir(org)
d.mkdir(parents=True, exist_ok=True)
p = _path(org)
tmp = p.with_name(p.name + ".tmp")
tmp.write_text(json.dumps(rows[-_CAP:]))
tmp.replace(p)
def record(org: str, *, kind: str, prompt: str, refs: list, uploads: list,
parents: list, output_prefix: str, params: dict | None = None,
pid: str | None = None, lane: str | None = None, node: str | None = None,
status: str = "queued", error: str | None = None) -> dict:
"""Append one dispatch row. ``pid`` is the /prompt id on success (used to match
live queue/history); a failed dispatch has no id, so a synthetic one is minted
and the row is marked failed with the reason so it STILL shows (never silent)."""
now = int(time.time())
row: dict = {
"id": pid or f"failed-{uuid.uuid4().hex[:12]}",
"ts": now,
"kind": kind,
"prompt": (prompt or "")[:800],
"refs": [str(x) for x in (refs or [])][:8],
"uploads": [str(x) for x in (uploads or [])][:8],
"parents": [str(x) for x in (parents or [])][:8],
"output_prefix": output_prefix or "",
"params": params or {},
"lane": lane or ("failed" if status == "failed" else "local"),
"node": node or ("studio pod" if (lane in (None, "local")) else "gpu"),
"status": status,
"updatedAt": now,
"events": [{"s": "queued", "ts": now}],
}
if error:
row["error"] = str(error)[:400]
row["events"].append({"s": "failed", "ts": now, "note": row["error"]})
rows = load(org)
rows.append(row)
_save(org, rows)
return row
def set_status(org: str, pid: str, status: str, note: str | None = None) -> bool:
"""Advance a row's status by prompt_id (running/done/failed/cancelled). Returns
True if a row was updated. Idempotent-safe; terminal rows are left untouched."""
if not pid:
return False
rows = load(org)
hit = False
now = int(time.time())
for r in rows:
if r.get("id") == pid and r.get("status") not in _TERMINAL:
r["status"] = status
r["updatedAt"] = now
r.setdefault("events", []).append(
{"s": status, "ts": now, **({"note": note} if note else {})})
hit = True
if hit:
_save(org, rows)
return hit
def mark_started(org: str, pid: str, at: int) -> bool:
"""Stamp when a job first begins rendering (first seen at the head of its lane).
Idempotent: only the first observation sets started_at. Feeds duration = finished
started, the basis of the ETA median."""
if not pid:
return False
rows = load(org)
hit = False
for r in rows:
if r.get("id") == pid and not r.get("started_at") and r.get("status") not in _TERMINAL:
r["started_at"] = int(at)
r["status"] = "running"
r["updatedAt"] = int(time.time())
r.setdefault("events", []).append({"s": "running", "ts": int(at)})
hit = True
if hit:
_save(org, rows)
return hit
def mark_finished(org: str, pid: str, at: int) -> bool:
"""Stamp real completion (the output file's mtime) and close the row as done."""
if not pid:
return False
rows = load(org)
hit = False
for r in rows:
if r.get("id") == pid and r.get("status") not in _TERMINAL:
r["finished_at"] = int(at)
r["status"] = "done"
r["updatedAt"] = int(time.time())
r.setdefault("events", []).append({"s": "done", "ts": int(at)})
hit = True
if hit:
_save(org, rows)
return hit
def _duration(r: dict) -> float | None:
"""Measured render seconds for a completed row: finished started when both
exist (true render time), else finished queued (turnaround, an upper bound)."""
fin = r.get("finished_at")
if fin is None:
return None
start = r.get("started_at")
if start is None:
start = r.get("ts")
if start is None:
return None
return float(fin - start) if fin > start else None
def median_durations(rows: list[dict]) -> dict:
"""Rolling median render seconds per kind (fix/compose/rerun) over completed
rows the ETA basis. Pure. Only kinds with a measured sample appear."""
buckets: dict[str, list[float]] = {}
for r in rows:
d = _duration(r)
if d and d > 0:
buckets.setdefault(r.get("kind", ""), []).append(d)
out: dict[str, int] = {}
for k, v in buckets.items():
v.sort()
n = len(v)
out[k] = int(v[n // 2] if n % 2 else (v[n // 2 - 1] + v[n // 2]) / 2)
return out
def estimate_lanes(active: list[dict], medians: dict, now: int,
default_median: int = 180) -> tuple[dict, dict]:
"""Pure position + ETA math, per lane. ``active`` items are
``{id, kind, lane, ts, status, started_at?}``. Within a lane the running item
(head) is position 1; each queued item's wait ≈ jobs-ahead × their medians +
the running item's remaining time (median elapsed). ETA to done = wait +
its own median. Returns (per-id info, per-lane count). No client math over
another tenant's data — the caller passes only THIS org's active jobs."""
lanes: dict[str, list[dict]] = {}
for j in active:
lanes.setdefault(j.get("lane") or "local", []).append(j)
info: dict[str, dict] = {}
counts: dict[str, int] = {}
for lane, jobs in lanes.items():
jobs.sort(key=lambda j: (0 if j.get("status") == "running" else 1, j.get("ts", 0)))
counts[lane] = len(jobs)
cum = 0.0 # seconds until the START of the current item
for idx, j in enumerate(jobs):
m = medians.get(j.get("kind", ""), default_median)
row = {"lane": lane, "position": idx + 1, "of": len(jobs),
"node": j.get("node") or ("studio pod" if lane == "local" else "gpu")}
if idx == 0 and j.get("status") == "running":
started = j.get("started_at") or j.get("ts") or now
elapsed = max(0, now - started)
remaining = max(0.0, m - elapsed)
row.update(elapsed_seconds=int(elapsed), wait_seconds=0,
remaining_seconds=int(remaining), eta_seconds=int(remaining))
cum = remaining
else:
row.update(elapsed_seconds=0, wait_seconds=int(cum),
remaining_seconds=None, eta_seconds=int(cum + m))
cum += m
info[j["id"]] = row
return info, counts
def build_lineage(rows: list[dict], target: str) -> dict:
"""Pure: given work-log rows (each with ``parents`` = source library paths and
``output`` = the landed library path, resolved by the caller) and a target
library asset path, return its version chain:
{"ancestors": [older immediate parent], "node": <target>,
"descendants": [later derivations, chronological]}
Each entry is ``{path, prompt, kind, ts}``. Ancestors walk the first-parent edge
up from the row that PRODUCED the target; descendants are rows that name the
target among their parents (and, transitively, their outputs). Cycles and
missing rows terminate the walk never loops."""
by_output: dict[str, dict] = {}
for r in rows:
out = r.get("output")
if out and out not in by_output:
by_output[out] = r
def entry(path: str, r: dict | None) -> dict:
return {
"path": path,
"prompt": (r or {}).get("prompt", ""),
"kind": (r or {}).get("kind", ""),
"ts": (r or {}).get("ts", 0),
}
# Ancestors: from the row that produced `target`, follow its first parent up.
ancestors: list[dict] = []
seen = {target}
cur = target
while True:
producer = by_output.get(cur)
if not producer:
break
parents = producer.get("parents") or []
if not parents:
break
parent = parents[0]
if parent in seen:
break
seen.add(parent)
ancestors.append(entry(parent, by_output.get(parent)))
cur = parent
ancestors.reverse() # oldest first
# Descendants: rows that derive FROM target, then rows deriving from those, …
descendants: list[dict] = []
frontier = [target]
seen_out = {target}
while frontier:
nxt: list[str] = []
kids = [r for r in rows if any(p in frontier for p in (r.get("parents") or []))]
kids.sort(key=lambda r: r.get("ts", 0))
for r in kids:
out = r.get("output")
key = out or r.get("id")
if key in seen_out:
continue
seen_out.add(key)
descendants.append(entry(out or "", r))
if out:
nxt.append(out)
frontier = nxt
return {"ancestors": ancestors, "node": target, "descendants": descendants}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "hanzo-studio"
version = "0.14.1"
version = "0.17.3"
readme = "README.md"
license = { file = "LICENSE" }
requires-python = ">=3.10"
+10 -2
View File
@@ -44,7 +44,10 @@ for name in CRIT:
PY
log "locked stack:"; sed 's/^/[packs] /' "$CGEN"
# 1. Clone / pin every pack from the manifest (comment + blank lines skipped).
# 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
@@ -54,8 +57,13 @@ while read -r name url sha _rest; do
else
git clone -q --filter=blob:none "$url" "$dir"
fi
git -C "$dir" checkout -q "$sha"
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.
+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():
+112 -29
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)
@@ -229,7 +264,8 @@ class PromptServer():
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:
@@ -399,11 +435,11 @@ class PromptServer():
},
)
response = web.FileResponse(os.path.join(self.web_root, "index.html"))
response.headers['Cache-Control'] = 'no-cache'
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
# Serve the editor SPA with the shared Studio shell injected (the ONE
# chrome — Studio/Editor toggle, wallet, GPUs, chat sidebar — that the
# Studio home also includes). No-cache, same as before.
from middleware.studio_home import editor_index
return editor_index(self.web_root)
@routes.get("/embeddings")
def get_embeddings(request):
@@ -968,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")
@@ -1050,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:
@@ -1064,19 +1120,15 @@ 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
org_id = self._get_org_id(request) # IAM org — scopes outputs + billing
extra_data["org_id"] = org_id
# BYO-GPU dispatch: cloud pods only. A worker-mode node IS the
# render backend — it must queue locally, never re-dispatch.
dispatched = False
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
dispatched = dispatch_if_worker(request, org_id, prompt_id, prompt)
if not dispatched:
self.prompt_queue.put((number, prompt_id, prompt, extra_data, outputs_to_execute, sensitive))
# 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)
else:
@@ -1094,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)
@@ -1112,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
@@ -1129,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)
@@ -1310,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)
@@ -1337,6 +1416,10 @@ class PromptServer():
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 -1
View File
@@ -1,3 +1,3 @@
# This file is automatically generated by the build process when version is
# updated in pyproject.toml.
__version__ = "0.14.1"
__version__ = "0.17.3"
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Studio watchdog: keep :8188 alive through the overnight karma render run.
# Emits a line ONLY on a restart event (so it's a signal, not a firehose).
# Detects: process gone, port unresponsive, or output-log silent >12min while a
# render is supposedly in flight (the 6h-hang failure mode from the QC log).
set -u
STUDIO=/home/z/work/hanzo/studio
LOG=$STUDIO/studio-local.log
launch() {
cd "$STUDIO"
# Free the port first: a stale/duplicate main.py holding :8188 makes the new
# one crash on bind (EADDRINUSE). Kill any existing listener, wait for release.
pkill -f 'main.py --listen' 2>/dev/null || true
for _ in $(seq 1 15); do
ss -ltn 2>/dev/null | grep -q ':8188 ' || break
sleep 1
done
HF_HOME=/home/z/.cache/huggingface \
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \
STUDIO_PERSIST_QUEUE=1 \
nohup .venv/bin/python main.py --listen 0.0.0.0 --port 8188 --normalvram \
--disable-auto-launch --output-directory "$STUDIO/output" >> "$LOG" 2>&1 &
sleep 1
local pid
pid=$(pgrep -f 'main.py --listen' | head -1)
[ -n "$pid" ] && sudo -n sh -c "echo -700 > /proc/$pid/oom_score_adj" 2>/dev/null || true
echo "$pid"
}
while true; do
code=$(curl -s -m 5 http://127.0.0.1:8188/system_stats -o /dev/null -w "%{http_code}" 2>/dev/null || echo 000)
if [ "$code" != "200" ]; then
# give it one grace re-check (could be mid-render, momentarily busy)
sleep 8
code=$(curl -s -m 5 http://127.0.0.1:8188/system_stats -o /dev/null -w "%{http_code}" 2>/dev/null || echo 000)
if [ "$code" != "200" ]; then
pid=$(launch)
# wait for it to answer
for i in $(seq 1 45); do
c=$(curl -s -m 4 http://127.0.0.1:8188/system_stats -o /dev/null -w "%{http_code}" 2>/dev/null || echo 000)
[ "$c" = "200" ] && break
sleep 4
done
echo "STUDIO_RESTARTED pid=$pid health=$c"
fi
fi
sleep 45
done
@@ -0,0 +1,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
@@ -0,0 +1,33 @@
"""Unit tests for BYO-GPU dispatch node identity (middleware/gpu_dispatch.py)."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from middleware import gpu_dispatch as g
def test_node_label_prefers_human_name():
assert g._node_label({"name": "spark", "hostname": "h", "gpu": True}) == "spark"
assert g._node_label({"hostname": "dbc-01", "gpu": True}) == "dbc-01"
assert g._node_label({"worker_id": "gpu-acme-123"}) == "gpu-acme-123"
assert g._node_label({"gpu_model": "RTX 4090"}) == "RTX 4090"
assert g._node_label({}) == "gpu" # never blank
def test_online_gpu_nodes_filters_offline_and_non_gpu(monkeypatch):
monkeypatch.setattr(g, "_fleet_machines", lambda tok: [
{"name": "spark", "status": "online", "gpu": True},
{"name": "idle", "status": "offline", "gpu": True},
{"name": "cpu-box", "status": "online", "gpu": False},
])
assert [m["name"] for m in g._online_gpu_nodes("tok")] == ["spark"]
assert g._has_online_gpu("tok") is True
def test_no_online_gpu_when_all_offline(monkeypatch):
monkeypatch.setattr(g, "_fleet_machines", lambda tok: [
{"name": "spark", "status": "offline", "gpu": True},
])
assert g._online_gpu_nodes("tok") == []
assert g._has_online_gpu("tok") is False
+204
View File
@@ -0,0 +1,204 @@
"""Tests for the /v1/mcp MCP-over-HTTP endpoint: initialize, tools/list, the
tools/call dispatch, DispatchError -> isError result, and unknown-method error.
Mirrors session_test.py: a real aiohttp app with a middleware that injects the
IAM user the auth layer would have set, so org resolution and the JSON-RPC
envelopes are the real thing. The render dispatch (studio_home.dispatch_fix /
dispatch_compose) is monkeypatched, so no GPU / self-POST is needed.
"""
import asyncio
import json
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from middleware import mcp
from middleware import studio_home
ORG_USER = {"sub": "hanzo/antje", "org_id": "hanzo", "orgs": ["hanzo"]}
def _app(iam_user=ORG_USER):
app = web.Application()
@web.middleware
async def inject(request, handler):
if iam_user is not None:
request["iam_user"] = iam_user
return await handler(request)
app.middlewares.append(inject)
routes = web.RouteTableDef()
mcp.add_mcp_routes(routes, None)
app.add_routes(routes)
return app
def _rpc(client, method, params=None, id_=1):
payload = {"jsonrpc": "2.0", "id": id_, "method": method}
if params is not None:
payload["params"] = params
return client.post("/v1/mcp", json=payload)
def _run(coro):
return asyncio.run(coro)
# --- initialize ----------------------------------------------------------
def test_initialize_reports_server_and_tools_capability():
async def go():
async with TestClient(TestServer(_app())) as c:
r = await _rpc(c, "initialize")
assert r.status == 200
return await r.json()
body = _run(go())
assert body["jsonrpc"] == "2.0"
assert body["id"] == 1
assert body["result"]["serverInfo"]["name"] == "hanzo-studio"
assert body["result"]["protocolVersion"]
assert "tools" in body["result"]["capabilities"]
# --- tools/list ----------------------------------------------------------
def test_tools_list_returns_the_three_render_tools():
async def go():
async with TestClient(TestServer(_app())) as c:
r = await _rpc(c, "tools/list")
return await r.json()
body = _run(go())
tools = body["result"]["tools"]
assert {t["name"] for t in tools} == {"fix", "compose", "ask"}
for t in tools:
assert t["inputSchema"]["type"] == "object"
compose = next(t for t in tools if t["name"] == "compose")
assert compose["inputSchema"]["properties"]["paths"]["minItems"] == 2
assert compose["inputSchema"]["properties"]["paths"]["maxItems"] == 5
# --- tools/call ----------------------------------------------------------
def test_tools_call_fix_dispatches(monkeypatch):
seen = {}
async def fake_fix(request, server, org, path, instruction):
seen.update(org=org, path=path, instruction=instruction)
return {"ok": True, "prompt_id": "p1", "output_prefix": "fixes/x"}
monkeypatch.setattr(studio_home, "dispatch_fix", fake_fix)
async def go():
async with TestClient(TestServer(_app())) as c:
r = await _rpc(c, "tools/call", {
"name": "fix",
"arguments": {"path": "a/b.png", "instruction": "make it red"},
})
assert r.status == 200
return await r.json()
body = _run(go())
assert seen == {"org": "hanzo", "path": "a/b.png", "instruction": "make it red"}
result = body["result"]
assert result.get("isError") is not True
payload = json.loads(result["content"][0]["text"])
assert payload["prompt_id"] == "p1"
assert payload["output_prefix"] == "fixes/x"
def test_tools_call_compose_dispatches(monkeypatch):
seen = {}
async def fake_compose(request, server, org, paths, instruction):
seen.update(org=org, paths=paths, instruction=instruction)
return {"ok": True, "prompt_id": "p2", "output_prefix": "composes/y", "refs": len(paths)}
monkeypatch.setattr(studio_home, "dispatch_compose", fake_compose)
async def go():
async with TestClient(TestServer(_app())) as c:
r = await _rpc(c, "tools/call", {
"name": "compose",
"arguments": {"paths": ["a.png", "b.png"], "instruction": "merge them"},
})
return await r.json()
body = _run(go())
assert seen["paths"] == ["a.png", "b.png"]
payload = json.loads(body["result"]["content"][0]["text"])
assert payload["prompt_id"] == "p2"
assert payload["refs"] == 2
def test_tools_call_ask_echoes_question():
async def go():
async with TestClient(TestServer(_app())) as c:
r = await _rpc(c, "tools/call", {
"name": "ask", "arguments": {"question": "which asset?"},
})
return await r.json()
body = _run(go())
payload = json.loads(body["result"]["content"][0]["text"])
assert payload["question"] == "which asset?"
assert body["result"].get("isError") is not True
def test_dispatch_error_becomes_iserror_result(monkeypatch):
async def bad_fix(request, server, org, path, instruction):
raise studio_home.DispatchError("unknown asset: nope.png")
monkeypatch.setattr(studio_home, "dispatch_fix", bad_fix)
async def go():
async with TestClient(TestServer(_app())) as c:
r = await _rpc(c, "tools/call", {
"name": "fix", "arguments": {"path": "nope.png", "instruction": "x"},
})
assert r.status == 200 # never a 500
return await r.json()
body = _run(go())
assert "error" not in body # not a JSON-RPC error
assert body["result"]["isError"] is True
assert "unknown asset" in body["result"]["content"][0]["text"]
def test_unknown_tool_is_iserror_result():
async def go():
async with TestClient(TestServer(_app())) as c:
r = await _rpc(c, "tools/call", {"name": "frobnicate", "arguments": {}})
return await r.json()
body = _run(go())
assert body["result"]["isError"] is True
# --- protocol edges ------------------------------------------------------
def test_unknown_method_is_jsonrpc_error():
async def go():
async with TestClient(TestServer(_app())) as c:
r = await _rpc(c, "tools/frobnicate")
assert r.status == 200
return await r.json()
body = _run(go())
assert "result" not in body
assert body["error"]["code"] == -32601
assert body["id"] == 1
def test_notification_gets_no_body():
async def go():
async with TestClient(TestServer(_app())) as c:
r = await c.post("/v1/mcp", json={"jsonrpc": "2.0", "method": "notifications/initialized"})
return r.status, await r.text()
status, text = _run(go())
assert status == 202
assert text == ""
def test_bad_json_is_parse_error():
async def go():
async with TestClient(TestServer(_app())) as c:
r = await c.post("/v1/mcp", data="not json", headers={"Content-Type": "application/json"})
return r.status, await r.json()
status, body = _run(go())
assert status == 400
assert body["error"]["code"] == -32700
+162
View File
@@ -0,0 +1,162 @@
"""Unit tests for the per-org work log (middleware/worklog.py)."""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from middleware import worklog
@pytest.fixture
def orgdirs(tmp_path, monkeypatch):
"""Map each org to its own output dir, exactly as folder_paths does per-org, so
the tests exercise real cross-tenant isolation (one file per org)."""
def out_dir(org=None):
d = tmp_path / "orgs" / (org or "default") / "output"
d.mkdir(parents=True, exist_ok=True)
return str(d)
monkeypatch.setattr(worklog.folder_paths, "get_org_output_directory", out_dir)
return out_dir
def test_record_and_load_roundtrip(orgdirs):
row = worklog.record("acme", kind="fix", prompt="brighten", refs=["r.png"],
uploads=[], parents=["a.png"], output_prefix="fixes/a",
pid="pid-1", lane="gpu")
assert row["id"] == "pid-1" and row["status"] == "queued" and row["lane"] == "gpu"
rows = worklog.load("acme")
assert len(rows) == 1 and rows[0]["parents"] == ["a.png"] and rows[0]["kind"] == "fix"
def test_failed_dispatch_still_recorded_with_reason(orgdirs):
row = worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[],
parents=["a.png"], output_prefix="fixes/a",
status="failed", error="no GPU worker online for this org")
assert row["status"] == "failed" and row["id"].startswith("failed-")
assert "no GPU worker" in row["error"]
assert any(e["s"] == "failed" for e in row["events"]) # visible, not swallowed
def test_set_status_advances_but_respects_terminal(orgdirs):
worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
output_prefix="fixes/a", pid="p1")
assert worklog.set_status("acme", "p1", "running")
assert worklog.load("acme")[0]["status"] == "running"
assert worklog.set_status("acme", "p1", "cancelled")
# a terminal row is frozen — a late 'done' can't resurrect a cancelled job
assert not worklog.set_status("acme", "p1", "done")
assert worklog.load("acme")[0]["status"] == "cancelled"
def test_cross_tenant_isolation(orgdirs):
worklog.record("acme", kind="fix", prompt="secret acme prompt", refs=[], uploads=[],
parents=["a.png"], output_prefix="fixes/a", pid="p1")
# a different org's log is a different file — zero leakage either direction
assert worklog.load("acme") and worklog.load("globex") == []
worklog.record("globex", kind="fix", prompt="globex prompt", refs=[], uploads=[],
parents=["b.png"], output_prefix="fixes/b", pid="p2")
assert [r["prompt"] for r in worklog.load("acme")] == ["secret acme prompt"]
assert [r["prompt"] for r in worklog.load("globex")] == ["globex prompt"]
def test_cap_keeps_newest(orgdirs, monkeypatch):
monkeypatch.setattr(worklog, "_CAP", 5)
for i in range(12):
worklog.record("acme", kind="fix", prompt=f"p{i}", refs=[], uploads=[],
parents=[], output_prefix=f"fixes/{i}", pid=f"p{i}")
rows = worklog.load("acme")
assert len(rows) == 5 and [r["prompt"] for r in rows] == [f"p{i}" for i in range(7, 12)]
def test_build_lineage_chain():
# a.png --fix--> b.png --fix--> c.png ; and a.png --fix--> d.png (a sibling branch)
rows = [
{"kind": "fix", "prompt": "step1", "ts": 1, "parents": ["a.png"], "output": "b.png"},
{"kind": "fix", "prompt": "step2", "ts": 2, "parents": ["b.png"], "output": "c.png"},
{"kind": "fix", "prompt": "branch", "ts": 3, "parents": ["a.png"], "output": "d.png"},
]
lin = worklog.build_lineage(rows, "c.png")
assert [a["path"] for a in lin["ancestors"]] == ["a.png", "b.png"] # oldest first
assert lin["node"] == "c.png"
assert lin["descendants"] == []
# from the root, descendants include both branches, chronological
lin2 = worklog.build_lineage(rows, "a.png")
assert [d["path"] for d in lin2["descendants"]] == ["b.png", "d.png", "c.png"]
assert lin2["ancestors"] == []
def test_record_stores_node(orgdirs):
r = worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
output_prefix="f", pid="p1", lane="gpu", node="spark")
assert r["node"] == "spark"
r2 = worklog.record("acme", kind="fix", prompt="y", refs=[], uploads=[], parents=[],
output_prefix="g", pid="p2", lane="local")
assert r2["node"] == "studio pod" # local jobs run on the pod
def test_mark_started_finished_and_median(orgdirs):
worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
output_prefix="fixes/x", pid="p1")
assert worklog.mark_started("acme", "p1", 1000)
r = worklog.load("acme")[0]
assert r["started_at"] == 1000 and r["status"] == "running"
assert not worklog.mark_started("acme", "p1", 2000) # idempotent: keeps first
assert worklog.load("acme")[0]["started_at"] == 1000
assert worklog.mark_finished("acme", "p1", 1050)
r = worklog.load("acme")[0]
assert r["finished_at"] == 1050 and r["status"] == "done"
assert worklog.median_durations(worklog.load("acme"))["fix"] == 50 # 1050-1000
def test_median_durations_per_kind():
rows = [
{"kind": "fix", "ts": 100, "started_at": 100, "finished_at": 110}, # 10
{"kind": "fix", "ts": 200, "started_at": 200, "finished_at": 220}, # 20
{"kind": "fix", "ts": 300, "started_at": 300, "finished_at": 360}, # 60 → median 20
{"kind": "compose", "ts": 0, "started_at": 0, "finished_at": 100}, # 100
{"kind": "fix", "ts": 400}, # unfinished → ignored
]
m = worklog.median_durations(rows)
assert m == {"fix": 20, "compose": 100}
# no started_at → duration falls back to finished queued(ts)
assert worklog.median_durations([{"kind": "rerun", "ts": 0, "finished_at": 30}]) == {"rerun": 30}
def test_estimate_lanes_positions_and_eta():
now = 1000
active = [
{"id": "a", "kind": "fix", "lane": "gpu", "ts": 100, "status": "running", "started_at": now - 60, "node": "spark"},
{"id": "b", "kind": "fix", "lane": "gpu", "ts": 200, "status": "queued", "node": "spark"},
{"id": "c", "kind": "compose", "lane": "gpu", "ts": 300, "status": "queued", "node": "spark"},
{"id": "d", "kind": "fix", "lane": "local", "ts": 150, "status": "queued", "node": "studio pod"},
]
medians = {"fix": 120, "compose": 300}
est, counts = worklog.estimate_lanes(active, medians, now)
assert counts == {"gpu": 3, "local": 1}
# running: elapsed 60 of median 120 → 60 remaining
assert est["a"]["position"] == 1 and est["a"]["of"] == 3
assert est["a"]["elapsed_seconds"] == 60 and est["a"]["remaining_seconds"] == 60 and est["a"]["eta_seconds"] == 60
assert est["a"]["node"] == "spark"
# queued #2: wait = running remaining(60); eta = 60 + own fix median(120)
assert est["b"]["position"] == 2 and est["b"]["wait_seconds"] == 60 and est["b"]["eta_seconds"] == 180
# queued #3: wait = 60 + b's fix(120) = 180; eta = 180 + compose(300)
assert est["c"]["position"] == 3 and est["c"]["wait_seconds"] == 180 and est["c"]["eta_seconds"] == 480
# separate lane, single queued item, nothing running → starts ~now
assert est["d"]["position"] == 1 and est["d"]["of"] == 1 and est["d"]["wait_seconds"] == 0 and est["d"]["eta_seconds"] == 120
def test_estimate_lanes_default_median_for_unknown_kind():
est, _ = worklog.estimate_lanes(
[{"id": "x", "kind": "video", "lane": "gpu", "ts": 1, "status": "queued"}], {}, 0, default_median=200)
assert est["x"]["eta_seconds"] == 200
def test_build_lineage_terminates_on_cycle():
rows = [
{"kind": "fix", "prompt": "x", "ts": 1, "parents": ["y.png"], "output": "x.png"},
{"kind": "fix", "prompt": "y", "ts": 2, "parents": ["x.png"], "output": "y.png"},
]
lin = worklog.build_lineage(rows, "x.png") # must not loop forever
assert lin["node"] == "x.png"
@@ -0,0 +1,590 @@
"""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"
# ── Shared shell: editor index injection + templates + workflow + wallet ─────────
async def _client(server, org_middleware=True):
"""A real aiohttp app with the studio-home routes and a fake-auth middleware that
stamps request['iam_user'] from an X-Org header so route tests exercise the
SAME server-side org scoping (_org_of) production uses."""
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
@web.middleware
async def fake_auth(request, handler):
o = request.headers.get("X-Org")
if o:
request["iam_user"] = {"org_id": o, "orgs": [o]}
return await handler(request)
app = web.Application(middlewares=[fake_auth] if org_middleware else [])
routes = web.RouteTableDef()
sh.add_studio_home_routes(routes, server)
app.add_routes(routes)
ts = TestServer(app)
await ts.start_server()
client = TestClient(ts)
await client.start_server()
return client
def test_editor_index_injects_shell_once(tmp_path):
root = tmp_path / "web"
root.mkdir()
(root / "index.html").write_text("<html><head></head><body><div id=app></div></body></html>")
resp = sh.editor_index(str(root))
assert resp.headers["Cache-Control"] == "no-cache"
assert '<link rel="stylesheet" href="/studio/shell.css"></head>' in resp.text
assert '<script src="/studio/shell.js" defer></script></body>' in resp.text
# idempotent: an index that already carries the shell is served unchanged
(root / "index.html").write_text(resp.text)
again = sh.editor_index(str(root))
assert again.text.count("/studio/shell.js") == 1
def test_template_api_prompt_shapes():
api = {"9": {"class_type": "KSampler", "inputs": {}}, "15": {"class_type": "SaveImage", "inputs": {}}}
assert sh._template_api_prompt({"output": api, "workflow": {"nodes": []}}) == api # editor save
assert sh._template_api_prompt(api) == api # bare API prompt
assert sh._template_api_prompt({"nodes": [{"id": 1}], "last_node_id": 1}) is None # UI-only, not runnable
assert sh._template_api_prompt({}) is None and sh._template_api_prompt("x") is None
assert sh._template_slug("Karma — Ghost Mannequin!") == "karma-ghost-mannequin"
@pytest.mark.asyncio
async def test_templates_crud_and_tenant_isolation(tmp_path, monkeypatch):
_orgaware(tmp_path, monkeypatch)
client = await _client(_FakeServer())
graph = {"output": {"15": {"class_type": "SaveImage", "inputs": {"filename_prefix": "t/x"}}}}
r = await client.post("/v1/templates", json={"name": "Ghost Mannequin", "graph": graph}, headers={"X-Org": "acme"})
assert r.status == 200 and (await r.json())["slug"] == "ghost-mannequin"
# acme sees it (renderable — the graph carries an API prompt), fetches it by slug
r = await client.get("/v1/templates", headers={"X-Org": "acme"})
body = await r.json()
assert body["org"] == "acme" and len(body["templates"]) == 1
assert body["templates"][0]["name"] == "Ghost Mannequin" and body["templates"][0]["renderable"] is True
r = await client.get("/v1/templates/ghost-mannequin", headers={"X-Org": "acme"})
assert (await r.json())["graph"] == graph
# tenant isolation: globex's list is empty and it can't read acme's template
assert (await (await client.get("/v1/templates", headers={"X-Org": "globex"})).json())["templates"] == []
assert (await client.get("/v1/templates/ghost-mannequin", headers={"X-Org": "globex"})).status == 404
# validation
assert (await client.post("/v1/templates", json={"name": "", "graph": graph}, headers={"X-Org": "acme"})).status == 400
assert (await client.post("/v1/templates", json={"name": "x", "graph": {}}, headers={"X-Org": "acme"})).status == 400
await client.close()
@pytest.mark.asyncio
async def test_template_render_dispatches_and_rejects_ui_only(tmp_path, monkeypatch):
_orgaware(tmp_path, monkeypatch)
async def fake_queue(request, server, graph):
return "pid-tmpl"
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
client = await _client(_FakeServer())
runnable = {"output": {"9": {"class_type": "KSampler", "inputs": {"seed": 1}},
"15": {"class_type": "SaveImage", "inputs": {"filename_prefix": "t/run"}}}}
await client.post("/v1/templates", json={"name": "Run Me", "graph": runnable}, headers={"X-Org": "acme"})
r = await client.post("/v1/templates/render", json={"name": "run-me"}, headers={"X-Org": "acme"})
assert r.status == 200 and (await r.json())["prompt_id"] == "pid-tmpl"
assert sh.worklog.load("acme")[-1]["kind"] == "template" # went through the ONE funnel
# UI-only graph can't run headless -> 422 with a reason, not a silent dispatch
await client.post("/v1/templates", json={"name": "UI Only", "graph": {"nodes": [], "last_node_id": 0}}, headers={"X-Org": "acme"})
assert (await client.post("/v1/templates/render", json={"name": "ui-only"}, headers={"X-Org": "acme"})).status == 422
# cross-tenant render: globex has no such template -> 404
assert (await client.post("/v1/templates/render", json={"name": "run-me"}, headers={"X-Org": "globex"})).status == 404
await client.close()
@pytest.mark.asyncio
async def test_workflow_reads_embedded_graph_tenant_scoped(tmp_path, monkeypatch):
from PIL import Image
from PIL.PngImagePlugin import PngInfo
_orgaware(tmp_path, monkeypatch)
root = Path(sh.folder_paths.get_org_output_directory("acme"))
(root / "library.json").write_text('{"assets":[]}')
(root / "fixes").mkdir(parents=True, exist_ok=True)
info = PngInfo()
ui = {"nodes": [{"id": 1, "type": "SaveImage"}], "last_node_id": 1}
info.add_text("workflow", json.dumps(ui))
info.add_text("prompt", json.dumps({"1": {"class_type": "SaveImage", "inputs": {"filename_prefix": "fixes/x"}}}))
Image.new("RGB", (4, 4)).save(root / "fixes" / "x_00001_.png", "PNG", pnginfo=info)
sh.worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
output_prefix="fixes/x", pid="p1")
sh.worklog.record("acme", kind="fix", prompt="pending", refs=[], uploads=[], parents=[],
output_prefix="fixes/nope", pid="p2")
client = await _client(_FakeServer())
r = await client.get("/v1/workflow?id=p1", headers={"X-Org": "acme"})
body = await r.json()
assert r.status == 200 and body["format"] == "ui" and body["graph"] == ui # UI graph preferred
# not landed yet -> 404 (no graph to open)
assert (await client.get("/v1/workflow?id=p2", headers={"X-Org": "acme"})).status == 404
# cross-tenant id is never confirmed -> 404
assert (await client.get("/v1/workflow?id=p1", headers={"X-Org": "globex"})).status == 404
await client.close()
@pytest.mark.asyncio
async def test_wallet_proxies_and_scopes_org(tmp_path, monkeypatch):
_orgaware(tmp_path, monkeypatch)
async def fake_balance(tok):
assert tok == "Bearer tkn"
return {"availableCents": 2500, "currency": "usd", "pendingCents": 0}
monkeypatch.setattr(sh, "_finance_balance", fake_balance)
client = await _client(_FakeServer())
r = await client.get("/v1/wallet", headers={"X-Org": "acme", "Authorization": "Bearer tkn"})
body = await r.json()
assert body == {"org": "acme", "balance": 25.0, "currency": "usd"}
# signed-out (no bearer): display-only null balance, never a fabricated number
r = await client.get("/v1/wallet", headers={"X-Org": "acme"})
assert await r.json() == {"org": "acme", "balance": None, "currency": None}
await client.close()
@pytest.mark.asyncio
async def test_library_upload_roundtrip_dedup_and_tenant_isolation(tmp_path, monkeypatch):
import aiohttp
_orgaware(tmp_path, monkeypatch)
# library.json so _library_root resolves for the GET round-trip
(Path(sh.folder_paths.get_org_output_directory("acme")) / "library.json").write_text('{"assets":[]}')
(Path(sh.folder_paths.get_org_output_directory("globex")) / "library.json").write_text('{"assets":[]}')
client = await _client(_FakeServer())
png = b"\x89PNG\r\n\x1a\n" + b"acme-bytes"
# multipart upload as acme -> renders/r1.png
form = aiohttp.FormData()
form.add_field("image", png, filename="r1.png", content_type="image/png")
r = await client.post("/v1/library/upload", data=form, headers={"X-Org": "acme"})
body = await r.json()
assert r.status == 200 and body["ok"] and body["path"] == "renders/r1.png" and not body.get("existed")
# round-trip through the org-scoped file server
g = await client.get("/v1/library/file?path=renders/r1.png", headers={"X-Org": "acme"})
assert g.status == 200 and (await g.read()) == png
# dedup: identical name+size -> existed, no rewrite
form2 = aiohttp.FormData()
form2.add_field("image", png, filename="r1.png", content_type="image/png")
r2 = await client.post("/v1/library/upload", data=form2, headers={"X-Org": "acme"})
assert (await r2.json()) == {"ok": True, "existed": True, "path": "renders/r1.png"}
# tenant isolation: org B never sees org A's file; its own upload is a separate tree
assert (await client.get("/v1/library/file?path=renders/r1.png", headers={"X-Org": "globex"})).status == 404
rg = await client.post("/v1/library/upload?name=r1.png", data=b"\x89PNGglobex",
headers={"X-Org": "globex", "Content-Type": "application/octet-stream"})
assert (await rg.json())["path"] == "renders/r1.png"
# org A's bytes are untouched by org B's same-named upload
assert (await (await client.get("/v1/library/file?path=renders/r1.png", headers={"X-Org": "acme"})).read()) == png
await client.close()
@pytest.mark.asyncio
async def test_library_upload_rejects_traversal_bad_name_and_oversize(tmp_path, monkeypatch):
_orgaware(tmp_path, monkeypatch)
client = await _client(_FakeServer())
hdr = {"X-Org": "acme", "Content-Type": "application/octet-stream"}
# subpath traversal -> 400
assert (await client.post("/v1/library/upload?name=x.png&subpath=../secret", data=b"\x89PNGx", headers=hdr)).status == 400
# non-image name -> 400
assert (await client.post("/v1/library/upload?name=notes.txt", data=b"hi", headers=hdr)).status == 400
# a traversal-y NAME is neutralized to its basename, lands safely under renders/
r = await client.post("/v1/library/upload?name=../../evil.png", data=b"\x89PNGevil", headers=hdr)
assert (await r.json())["path"] == "renders/evil.png"
assert (Path(sh.folder_paths.get_org_output_directory("acme")) / "renders" / "evil.png").is_file()
# size cap -> 413
monkeypatch.setattr(sh, "_MAX_UPLOAD", 8)
assert (await client.post("/v1/library/upload?name=big.png", data=b"0123456789", headers=hdr)).status == 413
await client.close()
@pytest.mark.asyncio
async def test_library_upload_indexes_into_assets_and_worklog(tmp_path, monkeypatch):
import aiohttp
_orgaware(tmp_path, monkeypatch)
client = await _client(_FakeServer())
png = b"\x89PNG\r\n\x1a\n" + b"render-bytes"
form = aiohttp.FormData()
form.add_field("image", png, filename="shot.png", content_type="image/png")
r = await client.post("/v1/library/upload?node=spark&design=karma&kind=render",
data=form, headers={"X-Org": "acme"})
assert (await r.json())["path"] == "renders/shot.png"
# instantly findable in Assets (library.json entry, draft)
lib = await (await client.get("/v1/library", headers={"X-Org": "acme"})).json()
a = next(x for x in lib["assets"] if x["path"] == "renders/shot.png")
assert a["status"] == "draft" and a["kind"] == "render" and a["design"] == "karma" and a["tags"] == []
# instantly findable in Queue & History with its source node + resolved output
wl = await (await client.get("/v1/worklog", headers={"X-Org": "acme"})).json()
it = next(x for x in wl["items"] if x["output_prefix"] == "renders/shot.png")
assert it["kind"] == "render" and it["node"] == "spark" and it["status"] == "done"
assert it["output"] == "renders/shot.png" and it["lane"] == "gpu"
# tenant isolation: org B sees neither the asset nor the row
lib2 = await (await client.get("/v1/library", headers={"X-Org": "globex"})).json()
assert all(x["path"] != "renders/shot.png" for x in lib2.get("assets", []))
assert (await (await client.get("/v1/worklog", headers={"X-Org": "globex"})).json())["items"] == []
# dedup: re-upload identical bytes adds no second asset or row
form2 = aiohttp.FormData()
form2.add_field("image", png, filename="shot.png", content_type="image/png")
r2 = await client.post("/v1/library/upload?node=spark", data=form2, headers={"X-Org": "acme"})
assert (await r2.json()).get("existed") is True
lib3 = await (await client.get("/v1/library", headers={"X-Org": "acme"})).json()
assert sum(1 for x in lib3["assets"] if x["path"] == "renders/shot.png") == 1
wl3 = await (await client.get("/v1/worklog", headers={"X-Org": "acme"})).json()
assert sum(1 for x in wl3["items"] if x["output_prefix"] == "renders/shot.png") == 1
await client.close()
@pytest.mark.asyncio
async def test_worklog_search_matches_paths_and_refs_not_just_prompt(tmp_path, monkeypatch):
_orgaware(tmp_path, monkeypatch)
(Path(sh.folder_paths.get_org_output_directory("acme")) / "library.json").write_text('{"assets":[]}')
# an ingested render: empty prompt, findable only by its filename / ref
sh.worklog.record("acme", kind="render", prompt="", refs=["srcphoto.png"], uploads=[],
parents=[], output_prefix="renders/model_shot.png", pid="r1", status="done")
sh.worklog.record("acme", kind="fix", prompt="brighten the sky", refs=[], uploads=[],
parents=[], output_prefix="fixes/x", pid="r2")
client = await _client(_FakeServer())
async def ids(q):
r = await client.get("/v1/worklog?q=" + q, headers={"X-Org": "acme"})
return [it["id"] for it in (await r.json())["items"]]
assert await ids("model_shot") == ["r1"] # by output filename, empty prompt
assert await ids("srcphoto") == ["r1"] # by reference
assert await ids("brighten") == ["r2"] # prompt search still works
assert await ids("nomatch") == []
await client.close()
@@ -0,0 +1,744 @@
{
"id": "hanzo-full-generative-pipeline",
"revision": 0,
"last_node_id": 11,
"last_link_id": 9,
"nodes": [
{
"id": 1,
"type": "HanzoChat",
"pos": [
40,
40
],
"size": [
420,
340
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "COMBO",
"widget": {
"name": "model"
},
"link": null
},
{
"name": "prompt",
"type": "STRING",
"widget": {
"name": "prompt"
},
"link": null
},
{
"name": "system",
"type": "STRING",
"widget": {
"name": "system"
},
"link": null
},
{
"name": "temperature",
"type": "FLOAT",
"widget": {
"name": "temperature"
},
"link": null
},
{
"name": "max_tokens",
"type": "INT",
"widget": {
"name": "max_tokens"
},
"link": null
},
{
"name": "stream",
"type": "BOOLEAN",
"widget": {
"name": "stream"
},
"link": null
}
],
"outputs": [
{
"name": "text",
"type": "STRING",
"links": [
1,
2,
3,
4
]
}
],
"properties": {
"Node name for S&R": "HanzoChat"
},
"widgets_values": [
"default",
"/no_think Write one vivid comma-separated image prompt for a majestic bronze dragon perched on a cliff at golden hour. Output only the prompt.",
"You are a concise text-to-image prompt engineer.",
0.4,
200,
true
]
},
{
"id": 2,
"type": "HanzoImageGen",
"pos": [
500,
40
],
"size": [
340,
220
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "prompt",
"type": "STRING",
"widget": {
"name": "prompt"
},
"link": 1
},
{
"name": "width",
"type": "INT",
"widget": {
"name": "width"
},
"link": null
},
{
"name": "height",
"type": "INT",
"widget": {
"name": "height"
},
"link": null
},
{
"name": "model",
"type": "COMBO",
"widget": {
"name": "model"
},
"link": null
},
{
"name": "n",
"type": "INT",
"widget": {
"name": "n"
},
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
5,
6
]
}
],
"properties": {
"Node name for S&R": "HanzoImageGen"
},
"widgets_values": [
"",
1024,
1024,
"default",
1
]
},
{
"id": 3,
"type": "HanzoImageTo3D",
"pos": [
880,
40
],
"size": [
320,
200
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 5
},
{
"name": "format",
"type": "COMBO",
"widget": {
"name": "format"
},
"link": null
},
{
"name": "texture",
"type": "BOOLEAN",
"widget": {
"name": "texture"
},
"link": null
},
{
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": null
},
{
"name": "steps",
"type": "INT",
"widget": {
"name": "steps"
},
"link": null
}
],
"outputs": [
{
"name": "mesh_path",
"type": "STRING",
"links": []
}
],
"properties": {
"Node name for S&R": "HanzoImageTo3D"
},
"widgets_values": [
"glb",
true,
0,
30
]
},
{
"id": 4,
"type": "HanzoTextToVideo",
"pos": [
500,
320
],
"size": [
340,
260
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "prompt",
"type": "STRING",
"widget": {
"name": "prompt"
},
"link": 2
},
{
"name": "num_frames",
"type": "INT",
"widget": {
"name": "num_frames"
},
"link": null
},
{
"name": "width",
"type": "INT",
"widget": {
"name": "width"
},
"link": null
},
{
"name": "height",
"type": "INT",
"widget": {
"name": "height"
},
"link": null
},
{
"name": "steps",
"type": "INT",
"widget": {
"name": "steps"
},
"link": null
},
{
"name": "model",
"type": "COMBO",
"widget": {
"name": "model"
},
"link": null
}
],
"outputs": [
{
"name": "frames",
"type": "IMAGE",
"links": [
7
]
}
],
"properties": {
"Node name for S&R": "HanzoTextToVideo"
},
"widgets_values": [
"",
81,
832,
480,
30,
"default"
]
},
{
"id": 5,
"type": "HanzoTTS",
"pos": [
500,
620
],
"size": [
340,
160
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "text",
"type": "STRING",
"link": 3
},
{
"name": "model",
"type": "COMBO",
"widget": {
"name": "model"
},
"link": null
},
{
"name": "response_format",
"type": "COMBO",
"widget": {
"name": "response_format"
},
"link": null
}
],
"outputs": [
{
"name": "audio",
"type": "AUDIO",
"links": [
8
]
}
],
"properties": {
"Node name for S&R": "HanzoTTS"
},
"widgets_values": [
"default",
"wav"
]
},
{
"id": 6,
"type": "HanzoMusic",
"pos": [
40,
620
],
"size": [
420,
200
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "prompt",
"type": "STRING",
"widget": {
"name": "prompt"
},
"link": null
},
{
"name": "model",
"type": "COMBO",
"widget": {
"name": "model"
},
"link": null
},
{
"name": "response_format",
"type": "COMBO",
"widget": {
"name": "response_format"
},
"link": null
}
],
"outputs": [
{
"name": "audio",
"type": "AUDIO",
"links": [
9
]
}
],
"properties": {
"Node name for S&R": "HanzoMusic"
},
"widgets_values": [
"epic cinematic orchestral, driving percussion, brass swells, heroic",
"default",
"wav"
]
},
{
"id": 7,
"type": "SaveImage",
"pos": [
1240,
40
],
"size": [
380,
380
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 6
},
{
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [],
"properties": {
"Node name for S&R": "SaveImage"
},
"widgets_values": [
"hanzo_full_pipeline_still"
]
},
{
"id": 8,
"type": "SaveImage",
"pos": [
880,
320
],
"size": [
340,
300
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 7
},
{
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [],
"properties": {
"Node name for S&R": "SaveImage"
},
"widgets_values": [
"hanzo_full_pipeline_video_frames"
]
},
{
"id": 9,
"type": "SaveAudio",
"pos": [
880,
620
],
"size": [
320,
140
],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"name": "audio",
"type": "AUDIO",
"link": 8
},
{
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [],
"properties": {
"Node name for S&R": "SaveAudio"
},
"widgets_values": [
"audio/hanzo_full_pipeline_narration"
]
},
{
"id": 10,
"type": "SaveAudio",
"pos": [
40,
840
],
"size": [
420,
140
],
"flags": {},
"order": 9,
"mode": 0,
"inputs": [
{
"name": "audio",
"type": "AUDIO",
"link": 9
},
{
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [],
"properties": {
"Node name for S&R": "SaveAudio"
},
"widgets_values": [
"audio/hanzo_full_pipeline_music"
]
},
{
"id": 11,
"type": "HanzoSaveText",
"pos": [
1240,
460
],
"size": [
340,
160
],
"flags": {},
"order": 10,
"mode": 0,
"inputs": [
{
"name": "text",
"type": "STRING",
"link": 4
},
{
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [],
"properties": {
"Node name for S&R": "HanzoSaveText"
},
"widgets_values": [
"hanzo_full_pipeline_prompt"
]
}
],
"links": [
[
1,
1,
0,
2,
0,
"STRING"
],
[
2,
1,
0,
4,
0,
"STRING"
],
[
3,
1,
0,
5,
0,
"STRING"
],
[
4,
1,
0,
11,
0,
"STRING"
],
[
5,
2,
0,
3,
0,
"IMAGE"
],
[
6,
2,
0,
7,
0,
"IMAGE"
],
[
7,
4,
0,
8,
0,
"IMAGE"
],
[
8,
5,
0,
9,
0,
"AUDIO"
],
[
9,
6,
0,
10,
0,
"AUDIO"
]
],
"groups": [
{
"title": "Prompt",
"bounding": [
20,
0,
460,
380
],
"color": "#3f789e"
},
{
"title": "Image + 3D",
"bounding": [
480,
0,
1140,
300
],
"color": "#a1309b"
},
{
"title": "Video",
"bounding": [
480,
300,
740,
320
],
"color": "#b58b2a"
},
{
"title": "Audio (speech + music)",
"bounding": [
20,
600,
1180,
400
],
"color": "#2a7d4f"
}
],
"config": {},
"extra": {},
"version": 0.4
}
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo
+32
View File
@@ -0,0 +1,32 @@
# Studio web — the Hanzo Studio product frontend
Our unique studio frontend: a specialized **chat app** on the shared Hanzo stack.
You say what to create; the cloud `/v1/chat` orchestrator drives studio's render
tools and the outputs land in the library. This is the default surface at
`studio.hanzo.ai/studio`.
- **Stack**: Vite + React + TS. `@hanzo/*` packages are npm-published, so a clean
`npm install` — no workspace linking.
- **Chat**: `POST api.hanzo.ai/v1/chat {capability, messages}``{reply, actions, ops}`
— the cloud `clients/chat` orchestrator, called **directly** (no studio proxy). The
`hanzo_token` cookie is `.hanzo.ai`-scoped, so `credentials:'include'` carries it
cross-origin and the handler bills the caller org. (Gateway CORS must allow the
`studio.hanzo.ai` origin with credentials.)
- **Engine**: the studio pod serves `/v1/session`, `/v1/library`, `/v1/render-queue`,
`/v1/gpu-status` same-origin (same cookie) — no token handling in the browser.
```bash
npm install
npm run build # -> dist/ (served at /studio; base = /studio/)
npm run dev # local, set VITE_STUDIO_API=https://studio.hanzo.ai
```
The ComfyUI-derived node editor ("Advanced mode") is a **separate** repo —
`hanzoai/studio-ui` (formerly `studio-frontend`) — not this app.
## Status / next
- Increment 1 (done): chat wired to `/v1/chat` create + live library/queue/gpu strip.
- Increment 2: swap the transcript/composer for `@hanzo/chat` `<Chat>` on `@hanzo/gui`
(GuiProvider + SessionProvider, copying console2's `Provider.tsx` spine).
- Wiring: Dockerfile multi-stage to build `dist/` and serve it at `/studio` in
place of `middleware/studio_home.html`. (No proxy — chat hits api.hanzo.ai direct.)
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Hanzo Studio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Generated Vendored
+1888
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@hanzo/studio-chat",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Studio as a specialized chat app — @hanzo/ai + @hanzo/gui talking to the cloud /v1/chat orchestrator, served at studio.hanzo.ai/studio.",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^6.0.7"
}
}
+115
View File
@@ -0,0 +1,115 @@
import { useEffect, useRef, useState } from 'react'
import {
chat, getSession, getLibrary, getRenderQueue, gpuOnline, thumbURL,
type ChatMessage, type Session, type Asset, type RenderJob,
} from './api'
// Studio-chat: the specialized chat surface. You say what to create; the cloud
// /v1/agent "create" preset drives studio's render tools (fix/compose) and the
// outputs land in the library strip. Increment 1 uses a minimal own-UI; the next
// increment swaps the transcript/composer for @hanzo/chat's <Chat> on @hanzo/gui.
export function App() {
const [session, setSession] = useState<Session>({})
const [messages, setMessages] = useState<ChatMessage[]>([])
const [conversationId, setConversationId] = useState<string>()
const [input, setInput] = useState('')
const [busy, setBusy] = useState(false)
const [assets, setAssets] = useState<Asset[]>([])
const [jobs, setJobs] = useState<RenderJob[]>([])
const [gpu, setGpu] = useState(false)
const scroller = useRef<HTMLDivElement>(null)
useEffect(() => { getSession().then(setSession) }, [])
// Poll the library + in-flight queue so dispatched renders appear as they land.
useEffect(() => {
let alive = true
const tick = async () => {
if (!alive) return
const [lib, q, on] = await Promise.all([getLibrary(), getRenderQueue(), gpuOnline()])
if (!alive) return
setAssets(lib.assets.filter(a => a.status !== 'deleted').slice(0, 24))
setJobs(q.jobs); setGpu(on)
}
tick()
const t = setInterval(tick, 5000)
return () => { alive = false; clearInterval(t) }
}, [])
useEffect(() => { scroller.current?.scrollTo(0, scroller.current.scrollHeight) }, [messages, busy])
async function send() {
const text = input.trim()
if (!text || busy) return
const next = [...messages, { role: 'user' as const, content: text }]
setMessages(next); setInput(''); setBusy(true)
try {
const r = await chat(next, conversationId)
setConversationId(r.conversationId)
const note = r.actions.length
? `\n\n${r.actions.map(a => a.error ? `⚠︎ ${a.name}: ${a.error}` : `${a.name} dispatched`).join('\n')}`
: ''
setMessages([...next, { role: 'assistant', content: (r.reply || '…') + note }])
} catch (e) {
setMessages([...next, { role: 'assistant', content: `Couldn't reach the studio chat (${(e as Error).message}).` }])
} finally {
setBusy(false)
}
}
return (
<div className="app">
<header className="top">
<div className="brand">Hanzo <b>Studio</b></div>
<div className="spacer" />
<div className={`gpu ${gpu ? 'on' : 'off'}`}>{gpu ? 'GPU online' : 'no GPU'}</div>
<div className="who">{session.authenticated ? (session.org || session.user || 'signed in') : 'sign in'}</div>
</header>
<main className="body">
<section className="chat">
<div className="scroll" ref={scroller}>
{messages.length === 0 && (
<div className="hero">
<h1>What should we create?</h1>
<p>Describe an edit or a new shot make the Valentina back crotchless,
put design 13 on model 01. Ill run it on your GPU and it lands below.</p>
</div>
)}
{messages.map((m, i) => (
<div key={i} className={`msg ${m.role}`}><div className="bubble">{m.content}</div></div>
))}
{busy && <div className="msg assistant"><div className="bubble dim">working</div></div>}
</div>
<div className="composer">
<textarea
value={input} placeholder="Describe what to create or change…"
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }}
/>
<button onClick={send} disabled={busy || !input.trim()}>Send</button>
</div>
</section>
<aside className="rail">
{jobs.length > 0 && (
<div className="queue">
<div className="rail-h">Rendering ({jobs.length})</div>
{jobs.map(jb => <div key={jb.id} className="job">{jb.prefix || jb.id.slice(0, 8)}</div>)}
</div>
)}
<div className="rail-h">Library</div>
<div className="grid">
{assets.map(a => (
<a key={a.path} className="cell" href={thumbURL(a.path)} target="_blank" rel="noreferrer">
<img loading="lazy" src={thumbURL(a.path)} alt="" />
</a>
))}
{assets.length === 0 && <div className="empty">Renders appear here.</div>}
</div>
</aside>
</main>
</div>
)
}
+53
View File
@@ -0,0 +1,53 @@
// The studio-chat data layer. Two backends, no proxy layer between them:
// - the agent orchestrator: POST api.hanzo.ai/v1/agent (github.com/hanzoai/agent,
// mounted in the cloud binary) — called DIRECTLY, no studio pass-through. The
// hanzo_token cookie is scoped to .hanzo.ai so credentials:'include' carries it
// cross-origin, and the round replays it into the per-org-billed completion.
// (Gateway CORS must allow the studio.hanzo.ai origin with credentials.)
// - the studio engine API: /v1/session, /v1/library, /v1/render-queue, … —
// same-origin on studio.hanzo.ai (same cookie).
// No token handling in the browser: the cookie is the credential for both.
// VITE_STUDIO_API / VITE_AGENT_API override the bases for local dev.
const BASE = (import.meta.env.VITE_STUDIO_API ?? '').replace(/\/$/, '')
const AGENT_API = (import.meta.env.VITE_AGENT_API ?? 'https://api.hanzo.ai').replace(/\/$/, '')
async function j<T>(url: string, init?: RequestInit): Promise<T> {
const r = await fetch(url, { credentials: 'include', ...init })
if (!r.ok) throw new Error(`${url} -> ${r.status}`)
return r.json() as Promise<T>
}
export type Role = 'user' | 'assistant'
export interface ChatMessage { role: Role; content: string }
export interface Action { name: string; args?: Record<string, unknown>; result?: unknown; error?: string }
export interface Op { name: string; args?: Record<string, unknown> }
export interface ChatReply { reply: string; actions: Action[]; ops: Op[]; conversationId: string }
// One turn of a preset: the model edits/combines library assets; `actions` are the
// renders it dispatched (each result carries a prompt_id). Pass the prior reply's
// conversationId to continue a thread — the orchestrator persists per-org history.
export function chat(messages: ChatMessage[], conversationId?: string, preset = 'create'): Promise<ChatReply> {
return j<ChatReply>(`${AGENT_API}/v1/agent`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preset, messages, conversationId }),
})
}
export interface Session {
user?: string; email?: string; org?: string; orgs?: string[]
authenticated?: boolean; iam_url?: string
}
export const getSession = () => j<Session>(`${BASE}/v1/session`).catch(() => ({ authenticated: false } as Session))
export interface Asset { path: string; status?: string; mtime?: number; view?: unknown }
export const getLibrary = () => j<{ org: string; assets: Asset[] }>(`${BASE}/v1/library`).catch(() => ({ org: '', assets: [] }))
export interface RenderJob { id: string; prefix?: string; refs?: string[]; ts?: number; age?: number; status?: string }
export const getRenderQueue = () => j<{ jobs: RenderJob[] }>(`${BASE}/v1/render-queue`).catch(() => ({ jobs: [] }))
export const gpuOnline = () => j<{ online: boolean }>(`${BASE}/v1/gpu-status`).then(r => r.online).catch(() => false)
// Thumbnail URL for a library asset (the studio pod serves a downsized JPEG).
export const thumbURL = (path: string) => `${BASE}/v1/library/file?thumb=1&path=${encodeURIComponent(path)}`
export const fileURL = (path: string) => `${BASE}/v1/library/file?path=${encodeURIComponent(path)}`
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import './styles.css'
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+48
View File
@@ -0,0 +1,48 @@
:root {
--bg: #0d0d0f; --panel: #151517; --line: #26262b; --text: #f4f4f6;
--dim: #9a9aa2; --accent: #f4f4f6; --accent-ink: #0d0d0f;
}
@media (prefers-color-scheme: light) {
:root { --bg: #fafafa; --panel: #fff; --line: #e6e6ea; --text: #16161a; --dim: #6b6b73; --accent: #16161a; --accent-ink: #fff; }
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body { background: var(--bg); color: var(--text); font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.app { display: flex; flex-direction: column; height: 100%; }
.top { display: flex; align-items: center; gap: 12px; padding: 12px 18px; border-bottom: 1px solid var(--line); }
.brand { font-weight: 500; letter-spacing: .2px; } .brand b { font-weight: 700; }
.spacer { flex: 1; }
.gpu { font-size: 12px; padding: 3px 8px; border-radius: 999px; border: 1px solid var(--line); color: var(--dim); }
.gpu.on { color: var(--text); } .gpu.on::before { content: "● "; color: #35c26b; }
.who { font-size: 13px; color: var(--dim); }
.body { flex: 1; display: grid; grid-template-columns: 1fr 320px; min-height: 0; }
.chat { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--line); }
.scroll { flex: 1; overflow-y: auto; padding: 22px; }
.hero { max-width: 560px; margin: 8vh auto 0; text-align: center; }
.hero h1 { font-size: 30px; font-weight: 600; margin: 0 0 10px; }
.hero p { color: var(--dim); }
.msg { display: flex; margin: 10px 0; } .msg.user { justify-content: flex-end; }
.bubble { max-width: 74%; padding: 10px 14px; border-radius: 14px; white-space: pre-wrap; border: 1px solid var(--line); background: var(--panel); }
.msg.user .bubble { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
.bubble.dim { color: var(--dim); }
.composer { display: flex; gap: 10px; padding: 14px; border-top: 1px solid var(--line); }
.composer textarea { flex: 1; resize: none; height: 44px; padding: 11px 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); color: var(--text); font: inherit; }
.composer button { padding: 0 18px; border-radius: 12px; border: 0; background: var(--accent); color: var(--accent-ink); font-weight: 600; cursor: pointer; }
.composer button:disabled { opacity: .5; cursor: default; }
.rail { overflow-y: auto; padding: 16px; }
.rail-h { font-size: 12px; text-transform: uppercase; letter-spacing: .6px; color: var(--dim); margin: 14px 0 8px; }
.queue .job { font-size: 12px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 8px; margin-bottom: 6px; color: var(--dim); }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
.cell { aspect-ratio: 3 / 4; overflow: hidden; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); }
.cell img { width: 100%; height: 100%; object-fit: cover; display: block; }
.empty { color: var(--dim); font-size: 13px; }
@media (max-width: 820px) {
.body { grid-template-columns: 1fr; }
.rail { display: none; }
.chat { border-right: 0; }
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// Served same-origin by the studio Python pod at /studio, so the app is based at
// /studio/ and every /v1/* fetch rides the same-origin hanzo_token cookie (no CORS).
export default defineConfig({
base: '/studio/',
plugins: [react()],
build: { outDir: 'dist', sourcemap: false },
})
+76
View File
@@ -0,0 +1,76 @@
# zen-video — headless text-to-video queue (GB10)
Fronts Hanzo Studio (ComfyUI, `:8188`) on the spark GB10 box with a small,
serialized, persisted queue that turns a prompt into a real MP4. Public model
name is the **Zen video family** (`zen3-video`); the diffusion backend is the
Zen video model (Wan2.2 TI2V-5B). The upstream/private model filenames live
only in `zen_wan_workflow.py` and are NEVER returned to callers.
## Why a queue
The GB10 is one GPU — one generation at a time. A single async worker drains an
ordered queue and submits to Studio, which itself serializes on the GPU. Jobs
persist in SQLite (`jobs.db`) so a service restart never loses in-flight or
finished state; interrupted jobs are re-queued on boot.
## Two API surfaces (composable, one backend)
1. **OpenAI-style** — for direct/frontend use, gated by gateway-injected identity:
- `POST /v1/videos/generations` `{model, prompt, size?, seconds?, seed?, negative_prompt?}`
`202 {id, status:"queued", ...}`. Requires `X-Org-Id` (the gateway injects
it from the IAM JWT); anonymous is refused unless `ZEN_ALLOW_ANON=1`.
- `GET /v1/videos/generations/{id}` → status; on completion `{status:"completed", url}`.
- `GET /v1/videos/files/{name}` → the MP4 (served over the Cloudflare tunnel).
- Bills per generated second into the `usage` table (and posts to `ZEN_METER_URL` if set).
2. **do-ai-compatible async** — driven by the cloud LLM gateway (`hanzoai/ai`
`GenerateVideoDOAI`), gated by a Bearer provider key (`ZEN_PROVIDER_KEY`):
- `POST /v1/videos` `{model, prompt, size?, seconds?}``202 {id:"video_…", object:"video", status:"queued"}`
- `GET /v1/videos/{id}``{status:"queued|in_progress|completed|failed"}`
- `GET /v1/videos/{id}/content` → raw `video/mp4` (only once completed)
This is the exact Sora-style create→poll→download contract the ai service's
video client expects, so the ai registry adopts spark by pointing the
`zen3-video` provider at `https://spark-video.hanzo.ai/v1` — no ai-side
client change.
`GET /healthz` reports comfy up/down + queue depth. `GET /v1/videos/models`
lists the Zen video SKUs.
## Models
| id | default | max s | steps | $/s |
|----|---------|-------|-------|-----|
| `zen3-video` | 1280×704 | 5 | 20 | 0.20 |
| `zen3-video-fast` | 704×704 | 3 | 16 | 0.08 |
| `zen3-video-pro` | 1280×704 | 5 | 30 | 0.40 |
| `wan2-2-t2v-a14b` | 704×704 | 3 | 20 | 0.20 | (do-ai upstream id, moderate default to fit the gateway's 300s ceiling) |
## Deploy (spark, systemd)
Runs under the Studio venv (`aiohttp`, `boto3`). Unit: `zen-video.service`
(`Requires=studio.service`). Secrets in `/etc/zen-video.env` (chmod 600):
`ZEN_PROVIDER_KEY` (KMS `hanzo/video/SPARK_VIDEO_PROVIDER_KEY`).
```
sudo cp zen-video.service /etc/systemd/system/ && sudo systemctl daemon-reload
sudo systemctl enable --now zen-video.service
```
Reachability: spark is LAN-only/NAT'd. The `lux-dchain` cloudflared tunnel routes
`spark-video.hanzo.ai → localhost:8189` (TLS terminated by Cloudflare).
## Delivery
S3-first (SeaweedFS) when `ZEN_S3_*` is set, else the MP4 is served from this
service over the tunnel at `/v1/videos/files/{name}` (a scoped, unguessable URL).
NOTE: the cluster SeaweedFS admin identity is currently a placeholder
(`PLACEHOLDER`/`PLACEHOLDER`) — provision real creds in KMS to enable S3.
## Ops
`studio_clean_restart.sh` breaks a Studio EADDRINUSE/JIT crash-loop: it stops the
service, frees `:8188` of orphans, waits for memory to settle, and starts one
clean instance (studio's `comfy_kitchen` backend JIT-compiles CUDA kernels once
on first clean boot; a crash-loop never lets that finish → runaway `cicc`
compilers exhaust unified memory).
BIN
View File
Binary file not shown.
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Hard-clean restart of studio.service: break orphan/EADDRINUSE crash loop.
set -u
echo "[1] stop service"
sudo systemctl stop studio.service 2>&1
sleep 2
echo "[2] kill all studio python + free 8188 (repeat until clear)"
for i in 1 2 3 4 5; do
sudo fuser -k 8188/tcp 2>/dev/null
pkill -9 -f "studio/.venv/bin/python main.py" 2>/dev/null
sleep 2
holders=$(sudo ss -tlnp 2>/dev/null | grep -c ':8188 ')
procs=$(pgrep -c -f "studio/.venv/bin/python main.py" 2>/dev/null || echo 0)
echo " pass=$i holders=$holders procs=$procs"
if [ "$holders" = "0" ] && [ "$procs" = "0" ]; then break; fi
done
echo "[3] confirm 8188 truly dead"
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://127.0.0.1:8188/ 2>/dev/null)
echo " curl 8188 -> $code (000 = free)"
echo "[4] mem"
free -g | awk '/Mem:/{print " free="$4"GB used="$3"GB"}'
echo "[5] reset-failed + start"
sudo systemctl reset-failed studio.service
sudo systemctl start studio.service
echo " start rc=$? state=$(systemctl is-active studio.service)"
+29
View File
@@ -0,0 +1,29 @@
[Unit]
Description=Zen Video - headless text-to-video queue (GB10) fronting Hanzo Studio
Documentation=https://github.com/hanzoai/studio
After=network-online.target studio.service
Wants=network-online.target
Requires=studio.service
StartLimitIntervalSec=0
[Service]
Type=simple
User=z
Group=z
WorkingDirectory=/home/z/work/hanzo/studio/zen-video
EnvironmentFile=/etc/zen-video.env
Environment=ZEN_COMFY_URL=http://127.0.0.1:8188
Environment=ZEN_VIDEO_PORT=8189
Environment=ZEN_OUTPUT_DIR=/home/z/work/hanzo/studio/output
Environment=ZEN_DB_PATH=/home/z/work/hanzo/studio/zen-video/jobs.db
Environment=ZEN_PUBLIC_BASE=https://spark-video.hanzo.ai
Environment=ZEN_ALLOW_ANON=0
ExecStart=/home/z/work/hanzo/studio/.venv/bin/python zen_video_service.py
Restart=on-failure
RestartSec=5
KillMode=mixed
TimeoutStopSec=15
Nice=5
[Install]
WantedBy=multi-user.target
+551
View File
@@ -0,0 +1,551 @@
#!/usr/bin/env python3
"""zen-video: headless text-to-video queue service (Zen video family) on the GB10.
Public model id: zen3-video. Backend: Hanzo Studio (ComfyUI) on :8188 running the
Zen video diffusion model. The upstream/private model filenames live only inside
zen_wan_workflow.py and are NEVER returned to callers.
One GPU => one job at a time. A single asyncio worker drains an ordered queue and
submits to Studio, which itself serializes on the GPU. Jobs persist in SQLite so a
service restart never loses in-flight/finished job state.
Delivery: S3 (SeaweedFS) when ZEN_S3_* is configured, else the finished mp4 is
served directly from this service over the Cloudflare tunnel at
/v1/videos/files/{name} (a scoped, unguessable URL).
Billing: identity is injected by the gateway as X-User-Id / X-Org-Id (from the
IAM JWT). Every completed job writes a usage row and, if ZEN_METER_URL is set,
posts a metering event. Requests without an org identity are refused unless
ZEN_ALLOW_ANON=1 (dev only) video is expensive, never free by accident.
"""
from __future__ import annotations
import asyncio
import json
import os
import sqlite3
import time
import uuid
from pathlib import Path
import aiohttp
from aiohttp import web
from zen_wan_workflow import build_prompt
# ---------------------------------------------------------------------------
# Config (env, matches studio.service conventions)
# ---------------------------------------------------------------------------
COMFY_URL = os.environ.get("ZEN_COMFY_URL", "http://127.0.0.1:8188")
PORT = int(os.environ.get("ZEN_VIDEO_PORT", "8189"))
OUTPUT_DIR = Path(os.environ.get("ZEN_OUTPUT_DIR", "/home/z/work/hanzo/studio/output"))
DB_PATH = os.environ.get("ZEN_DB_PATH", "/home/z/zen-video/jobs.db")
PUBLIC_BASE = os.environ.get("ZEN_PUBLIC_BASE", "").rstrip("/") # e.g. https://spark-video.hanzo.ai
ALLOW_ANON = os.environ.get("ZEN_ALLOW_ANON", "0") == "1"
METER_URL = os.environ.get("ZEN_METER_URL", "").rstrip("/")
# Bearer key that the cloud LLM gateway (hanzoai/ai) presents on the do-ai-compatible
# async video API (/v1/videos*). Provisioned in KMS, set as the "spark-video" provider
# apiKey in the ai model registry. Empty in dev => async API is open (dev only).
PROVIDER_KEY = os.environ.get("ZEN_PROVIDER_KEY", "")
POLL_SECS = float(os.environ.get("ZEN_POLL_SECS", "2"))
JOB_TIMEOUT = float(os.environ.get("ZEN_JOB_TIMEOUT", "3600"))
# S3 (optional durable delivery)
S3_ENDPOINT = os.environ.get("ZEN_S3_ENDPOINT", "")
S3_EXTERNAL = os.environ.get("ZEN_S3_EXTERNAL", "").rstrip("/")
S3_BUCKET = os.environ.get("ZEN_S3_BUCKET", "videos")
S3_KEY = os.environ.get("ZEN_S3_ACCESS_KEY", "")
S3_SECRET = os.environ.get("ZEN_S3_SECRET_KEY", "")
S3_REGION = os.environ.get("ZEN_S3_REGION", "us-east-1")
# ---------------------------------------------------------------------------
# Public model registry — Zen names only. Maps to backend generation params.
# NEVER surface the private model filename to a caller.
# ---------------------------------------------------------------------------
MODELS = {
"zen3-video": {
"default_size": (1280, 704), # native 720p
"max_seconds": 5,
"fps": 24,
"steps": 20,
"cfg": 5.0,
"shift": 8.0,
# metered price (USD) — flat per generated second; video is expensive.
"usd_per_second": 0.20,
},
# fast/preview SKU: lower res + fewer frames, cheaper
"zen3-video-fast": {
"default_size": (704, 704),
"max_seconds": 3,
"fps": 24,
"steps": 16,
"cfg": 5.0,
"shift": 8.0,
"usd_per_second": 0.08,
},
"zen3-video-pro": {
"default_size": (1280, 704),
"max_seconds": 5,
"fps": 24,
"steps": 30,
"cfg": 5.0,
"shift": 8.0,
"usd_per_second": 0.40,
},
# do-ai upstream id: the cloud LLM gateway maps zen3-video -> this upstream
# model on the async video provider. Default kept moderate so a generation
# completes inside the gateway client's 300s ceiling.
"wan2-2-t2v-a14b": {
"default_size": (704, 704),
"max_seconds": 3,
"fps": 24,
"steps": 20,
"cfg": 5.0,
"shift": 8.0,
"usd_per_second": 0.20,
},
}
# do-ai async status = OpenAI Sora-style. Map internal job status to it.
_DOAI_STATUS = {
"queued": "queued", "running": "in_progress",
"completed": "completed", "failed": "failed",
}
# ---------------------------------------------------------------------------
# SQLite job store
# ---------------------------------------------------------------------------
def db() -> sqlite3.Connection:
c = sqlite3.connect(DB_PATH, timeout=30)
c.row_factory = sqlite3.Row
return c
def init_db() -> None:
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
with db() as c:
c.execute(
"""CREATE TABLE IF NOT EXISTS jobs(
id TEXT PRIMARY KEY,
status TEXT NOT NULL,
model TEXT, prompt TEXT, params TEXT,
org_id TEXT, user_id TEXT,
prompt_id TEXT,
url TEXT, filename TEXT,
seconds REAL, width INTEGER, height INTEGER,
error TEXT,
usd REAL DEFAULT 0,
created REAL, started REAL, finished REAL
)"""
)
c.execute(
"""CREATE TABLE IF NOT EXISTS usage(
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT, org_id TEXT, user_id TEXT, model TEXT,
seconds REAL, usd REAL, ts REAL, metered INTEGER DEFAULT 0
)"""
)
def job_row(job_id: str):
with db() as c:
r = c.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
return dict(r) if r else None
def job_update(job_id: str, **kw) -> None:
if not kw:
return
cols = ", ".join(f"{k}=?" for k in kw)
with db() as c:
c.execute(f"UPDATE jobs SET {cols} WHERE id=?", (*kw.values(), job_id))
# ---------------------------------------------------------------------------
# Serialization view for API responses (Zen surface — never leak backend)
# ---------------------------------------------------------------------------
def public_job(r: dict) -> dict:
out = {
"id": r["id"],
"object": "video.generation",
"model": r["model"],
"status": r["status"],
"created": r.get("created"),
}
if r.get("seconds"):
out["seconds"] = r["seconds"]
if r.get("width") and r.get("height"):
out["size"] = f"{r['width']}x{r['height']}"
if r["status"] == "completed" and r.get("url"):
out["url"] = r["url"]
out["data"] = [{"url": r["url"]}]
if r["status"] == "failed" and r.get("error"):
out["error"] = {"message": r["error"]}
return out
# ---------------------------------------------------------------------------
# ComfyUI interaction (async)
# ---------------------------------------------------------------------------
async def comfy_submit(session: aiohttp.ClientSession, graph: dict) -> str:
async with session.post(f"{COMFY_URL}/prompt",
json={"prompt": graph, "client_id": "zen-video"},
timeout=aiohttp.ClientTimeout(total=120)) as r:
r.raise_for_status()
return (await r.json())["prompt_id"]
async def comfy_wait(session: aiohttp.ClientSession, prompt_id: str) -> dict:
deadline = time.time() + JOB_TIMEOUT
while time.time() < deadline:
try:
async with session.get(f"{COMFY_URL}/history/{prompt_id}",
timeout=aiohttp.ClientTimeout(total=30)) as r:
hist = await r.json()
except (aiohttp.ClientError, asyncio.TimeoutError):
await asyncio.sleep(POLL_SECS)
continue
entry = hist.get(prompt_id)
if entry:
status = entry.get("status", {})
if status.get("status_str") == "success" or status.get("completed"):
return entry
if status.get("status_str") == "error":
msgs = []
for m in status.get("messages", []):
if m and m[0] == "execution_error":
msgs.append(json.dumps(m[1])[:500])
raise RuntimeError("studio error: " + ("; ".join(msgs) or "unknown"))
await asyncio.sleep(POLL_SECS)
raise TimeoutError(f"job exceeded {JOB_TIMEOUT}s")
def find_output_mp4(entry: dict) -> tuple[str, Path]:
"""Return (filename, absolute path) of the SaveVideo mp4 from a history entry."""
for node_out in entry.get("outputs", {}).values():
for key in ("videos", "gifs", "images"):
for item in node_out.get(key, []) or []:
fn = item.get("filename", "")
if fn.endswith(".mp4"):
sub = item.get("subfolder", "")
p = OUTPUT_DIR / sub / fn
return fn, p
raise RuntimeError("no mp4 in studio output")
# ---------------------------------------------------------------------------
# Delivery
# ---------------------------------------------------------------------------
def upload_s3(path: Path, key: str) -> str | None:
if not (S3_ENDPOINT and S3_KEY and S3_SECRET):
return None
import boto3
from botocore.config import Config
s3 = boto3.client(
"s3", endpoint_url=S3_ENDPOINT, aws_access_key_id=S3_KEY,
aws_secret_access_key=S3_SECRET, region_name=S3_REGION,
config=Config(s3={"addressing_style": "path"}),
)
s3.upload_file(str(path), S3_BUCKET, key,
ExtraArgs={"ContentType": "video/mp4", "ACL": "public-read"})
base = S3_EXTERNAL or S3_ENDPOINT
return f"{base}/{S3_BUCKET}/{key}"
# ---------------------------------------------------------------------------
# Worker
# ---------------------------------------------------------------------------
async def worker(app: web.Application) -> None:
q: asyncio.Queue = app["queue"]
async with aiohttp.ClientSession() as session:
while True:
job_id = await q.get()
try:
await run_job(session, job_id)
except Exception as e: # never let the worker die
job_update(job_id, status="failed", error=str(e)[:1000],
finished=time.time())
finally:
q.task_done()
async def run_job(session: aiohttp.ClientSession, job_id: str) -> None:
r = job_row(job_id)
if not r or r["status"] in ("completed", "failed"):
return
params = json.loads(r["params"])
job_update(job_id, status="running", started=time.time())
graph, _seed = build_prompt(**params["wan"])
prompt_id = await comfy_submit(session, graph)
job_update(job_id, prompt_id=prompt_id)
entry = await comfy_wait(session, prompt_id)
fn, path = find_output_mp4(entry)
# deliver
key = f"{time.strftime('%Y/%m/%d')}/{job_id}.mp4"
url = None
try:
url = await asyncio.get_event_loop().run_in_executor(None, upload_s3, path, key)
except Exception:
url = None # fall back to tunnel-serve
if not url:
url = f"{PUBLIC_BASE}/v1/videos/files/{fn}" if PUBLIC_BASE else f"/v1/videos/files/{fn}"
# billing
seconds = params["seconds"]
usd = params["usd"]
_record_usage(job_id, r["org_id"], r["user_id"], r["model"], seconds, usd)
job_update(job_id, status="completed", url=url, filename=fn, usd=usd,
finished=time.time())
def _record_usage(job_id, org, user, model, seconds, usd) -> None:
with db() as c:
c.execute(
"INSERT INTO usage(job_id,org_id,user_id,model,seconds,usd,ts,metered) "
"VALUES(?,?,?,?,?,?,?,0)",
(job_id, org, user, model, seconds, usd, time.time()),
)
if METER_URL and org:
# best-effort synchronous meter post (small); failures leave metered=0 for retry
try:
import urllib.request
body = json.dumps({
"org_id": org, "user_id": user, "model": model,
"quantity": seconds, "unit": "second", "usd": usd,
"resource": "video.generation", "job_id": job_id,
}).encode()
req = urllib.request.Request(METER_URL, data=body,
headers={"Content-Type": "application/json"})
urllib.request.urlopen(req, timeout=10).read()
with db() as c:
c.execute("UPDATE usage SET metered=1 WHERE job_id=?", (job_id,))
except Exception:
pass
# ---------------------------------------------------------------------------
# HTTP handlers
# ---------------------------------------------------------------------------
def _identity(request: web.Request) -> tuple[str, str]:
return (request.headers.get("X-Org-Id", ""), request.headers.get("X-User-Id", ""))
def _bearer_ok(request: web.Request) -> bool:
if not PROVIDER_KEY:
return True # dev: open async API
auth = request.headers.get("Authorization", "")
return auth.startswith("Bearer ") and auth[7:].strip() == PROVIDER_KEY
class BadRequest(Exception):
pass
async def _enqueue(app, *, model, prompt, size, seconds, seed, negative,
org, user, idem_id) -> dict:
"""Validate + persist + enqueue a job. Returns the job row. Raises BadRequest."""
spec = MODELS.get(model)
if not spec:
raise BadRequest(f"unknown model '{model}'")
prompt = (prompt or "").strip()
if not prompt:
raise BadRequest("prompt required")
if size:
try:
w, h = (int(x) for x in str(size).lower().split("x"))
except Exception:
raise BadRequest("size must be WxH")
else:
w, h = spec["default_size"]
fps = spec["fps"]
seconds = float(seconds) if seconds else spec["max_seconds"]
seconds = max(1.0, min(seconds, spec["max_seconds"]))
length = int(round(seconds * fps))
length = ((length - 1) // 4) * 4 + 1
seconds = round(length / fps, 3)
usd = round(seconds * spec["usd_per_second"], 4)
job_id = idem_id or str(uuid.uuid4())
existing = job_row(job_id)
if existing:
return existing
wan = {"positive": prompt, "width": w, "height": h, "length": length,
"steps": spec["steps"], "cfg": spec["cfg"], "shift": spec["shift"],
"fps": fps, "filename_prefix": f"video/zen-{job_id[:8]}"}
if negative:
wan["negative"] = negative
if seed is not None:
wan["seed"] = int(seed)
params = {"wan": wan, "seconds": seconds, "usd": usd}
now = time.time()
with db() as c:
c.execute(
"INSERT INTO jobs(id,status,model,prompt,params,org_id,user_id,"
"seconds,width,height,usd,created) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
(job_id, "queued", model, prompt, json.dumps(params), org, user,
seconds, w, h, usd, now))
await app["queue"].put(job_id)
return job_row(job_id)
async def create_generation(request: web.Request) -> web.Response:
org, user = _identity(request)
if not org and not ALLOW_ANON:
return web.json_response(
{"error": {"message": "org identity required (gateway must inject X-Org-Id)"}},
status=401)
try:
body = await request.json()
except Exception:
return web.json_response({"error": {"message": "invalid JSON"}}, status=400)
idem = body.get("id") or request.headers.get("Idempotency-Key")
try:
r = await _enqueue(
request.app, model=body.get("model", "zen3-video"),
prompt=body.get("prompt"), size=body.get("size"),
seconds=body.get("seconds"), seed=body.get("seed"),
negative=body.get("negative_prompt"), org=org, user=user, idem_id=idem)
except BadRequest as e:
return web.json_response(
{"error": {"message": str(e), "available": list(MODELS)}}, status=400)
resp = public_job(r)
resp["queue_position"] = request.app["queue"].qsize()
return web.json_response(resp, status=202)
# ---------------------------------------------------------------------------
# do-ai-compatible async video API (OpenAI Sora-style) — driven by the cloud
# LLM gateway's GenerateVideoDOAI client. Auth = Bearer provider key. Identity
# and billing are owned upstream by the LLM gateway; this side just generates.
# ---------------------------------------------------------------------------
def _doai_record(r: dict) -> dict:
out = {"id": f"video_{r['id']}", "object": "video", "model": r["model"],
"status": _DOAI_STATUS.get(r["status"], "queued")}
if r["status"] == "failed":
out["error"] = {"message": r.get("error") or "generation failed",
"type": "generation_error"}
else:
out["error"] = None
return out
def _strip_vid(vid: str) -> str:
return vid[6:] if vid.startswith("video_") else vid
async def doai_create(request: web.Request) -> web.Response:
if not _bearer_ok(request):
return web.json_response({"error": {"message": "unauthorized"}}, status=401)
try:
body = await request.json()
except Exception:
return web.json_response({"error": {"message": "invalid JSON"}}, status=400)
try:
r = await _enqueue(
request.app, model=body.get("model", "wan2-2-t2v-a14b"),
prompt=body.get("prompt"), size=body.get("size"),
seconds=body.get("seconds"), seed=body.get("seed"),
negative=body.get("negative_prompt"),
org=request.headers.get("X-Org-Id", ""),
user=request.headers.get("X-User-Id", ""), idem_id=None)
except BadRequest as e:
return web.json_response({"error": {"message": str(e)}}, status=400)
return web.json_response(_doai_record(r), status=202)
async def doai_status(request: web.Request) -> web.Response:
if not _bearer_ok(request):
return web.json_response({"error": {"message": "unauthorized"}}, status=401)
r = job_row(_strip_vid(request.match_info["id"]))
if not r:
return web.json_response({"error": {"message": "not found"}}, status=404)
return web.json_response(_doai_record(r))
async def doai_content(request: web.Request) -> web.StreamResponse:
if not _bearer_ok(request):
raise web.HTTPUnauthorized()
r = job_row(_strip_vid(request.match_info["id"]))
if not r:
raise web.HTTPNotFound()
if r["status"] != "completed" or not r.get("filename"):
# do-ai returns a tiny JSON placeholder while in progress; the client
# only downloads after polling to completion.
return web.json_response({"status": _DOAI_STATUS.get(r["status"], "queued")},
status=409)
for p in OUTPUT_DIR.rglob(r["filename"]):
if p.is_file():
return web.FileResponse(p, headers={"Content-Type": "video/mp4"})
raise web.HTTPNotFound()
async def get_generation(request: web.Request) -> web.Response:
r = job_row(request.match_info["id"])
if not r:
return web.json_response({"error": {"message": "not found"}}, status=404)
return web.json_response(public_job(r))
async def serve_file(request: web.Request) -> web.StreamResponse:
name = request.match_info["name"]
if "/" in name or ".." in name:
raise web.HTTPBadRequest()
# search output tree for the file
for p in OUTPUT_DIR.rglob(name):
if p.is_file():
return web.FileResponse(p, headers={"Content-Type": "video/mp4"})
raise web.HTTPNotFound()
async def healthz(request: web.Request) -> web.Response:
comfy = "down"
try:
async with aiohttp.ClientSession() as s:
async with s.get(f"{COMFY_URL}/system_stats",
timeout=aiohttp.ClientTimeout(total=5)) as r:
if r.status == 200:
comfy = "up"
except Exception:
pass
return web.json_response({
"status": "ok", "comfy": comfy,
"queue_depth": request.app["queue"].qsize(),
"models": list(MODELS),
})
async def list_models(request: web.Request) -> web.Response:
data = [{"id": m, "object": "model", "owned_by": "hanzo",
"max_seconds": s["max_seconds"], "default_size": f"{s['default_size'][0]}x{s['default_size'][1]}"}
for m, s in MODELS.items()]
return web.json_response({"object": "list", "data": data})
def make_app() -> web.Application:
init_db()
app = web.Application(client_max_size=1 * 1024 * 1024)
app["queue"] = asyncio.Queue()
app.router.add_post("/v1/videos/generations", create_generation)
app.router.add_get("/v1/videos/generations/{id}", get_generation)
app.router.add_get("/v1/videos/files/{name}", serve_file)
app.router.add_get("/v1/videos/models", list_models)
# do-ai-compatible async video API (driven by the cloud LLM gateway)
app.router.add_post("/v1/videos", doai_create)
app.router.add_get("/v1/videos/{id}", doai_status)
app.router.add_get("/v1/videos/{id}/content", doai_content)
app.router.add_get("/healthz", healthz)
async def _on_start(app):
# requeue jobs interrupted by a restart
with db() as c:
for row in c.execute("SELECT id FROM jobs WHERE status IN ('queued','running')"):
app["queue"].put_nowait(row["id"])
app["worker"] = asyncio.create_task(worker(app))
app.on_startup.append(_on_start)
return app
if __name__ == "__main__":
web.run_app(make_app(), host="0.0.0.0", port=PORT)
+92
View File
@@ -0,0 +1,92 @@
"""Zen video: Wan2.2 TI2V-5B text-to-video ComfyUI API-format workflow builder.
Public model id: zen3-video. Private backend: wan2.2_ti2v_5B_fp16.
Never expose the upstream name in any public surface.
"""
from __future__ import annotations
import random
# Wan2.2 default negative prompt (matches the official ComfyUI TI2V template).
DEFAULT_NEGATIVE = (
"色调艳丽,过曝,静态,细节模糊不清,"
"字幕,风格,作品,画作,画面,静止,"
"整体发灰,最差质量,低质量,JPEG压缩残留,"
"丑陋的,残缺的,多余的手指,画得不好的手部,"
"画得不好的脸部,畸形的,毁容的,形态畸形的肢体,"
"手指融合,静止不动的画面,杂乱的背景,三条腿,"
"背景人很多,倒着走"
)
# Model filenames as present on spark (models/ dir).
UNET = "wan2.2_ti2v_5B_fp16.safetensors"
CLIP = "umt5_xxl_fp8_e4m3fn_scaled.safetensors"
VAE = "wan2.2_vae.safetensors"
def build_prompt(
*,
positive: str,
negative: str | None = None,
width: int = 1280,
height: int = 704,
length: int = 121,
steps: int = 20,
cfg: float = 5.0,
shift: float = 8.0,
sampler: str = "uni_pc",
scheduler: str = "simple",
fps: int = 24,
seed: int | None = None,
filename_prefix: str = "video/zen",
) -> dict:
"""Return a ComfyUI /prompt API-format graph for Wan2.2 TI2V-5B text-to-video.
length must be 4n+1 (temporal VAE stride 4). width/height multiples of 16.
"""
if negative is None:
negative = DEFAULT_NEGATIVE
if seed is None:
seed = random.randint(0, 2**53 - 1)
# snap length to 4n+1
if (length - 1) % 4 != 0:
length = ((length - 1) // 4) * 4 + 1
width -= width % 16
height -= height % 16
return {
"37": {"class_type": "UNETLoader",
"inputs": {"unet_name": UNET, "weight_dtype": "default"}},
"38": {"class_type": "CLIPLoader",
"inputs": {"clip_name": CLIP, "type": "wan", "device": "default"}},
"39": {"class_type": "VAELoader",
"inputs": {"vae_name": VAE}},
"6": {"class_type": "CLIPTextEncode",
"inputs": {"text": positive, "clip": ["38", 0]}},
"7": {"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": ["38", 0]}},
"48": {"class_type": "ModelSamplingSD3",
"inputs": {"model": ["37", 0], "shift": shift}},
"55": {"class_type": "Wan22ImageToVideoLatent",
"inputs": {"vae": ["39", 0], "width": width, "height": height,
"length": length, "batch_size": 1}},
"3": {"class_type": "KSampler",
"inputs": {"model": ["48", 0], "seed": seed, "steps": steps,
"cfg": cfg, "sampler_name": sampler, "scheduler": scheduler,
"positive": ["6", 0], "negative": ["7", 0],
"latent_image": ["55", 0], "denoise": 1.0}},
"8": {"class_type": "VAEDecode",
"inputs": {"samples": ["3", 0], "vae": ["39", 0]}},
"57": {"class_type": "CreateVideo",
"inputs": {"images": ["8", 0], "fps": fps}},
"58": {"class_type": "SaveVideo",
"inputs": {"video": ["57", 0], "filename_prefix": filename_prefix,
"format": "mp4", "codec": "h264"}},
}, seed
if __name__ == "__main__":
import json
import sys
p, s = build_prompt(positive=sys.argv[1] if len(sys.argv) > 1 else "a cat",
width=640, height=640, length=41, steps=20)
sys.stdout.write(json.dumps({"prompt": p}, ensure_ascii=False) + "\n")