Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 12:36:47 -07:00
Hanzo AI a9223e59e8 studio/web: call api.hanzo.ai/v1/chat directly — no studio proxy layer
Chat hits the cloud clients/chat orchestrator directly (credentials:'include'
carries the .hanzo.ai cookie cross-origin; handler replays it into the billed
completion). Engine calls stay same-origin. Needs gateway CORS to allow the
studio.hanzo.ai origin with credentials — infra config, no backend code.
2026-07-16 10:57:33 -07:00
Hanzo AI 10eec303fd studio: web/ — our unique product frontend (chat app on the shared Hanzo stack)
Vite+React studio-chat: say what to create, the cloud /v1/chat orchestrator drives
studio's render tools, outputs land in the library. Increment 1 (own minimal UI
wired to /v1/chat create + library/queue/gpu). Served at /studio; the ComfyUI node
editor stays separate in studio-ui (was studio-frontend). Builds clean (npm-published
@hanzo/*).
2026-07-16 09:48:39 -07:00
Hanzo AI b07530398d studio: /v1/mcp render tools (fix/compose/ask) for the cloud chat tool registry
MCP-over-HTTP (JSON-RPC 2.0) exposing the studio render pipeline as tools the
cloud /v1/chat 'create' capability drives via the tool registry. Reuses the ONE
dispatch path (studio_home.dispatch_fix/dispatch_compose, extracted from the
/v1/library/{fix,compose} routes) so an LLM-issued fix behaves identically to a
UI one. Registered per-org via cloud POST /v1/tools/servers. v0.17.3.
2026-07-16 09:01:36 -07:00
Hanzo AI 0d307dceb2 studio: responsive — home was desktop-only (0 media queries). Add tablet(≤900) + phone(≤600) breakpoints: fluid grid, wrapping controls, full-width chat sheet, bigger tap targets, always-show card tools on touch (no hover-reveal). Works mobile/tablet/laptop/desktop. v0.17.2 2026-07-16 00:21:55 -07:00
Hanzo AI 1b713f904c studio: fix queue jobs VANISHING after 30s — render-queue completion check did a fuzzy base-prefix match that collided with the SOURCE image (a fix's output prefix derives from the source name), so every job looked 'done' after 30s and disappeared. Now checks for the EXACT output file (<outPrefix>_NNNNN_.png). This is why 'the queue is not working'. v0.17.1 2026-07-15 23:19:39 -07:00
Hanzo AI c969641d7d studio: EXIT Advanced mode — always-visible '← Studio' button (fixed top-left) + 'Exit to Studio Home' in the user menu. Advanced mode (the full-screen node editor) had NO way back to Studio Home — users got stuck. v0.17.0 2026-07-15 23:11:34 -07:00
Hanzo AI b7dff20861 studio: fix 'connect a GPU' when a GPU IS connected — UI checked /v1/engines (worker-federation) but 'hanzo gpu connect' registers with the FLEET (gpu-jobs), which is what dispatch actually uses. New /v1/gpu-status reports the SAME source as dispatch (_has_online_gpu → /v1/machines); UI now shows spark connected. v0.16.9 2026-07-15 22:58:04 -07:00
Hanzo AI e696fb7480 studio: SHOW BYO-GPU renders in the queue — fix/compose dispatch to gpu-jobs (worker), NOT the pod's local /queue, so a queued fix was invisible ('didn't bring it into queue'). Now dispatch_if_worker tracks each job per-org; /v1/render-queue serves in-flight ones (drops on output-landed/age-out); live-bar + Queue tab show them with thumbnail + ETA. v0.16.8 2026-07-15 22:20:50 -07:00
Hanzo AI b3f20495f8 studio: /studio served no-store — browsers cached the SPA HTML and served a STALE page after deploys, making UI fixes (delete) look broken to users on yesterday's cached page. v0.16.7 2026-07-15 22:12:29 -07:00
Hanzo AI 179b577add studio SECURITY: org-scope the inherited core endpoints (multi-tenant release blockers from red audit) — /history + /history/{id} filtered by caller org (was cross-tenant BOLA, live in Queue&History UI); /queue GET filtered; /queue clear/delete scoped (was: any tenant's 'Clear all' wiped ALL orgs); /interrupt scoped to caller's running job; org_id path-traversal sanitization in get_org_base_path. Single-tenant (auth off) unchanged. v0.16.6 2026-07-15 21:37:33 -07:00
Hanzo AI c2336e0c1e studio: YouTube-style up-next queue rows — reference thumbnail + title + brief description (the prompt) + ETA/#-in-line; serves staged fix/compose refs via ?input=1; v0.16.5 2026-07-15 20:51:01 -07:00
Hanzo AI 0538abf199 studio: FAST thumbnails (?thumb=1 → cached 480px JPEG, fixes slow full-res grid loading) + always-visible instant 🗑 trash on every card (optimistic delete, feels instant) + tray/compose use thumbs; v0.16.4 2026-07-15 20:49:03 -07:00
Hanzo AI 7eefdcd206 studio: persistent live-queue bar (always visible, non-advanced) — shows what's Generating now + queued count + ETA, polls /queue every 5s; auto-hiding header + full-bleed immersive stage CSS + Hanzo AI chat widget scaffold; v0.16.3 2026-07-15 20:42:01 -07:00
Hanzo AI b238b8c568 studio: bump BYO-GPU render deadline 600s→1200s (heavy Qwen edit on cold GB10 exceeds 10min); v0.16.2 2026-07-15 20:18:30 -07:00
Hanzo AI e97194613c studio: '+' on each photo → reference tray for the next generation; 1 ref=fix/edit, 2-5=compose; describe + Generate from the floating tray; v0.16.1 2026-07-15 20:12:56 -07:00
Hanzo AI b31a1ea3e2 studio: YouTube-style Queue & History — Now-running + Up-next (live /queue, remove/clear) + History with 0-5 star ratings + notes-for-AI (real /v1/library/rate + ratings.json curation store) + favorite→save-as-template guided flow; v0.16.0 2026-07-15 19:58:21 -07:00
Hanzo AI dd21fd9f85 studio: multi-asset Compose (select N → describe how to combine → /v1/library/compose, multi-ref Qwen graph) + Fix/Compose now report GPU-online status clearly (Qwen models live on BYO-GPU); v0.15.9 2026-07-15 19:50:41 -07:00
Hanzo AI 109cd04a54 studio: soft-delete (recoverable, Deleted filter) + multi-select bulk (approve/publish/fork/delete) + hover-reveal tools (cleaner grid) + systematized fashion templates (CAD→ghost, outfit-switcher, product, lifestyle, CAD→3D grouped by category); v0.15.8 2026-07-15 19:22:32 -07:00
Hanzo AI 552f47b844 studio: chat-first Claude-Design home ('What should we create?' + full Hanzo template set: product/ghost/CAD-3D/video/music/voice/social/docs/paper) + org-scoped /v1/library/file (fixes blank thumbnails); v0.15.7 2026-07-15 18:33:59 -07:00
Hanzo AI b0d1b06a6f studio home: Hanzo logo top-left + clean inline org switcher/avatar (drop broken floating identity pill); v0.15.6 2026-07-15 18:30:09 -07:00
Hanzo AI 023367bfec studio home: monochrome brand (drop yellow --brand) + surface org-switcher/user/wallet identity pill; v0.15.5 2026-07-15 17:42:05 -07:00
Hanzo AI b2121abc71 chore: version 0.15.4 — rebuild Studio Home (0.15.3 image never reached GHCR) 2026-07-14 17:51:53 -07:00
hanzo-dev 0a3146e00f docs: de-tenant the manifest + home docstrings
Claude-Session: https://claude.ai/code/session_01Gq8suw7uuodAMPDRpo6iAB
2026-07-14 17:06:03 -07:00
hanzo-dev 8e55ecac9a refactor: karma_manifest.py -> library_manifest.py — org-generic name for the org-generic tool (unified multi-tenant SaaS; no tenant names in code)
Claude-Session: https://claude.ai/code/session_01Gq8suw7uuodAMPDRpo6iAB
2026-07-14 17:05:44 -07:00
hanzo-dev b6317999dd fix: dedupe version key from rebase resolution
Claude-Session: https://claude.ai/code/session_01Gq8suw7uuodAMPDRpo6iAB
2026-07-14 16:52:46 -07:00
hanzo-dev 5e9f72bc16 feat(studio): Studio Home — content-forward default view; node editor becomes Advanced mode
/studio: org-scoped asset gallery over library.json (status chips draft/
approved/queued/published), per-asset workflow provenance from the embedded
PNG graph, Re-run (exact graph, fresh seed) and chat-fix (instruction ->
Qwen-edit pass on that output) both through the ONE /prompt path with the
caller's own credentials. Pipelines tab reuses the 0.15.1 deep-link opener
for Edit/Run; STUDIO_HOME_DEFAULT=1 makes / land on /studio (SPA at
/?advanced=1). 5 unit tests.

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

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

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

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

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

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

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

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

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

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

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

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

Config: one way, HANZO_ENGINE_URL (default http://127.0.0.1:1234), /v1/* only.
Ships example user/default/workflows/hanzo-native-pipeline.json (LLM prompt ->
image prompt -> image gen -> save), e2e-verified against a CPU Qwen3-0.6B engine.
2026-07-09 21:16:03 -07:00
64 changed files with 9192 additions and 221 deletions
+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 -4
View File
@@ -22,10 +22,13 @@ Modifications made in this fork
branding/. Backend Python packages are namespaced under studio_* and the
server reports itself as "Hanzo Studio".
- Hanzo Engine integration nodes (custom_nodes/hanzo_engine/): custom nodes
that call a local Hanzo Engine OpenAI-compatible server — HanzoChat
(chat/completions), HanzoImageGen (images/generations), and
HanzoVisionCaption (vision chat/completions). Hanzo Engine is the native-Rust,
- Hanzo Engine integration nodes (custom_nodes/hanzo_engine/): first-party
nodes that run AI workloads on a local Hanzo Engine OpenAI-compatible server
over a shared client — HanzoChat (chat/completions, streaming), HanzoImageGen
(images/generations), HanzoTTS (audio/speech), HanzoASR
(audio/transcriptions), HanzoEngineRequest (generic /v1 escape hatch),
HanzoVisionCaption (vision chat/completions), HanzoTextToVideo (videos),
HanzoImageTo3D (3d), and HanzoSaveText. Hanzo Engine is the native-Rust,
all-modality inference and training backend.
- Fashion/swimwear starter workflows (user/default/workflows/fashion/):
+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}"/>'
+64 -24
View File
@@ -1,40 +1,80 @@
# Hanzo Engine nodes
Custom nodes that call a local **Hanzo Engine** from a Hanzo Studio graph.
Hanzo Engine is the native-Rust, all-modality inference and training backend
(the training loop lives engine-side); these nodes talk to its
OpenAI-compatible HTTP API.
First-party Hanzo Studio nodes that run AI workloads on the native-Rust
**Hanzo Engine** instead of in-process Python. Each node is one call to the
engine's OpenAI-compatible HTTP API; the shared transport and Studio<->engine
type conversions live in `client.py`, so every node stays a few lines and a new
engine endpoint becomes a new small node with no framework change.
## Configuration
Set the engine base URL via environment variable (default shown):
One way to point at an engine: the base URL, default `http://127.0.0.1:1234`,
overridable by env. Paths are always `/v1/*` (never `/api/*`); a trailing `/v1`
on the base URL is tolerated.
```bash
export HANZO_ENGINE_URL=http://127.0.0.1:1234/v1
export HANZO_ENGINE_URL=http://127.0.0.1:1234
hanzo engine serve -m Qwen/Qwen3-4B --port 1234 # or any local path
```
Connection or protocol failures surface as a clear error on the failing node
the server does not crash.
Engine-down and protocol failures raise a clear error on the failing node; the
server keeps running.
## Nodes (category: `Hanzo/Engine`)
| Node | Route | In Out |
|------|-------|----------|
| **Hanzo Chat (Engine)** | `POST /v1/chat/completions` | model + prompt + system → `text` (STRING) |
| **Hanzo Image Gen (Engine)** | `POST /v1/images/generations` | prompt + size + steps → `IMAGE` |
| **Hanzo Vision Caption (Engine)** | `POST /v1/chat/completions` (vision) | `IMAGE` + prompt → `caption` (STRING) |
| **Hanzo Save Text** | — | `text` (STRING) → writes `.txt` to `output/` |
| Node | Endpoint | In -> Out |
|------|----------|-----------|
| **Hanzo Chat (Engine)** | `POST /v1/chat/completions` (streams) | model + prompt -> `text` |
| **Hanzo Image Gen (Engine)** | `POST /v1/images/generations` | prompt + width/height -> `IMAGE` |
| **Hanzo TTS (Engine)** | `POST /v1/audio/speech` | text -> `AUDIO` |
| **Hanzo Music (Engine)** | `POST /v1/audio/music` | prompt -> `AUDIO` |
| **Hanzo ASR (Engine)** | `POST /v1/audio/transcriptions` | `AUDIO` -> `text` |
| **Hanzo Engine Request** | any `/v1` path | method + path + JSON -> `response` |
| **Hanzo Vision Caption (Engine)** | `POST /v1/chat/completions` (vision) | `IMAGE` + prompt -> `caption` |
| **Hanzo Text to Video (Engine)** | async `/v1/videos` | prompt -> `IMAGE` frames |
| **Hanzo Image to 3D (Engine)** | async `/v1/3d` | `IMAGE` (+`texture`) -> mesh path |
| **Hanzo Dub / Lip-Sync (Engine)** | `POST /v1/animate` | `IMAGE` + `AUDIO` -> `IMAGE` frames + `AUDIO` |
| **Hanzo Save Text** | -- | `text` -> writes `output/*.txt` |
- **Hanzo Chat** — prompt engineering and caption/text chains inside a graph.
- **Hanzo Image Gen** — decodes the engine's `b64_json` (or `url`) response into a
Hanzo Studio `IMAGE` tensor. Requests `n` images at the chosen `size`.
- **Hanzo Vision Caption** — encodes the input image as an OpenAI `image_url`
data URL and asks a vision model (e.g. Qwen3-VL) to caption it. Use for
LoRA/dataset prep.
- **Hanzo Save Text** — writes captions to `output/` as `.txt` (image/caption
pairs) and previews them in the node. Completes the caption-dataset loop.
- **Chat** streams tokens over SSE, driving the node progress bar, and returns
the full text (also previewed on the node). `model` is a dropdown populated
from `/v1/models`; `default` always routes to the loaded model.
- **Image Gen** sends the engine's `width`/`height`/`n` schema and decodes the
`b64_json` (or `url`) response into a Studio `IMAGE`.
- **TTS/Music/ASR** speak the Studio `AUDIO` type (`{waveform, sample_rate}`)
and compose with the built-in Load/Save/Preview Audio nodes. TTS and Music
serve `wav`/`pcm` (the engine's synchronous audio formats); Music is stereo
44.1 kHz ACE-Step.
- **Engine Request** is the forward-compatible escape hatch: reach any endpoint
the day it ships, then promote it to a dedicated node here.
- **Image to 3D** exposes the `texture` flag: on (default) runs the full
SLAT + FlexiCubes stage for a textured GLB; off returns the coarse mesh.
- **Text to Video / Image to 3D** use the engine's async job protocol
(`submit -> poll -> fetch content`) and report progress. **Dub** is
synchronous: it posts the portrait `IMAGE` + driving `AUDIO` and returns the
animated frames plus the audio (mux them with a video-combine node).
## Requirements
Uses only libraries already present in Hanzo Studio: `requests`, `Pillow`,
`numpy`, `torch`. No extra install.
Only libraries already in Hanzo Studio: `requests`, `Pillow`, `numpy`, `torch`
(always), plus `av` (loaded lazily, for the audio/video decode paths). WAV
encode/decode uses the stdlib `wave` module. No extra install.
## Verification
`contract_check.py` drives every node through real HTTP against a stub engine
that enforces the exact request fields each Rust handler deserializes and the
async job protocol (submit -> poll -> content):
```bash
python custom_nodes/hanzo_engine/contract_check.py
```
## Examples
- `user/default/workflows/hanzo-native-pipeline.json` -- Chat writes an image
prompt, Image Gen renders it, image + prompt are saved.
- `user/default/workflows/hanzo-full-generative-pipeline.json` -- the flagship
multimodal pipeline: Chat expands a prompt, which fans out to Image Gen ->
(textured Image-to-3D **and** WAN Text-to-Video), plus TTS narration and
ACE-Step Music. One graph, every modality, all on the native engine.
+318
View File
@@ -0,0 +1,318 @@
"""Single client layer for the Hanzo Engine — the native-Rust, all-modality
inference backend served over its OpenAI-compatible HTTP API.
Transport, config, and Studio<->engine type conversions live here so the nodes
in `nodes.py` stay thin: one node == one endpoint. New engine endpoints
(music, dub, ...) become a new small node over this same layer, no framework
change.
Base URL resolves from the HANZO_ENGINE_URL env var (default
http://127.0.0.1:1234); a trailing `/v1` is tolerated and stripped so callers
always build `/v1/...` paths themselves. Engine-down and protocol errors raise
EngineError, which Studio reports on the failing node instead of crashing the
server.
"""
import base64
import io
import json
import os
import time
import numpy as np
import requests
import torch
from PIL import Image
DEFAULT_BASE_URL = "http://127.0.0.1:1234"
POLL_INTERVAL_S = 2.0
class EngineError(RuntimeError):
"""Raised on any engine transport or protocol failure."""
def base_url() -> str:
raw = os.environ.get("HANZO_ENGINE_URL", DEFAULT_BASE_URL).strip().rstrip("/")
if raw.endswith("/v1"):
raw = raw[:-3]
return raw or DEFAULT_BASE_URL
def _url(path: str) -> str:
if not path.startswith("/"):
path = "/" + path
return f"{base_url()}{path}"
def _down_msg(url: str, exc: Exception) -> str:
return (
f"Hanzo Engine request to {url} failed: {exc}. Is the engine running? "
f"Start it with `hanzo engine serve` and set HANZO_ENGINE_URL "
f"(default {DEFAULT_BASE_URL})."
)
def _json_or_raise(resp: requests.Response, url: str) -> dict:
if not resp.ok:
raise EngineError(f"Hanzo Engine {url} returned HTTP {resp.status_code}: {resp.text[:500]}")
try:
return resp.json()
except ValueError as exc:
raise EngineError(f"Hanzo Engine {url} returned a non-JSON response: {resp.text[:200]}") from exc
# --- HTTP -------------------------------------------------------------------
def post_json(path: str, payload: dict, timeout: int = 120) -> dict:
url = _url(path)
try:
resp = requests.post(url, json=payload, timeout=timeout)
except requests.exceptions.RequestException as exc:
raise EngineError(_down_msg(url, exc)) from exc
return _json_or_raise(resp, url)
def get_json(path: str, timeout: int = 30) -> dict:
url = _url(path)
try:
resp = requests.get(url, timeout=timeout)
except requests.exceptions.RequestException as exc:
raise EngineError(_down_msg(url, exc)) from exc
return _json_or_raise(resp, url)
def post_bytes(path: str, payload: dict, timeout: int = 300) -> bytes:
"""POST JSON, return the raw response body (endpoints that return binary,
e.g. /v1/audio/speech)."""
url = _url(path)
try:
resp = requests.post(url, json=payload, timeout=timeout)
except requests.exceptions.RequestException as exc:
raise EngineError(_down_msg(url, exc)) from exc
if not resp.ok:
raise EngineError(f"Hanzo Engine {url} returned HTTP {resp.status_code}: {resp.text[:300]}")
return resp.content
def post_multipart(path: str, files: dict, data: dict, timeout: int = 300) -> dict:
url = _url(path)
try:
resp = requests.post(url, files=files, data=data, timeout=timeout)
except requests.exceptions.RequestException as exc:
raise EngineError(_down_msg(url, exc)) from exc
return _json_or_raise(resp, url)
def get_bytes(path_or_url: str, timeout: int = 120) -> bytes:
url = path_or_url if path_or_url.startswith("http") else _url(path_or_url)
try:
resp = requests.get(url, timeout=timeout)
except requests.exceptions.RequestException as exc:
raise EngineError(f"Hanzo Engine fetch {url} failed: {exc}.") from exc
if not resp.ok:
raise EngineError(f"Hanzo Engine fetch {url} returned HTTP {resp.status_code}.")
return resp.content
def list_models() -> list:
"""GET /v1/models -> [id, ...]. Returns [] if the engine is unreachable so
node definitions never fail to load."""
try:
data = get_json("/v1/models", timeout=5)
except EngineError:
return []
items = data.get("data") or []
return [it["id"] for it in items if isinstance(it, dict) and it.get("id")]
def stream_chat(payload: dict, timeout: int = 300):
"""Yield content deltas from a streaming /v1/chat/completions (OpenAI SSE)."""
url = _url("/v1/chat/completions")
body = {**payload, "stream": True}
try:
resp = requests.post(url, json=body, stream=True, timeout=timeout)
except requests.exceptions.RequestException as exc:
raise EngineError(_down_msg(url, exc)) from exc
if not resp.ok:
raise EngineError(f"Hanzo Engine {url} returned HTTP {resp.status_code}: {resp.text[:500]}")
with resp:
for line in resp.iter_lines(decode_unicode=True):
if not line or not line.startswith("data:"):
continue
chunk = line[len("data:"):].strip()
if chunk == "[DONE]":
break
try:
obj = json.loads(chunk)
except ValueError:
continue
delta = ((obj.get("choices") or [{}])[0].get("delta") or {}).get("content")
if delta:
yield delta
def extract_message_text(data: dict) -> str:
try:
content = (data["choices"][0].get("message") or {}).get("content")
except (KeyError, IndexError, TypeError) as exc:
raise EngineError(f"Unexpected Hanzo Engine response shape: {json.dumps(data)[:300]}") from exc
if isinstance(content, list): # OpenAI structured content parts
return "".join(p.get("text", "") for p in content
if isinstance(p, dict) and p.get("type") == "text").strip()
return (content or "").strip()
def run_job(resource: str, payload: dict, job_timeout: int, progress=None) -> bytes:
"""Submit an async generation job, poll to completion, return its content.
POST /v1/{resource} -> {"id": ...}
GET /v1/{resource}/{id} -> {"status": ..., "progress"?: ...}
GET /v1/{resource}/{id}/content -> raw bytes
"""
submit = post_json(f"/v1/{resource}", payload, timeout=60)
job_id = submit.get("id")
if not job_id:
raise EngineError(f"Hanzo Engine /v1/{resource} did not return a job id: {submit}")
deadline = time.monotonic() + job_timeout
while True:
body = get_json(f"/v1/{resource}/{job_id}", timeout=60)
status = str(body.get("status", "")).lower()
if status == "completed":
break
if status in {"failed", "cancelled", "error"}:
msg = body.get("error") or body.get("message") or status
raise EngineError(f"Hanzo Engine {resource} job {job_id} {status}: {msg}")
if progress is not None:
progress(body.get("progress"))
if time.monotonic() > deadline:
raise EngineError(
f"Hanzo Engine {resource} job {job_id} did not complete within "
f"{job_timeout}s (last status: {status or 'unknown'})."
)
time.sleep(POLL_INTERVAL_S)
return get_bytes(f"/v1/{resource}/{job_id}/content", timeout=job_timeout)
def progress_bar(total: int):
try:
from comfy.utils import ProgressBar
return ProgressBar(total)
except Exception:
return None
# --- Image conversions ------------------------------------------------------
def tensor_to_pil(image: torch.Tensor) -> Image.Image:
if image.dim() == 4: # [B,H,W,C] -> first frame
image = image[0]
arr = (image.cpu().numpy() * 255.0).clip(0, 255).astype(np.uint8)
return Image.fromarray(arr)
def pil_to_tensor(img: Image.Image) -> torch.Tensor:
arr = np.array(img.convert("RGB")).astype(np.float32) / 255.0
return torch.from_numpy(arr)[None,] # [1,H,W,C]
def pil_to_data_url(img: Image.Image) -> str:
buf = io.BytesIO()
img.save(buf, format="PNG")
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii")
def tensor_to_data_url(image: torch.Tensor) -> str:
return pil_to_data_url(tensor_to_pil(image))
def _decode_image_item(item: dict) -> Image.Image:
b64 = item.get("b64_json")
if b64:
raw = base64.b64decode(b64)
else:
url = item.get("url")
if not url:
raise EngineError("Hanzo Engine image item has neither 'b64_json' nor 'url'.")
raw = base64.b64decode(url.split(",", 1)[1]) if url.startswith("data:") else get_bytes(url)
return Image.open(io.BytesIO(raw)).convert("RGB")
def images_to_batch(items: list) -> torch.Tensor:
if not items:
raise EngineError("Hanzo Engine returned no image data.")
tensors = [pil_to_tensor(_decode_image_item(it)) for it in items]
if len({t.shape for t in tensors}) == 1:
return torch.cat(tensors, dim=0)
return tensors[0]
def mp4_bytes_to_image_batch(data: bytes) -> torch.Tensor:
import av
frames = []
with av.open(io.BytesIO(data)) as container:
for frame in container.decode(video=0):
frames.append(frame.to_ndarray(format="rgb24"))
if not frames:
raise EngineError("Hanzo Engine video job returned a video with no frames.")
arr = np.stack(frames).astype(np.float32) / 255.0
return torch.from_numpy(arr)
# --- Audio conversions (Studio AUDIO == {waveform:[B,C,T], sample_rate}) -----
def _f32_pcm(wav: torch.Tensor) -> torch.Tensor:
if wav.dtype.is_floating_point:
return wav
if wav.dtype == torch.int16:
return wav.float() / (2 ** 15)
if wav.dtype == torch.int32:
return wav.float() / (2 ** 31)
return wav.float()
def audio_bytes_to_studio(data: bytes) -> dict:
"""Decode engine speech bytes (any container) to a Studio AUDIO dict."""
import av
chunks, sr, n_ch = [], 24000, 1
with av.open(io.BytesIO(data)) as af:
if not af.streams.audio:
raise EngineError("Hanzo Engine speech response has no audio stream.")
stream = af.streams.audio[0]
sr = stream.codec_context.sample_rate or sr
n_ch = stream.channels or 1
for frame in af.decode(streams=stream.index):
buf = torch.from_numpy(frame.to_ndarray())
if buf.shape[0] != n_ch:
buf = buf.view(-1, n_ch).t()
chunks.append(buf)
wav = torch.cat(chunks, dim=1) if chunks else torch.zeros(n_ch, 0)
return {"waveform": _f32_pcm(wav).unsqueeze(0), "sample_rate": int(sr)}
def studio_audio_to_wav_bytes(audio: dict) -> bytes:
"""Encode a Studio AUDIO dict ({waveform:[B,C,T], sample_rate}) to 16-bit PCM
WAV bytes for multipart upload (ASR) or a data URL (dub). Stdlib `wave` only."""
import wave
wav = audio["waveform"]
if wav.dim() == 3: # [B,C,T] -> [C,T]
wav = wav[0]
arr = wav.detach().cpu().float().clamp_(-1.0, 1.0).numpy() # [C,T]
pcm16 = (arr * 32767.0).astype(np.int16)
interleaved = pcm16.T.reshape(-1).tobytes() # [T,C] interleaved
buf = io.BytesIO()
with wave.open(buf, "wb") as w:
w.setnchannels(arr.shape[0])
w.setsampwidth(2)
w.setframerate(int(audio["sample_rate"]))
w.writeframes(interleaved)
return buf.getvalue()
def studio_audio_to_data_url(audio: dict) -> str:
"""Encode a Studio AUDIO dict to a `data:audio/wav` URL (driving audio for
/v1/animate, which takes audio as an http/file/data URL)."""
return "data:audio/wav;base64," + base64.b64encode(studio_audio_to_wav_bytes(audio)).decode("ascii")
+212
View File
@@ -0,0 +1,212 @@
"""Contract test: drive every Hanzo Engine node through REAL HTTP against a stub
engine that enforces the exact request fields the Rust handlers deserialize
(ThreeDGenerationRequest, VideoGenerationRequest, AnimateRequest, Speech/Music
requests) and the async-job protocol (submit -> poll -> content). This is a real
request/response cycle over sockets + the requests lib + client.run_job polling,
not a monkeypatched mock. Run: python contract_check.py
"""
# ruff: noqa: T201 (a CLI harness prints its results)
import base64
import io
import json
import os
import sys
import threading
import types
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import numpy as np
import torch
# Stub out the ComfyUI-side modules the node pack imports so nodes.py loads.
fp = types.ModuleType("folder_paths")
fp.get_output_directory = lambda: "/tmp"
fp.get_save_image_path = lambda pre, d: (d, pre, 1, "", "")
sys.modules["folder_paths"] = fp
# Import the pack as a package so its `from . import client` resolves.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from hanzo_engine import nodes # noqa: E402
RECORDED = {} # path -> last JSON/body seen
def _wav_bytes(seconds=0.1, sr=44100, ch=2):
import wave
n = int(seconds * sr)
buf = io.BytesIO()
with wave.open(buf, "wb") as w:
w.setnchannels(ch)
w.setsampwidth(2)
w.setframerate(sr)
w.writeframes(b"\x00\x00" * n * ch)
return buf.getvalue()
def _png_b64():
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (8, 8), (128, 64, 32)).save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode("ascii")
def _mp4_bytes(frames=3, w=16, h=16):
import av
buf = io.BytesIO()
container = av.open(buf, mode="w", format="mp4")
stream = container.add_stream("libx264", rate=25)
stream.width, stream.height, stream.pix_fmt = w, h, "yuv420p"
for i in range(frames):
arr = np.full((h, w, 3), i * 20 % 255, dtype=np.uint8)
frame = av.VideoFrame.from_ndarray(arr, format="rgb24")
for pkt in stream.encode(frame):
container.mux(pkt)
for pkt in stream.encode():
container.mux(pkt)
container.close()
return buf.getvalue()
class Handler(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def _send(self, code, body, ctype="application/json"):
if isinstance(body, (dict, list)):
body = json.dumps(body).encode()
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _body_json(self):
n = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(n) if n else b"{}"
try:
return json.loads(raw)
except ValueError:
return {"_raw": raw}
def do_GET(self):
p = self.path
if p == "/v1/models":
return self._send(200, {"data": [{"id": "stub-model"}]})
if p.startswith("/v1/3d/") and p.endswith("/content"):
return self._send(200, base64.b64decode(_PLACEHOLDER_GLB_B64), "model/gltf-binary")
if p.startswith("/v1/videos/") and p.endswith("/content"):
return self._send(200, _mp4_bytes(), "video/mp4")
if p.startswith("/v1/3d/") or p.startswith("/v1/videos/"):
# poll: report completed immediately
return self._send(200, {"id": p.split("/")[3], "status": "completed", "progress": 1.0})
return self._send(404, {"message": "no"})
def do_POST(self):
try:
self._do_POST()
except Exception as exc: # surface stub-side errors to the client
self._send(500, {"stub_error": repr(exc)})
def _do_POST(self):
p = self.path
# multipart (ASR) is not JSON
if p == "/v1/audio/transcriptions":
n = int(self.headers.get("Content-Length", 0))
_ = self.rfile.read(n)
RECORDED[p] = {"multipart": True}
return self._send(200, {"text": "stub transcript"})
body = self._body_json()
RECORDED[p] = body
if p == "/v1/chat/completions":
return self._send(200, {"choices": [{"message": {"content": "expanded prompt"}}]})
if p == "/v1/images/generations":
return self._send(200, {"data": [{"b64_json": _png_b64()}]})
if p == "/v1/audio/speech":
assert "input" in body and "model" in body, "speech needs input+model"
return self._send(200, _wav_bytes(ch=1, sr=24000), "audio/wav")
if p == "/v1/audio/music":
assert "input" in body and "model" in body, "music needs input+model"
return self._send(200, _wav_bytes(ch=2, sr=44100), "audio/wav")
if p == "/v1/animate":
assert set(body) >= {"model", "audio", "visual"}, "animate needs model+audio+visual"
assert body["visual"].startswith("data:image/"), "visual must be an image data url"
assert body["audio"].startswith("data:audio/"), "audio must be an audio data url"
return self._send(200, _mp4_bytes(), "video/mp4")
if p == "/v1/videos":
assert "prompt" in body, "video needs prompt"
return self._send(202, {"id": "vid-1", "status": "queued"})
if p == "/v1/3d":
assert "image" in body, "3d needs image"
return self._send(202, {"id": "threed-1", "status": "queued", "progress": 0.0})
return self._send(404, {"message": "no"})
# A 1-triangle binary glTF is overkill; the node just writes bytes to disk, so any
# non-empty payload proves the content fetch + save path. Use a tiny placeholder.
_PLACEHOLDER_GLB_B64 = base64.b64encode(b"glTF\x02\x00\x00\x00stub-mesh").decode("ascii")
def main():
srv = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
port = srv.server_address[1]
threading.Thread(target=srv.serve_forever, daemon=True).start()
import os
os.environ["HANZO_ENGINE_URL"] = f"http://127.0.0.1:{port}"
img = torch.rand(1, 8, 8, 3)
audio = {"waveform": torch.zeros(1, 2, 4410), "sample_rate": 44100}
passed = []
# Chat (non-stream path)
(text,) = nodes.HanzoChat().generate(model="default", prompt="a red dragon", stream=False)["result"]
assert text == "expanded prompt", text
assert RECORDED["/v1/chat/completions"]["messages"][-1]["content"] == "a red dragon"
passed.append("HanzoChat -> /v1/chat/completions")
# ImageGen
(imgs,) = nodes.HanzoImageGen().generate("a red dragon", 64, 64, n=1)
assert imgs.shape[-1] == 3
passed.append("HanzoImageGen -> /v1/images/generations")
# TTS
(aud,) = nodes.HanzoTTS().speak("hello world", response_format="wav")
assert "waveform" in aud and aud["sample_rate"] == 24000
passed.append("HanzoTTS -> /v1/audio/speech")
# Music (NEW)
(music,) = nodes.HanzoMusic().compose("epic orchestral", response_format="wav")
assert music["sample_rate"] == 44100 and music["waveform"].shape[1] == 2, music["waveform"].shape
assert RECORDED["/v1/audio/music"]["input"] == "epic orchestral"
passed.append("HanzoMusic -> /v1/audio/music (input field, stereo 44.1k)")
# ASR
(tr,) = nodes.HanzoASR().transcribe(audio)
assert tr == "stub transcript"
passed.append("HanzoASR -> /v1/audio/transcriptions")
# ImageTo3D (texture flag fix) -- async job submit+poll+content
out = nodes.HanzoImageTo3D().generate(img, "glb", True, 0, 4)
assert RECORDED["/v1/3d"]["texture"] is True, "texture flag not sent"
assert RECORDED["/v1/3d"]["format"] == "glb"
assert out["result"][0].endswith(".glb")
passed.append("HanzoImageTo3D -> /v1/3d (texture=true sent; job submit->poll->content)")
# TextToVideo -- async job
(frames,) = nodes.HanzoTextToVideo().generate("a red dragon flying", 3, 16, 16, 4)
assert frames.dim() == 4 and frames.shape[0] >= 1
assert RECORDED["/v1/videos"]["num_frames"] == 3
passed.append("HanzoTextToVideo -> /v1/videos (job submit->poll->content)")
# Dub (NEW) -- synchronous mp4
(dframes, daudio) = nodes.HanzoDub().dub(img, audio, fps=25.0)
assert dframes.dim() == 4
assert daudio is audio
passed.append("HanzoDub -> /v1/animate (image+audio data urls; frames+audio out)")
srv.shutdown()
print("\n".join(f"PASS {p}" for p in passed))
print(f"\nALL {len(passed)} NODE CONTRACT CHECKS PASSED against a live HTTP engine stub.")
if __name__ == "__main__":
main()
+347 -143
View File
@@ -1,197 +1,236 @@
"""Hanzo Engine nodes for Hanzo Studio.
These nodes call a local Hanzo Engine — the native-Rust, all-modality inference
and training backend — over its OpenAI-compatible HTTP API. The base URL comes
from the HANZO_ENGINE_URL environment variable (default http://127.0.0.1:1234/v1).
Each node is one endpoint of the native-Rust Hanzo Engine, over the shared
client in `client.py`. Config is one way: the engine base URL (default
http://127.0.0.1:1234, override with HANZO_ENGINE_URL). Paths are always
`/v1/*` — never `/api/*`.
Connection and protocol errors surface as clear node errors (raised
RuntimeError), which Hanzo Studio reports on the failing node rather than
crashing the server.
HanzoChat -> POST /v1/chat/completions (prompt -> text)
HanzoImageGen -> POST /v1/images/generations (prompt -> IMAGE)
HanzoTTS -> POST /v1/audio/speech (text -> AUDIO)
HanzoMusic -> POST /v1/audio/music (prompt -> AUDIO)
HanzoASR -> POST /v1/audio/transcriptions (AUDIO -> text)
HanzoEngineRequest -> POST|GET <any /v1 path> (JSON -> response)
HanzoVisionCaption -> POST /v1/chat/completions (vision) (IMAGE -> text)
HanzoTextToVideo -> async /v1/videos (prompt -> frames)
HanzoImageTo3D -> async /v1/3d (IMAGE -> mesh path)
HanzoDub -> POST /v1/animate (IMAGE+AUDIO -> frames)
HanzoSaveText -> writes a STRING to output/*.txt
The generic HanzoEngineRequest is the forward-compatible escape hatch: a new
engine endpoint (music, dub, ...) is reachable the day it ships, and promoting
it to a first-class node is a few lines here.
"""
import base64
import io
import json
import os
import numpy as np
import requests
import torch
from PIL import Image
import folder_paths
DEFAULT_BASE_URL = "http://127.0.0.1:1234/v1"
from . import client
CATEGORY = "Hanzo/Engine"
def _base_url() -> str:
return os.environ.get("HANZO_ENGINE_URL", DEFAULT_BASE_URL).rstrip("/")
def _post(path: str, payload: dict, timeout: int = 120) -> dict:
url = f"{_base_url()}{path}"
try:
resp = requests.post(url, json=payload, timeout=timeout)
except requests.exceptions.RequestException as exc:
raise RuntimeError(
f"Hanzo Engine request to {url} failed: {exc}. Is the engine running? "
f"Set HANZO_ENGINE_URL (default {DEFAULT_BASE_URL})."
) from exc
if resp.status_code != 200:
raise RuntimeError(
f"Hanzo Engine {url} returned HTTP {resp.status_code}: {resp.text[:500]}"
)
try:
return resp.json()
except ValueError as exc:
raise RuntimeError(
f"Hanzo Engine {url} returned a non-JSON response: {resp.text[:200]}"
) from exc
def _get_bytes(url: str, timeout: int = 120) -> bytes:
try:
resp = requests.get(url, timeout=timeout)
except requests.exceptions.RequestException as exc:
raise RuntimeError(f"Hanzo Engine image fetch from {url} failed: {exc}.") from exc
if resp.status_code != 200:
raise RuntimeError(f"Hanzo Engine image fetch {url} returned HTTP {resp.status_code}.")
return resp.content
def _extract_message_text(data: dict) -> str:
try:
content = (data["choices"][0].get("message") or {}).get("content")
except (KeyError, IndexError, TypeError) as exc:
raise RuntimeError(
f"Unexpected Hanzo Engine response shape: {json.dumps(data)[:300]}"
) from exc
if isinstance(content, list): # OpenAI structured content parts
parts = [p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"]
return "".join(parts).strip()
return (content or "").strip()
def _tensor_to_pil(image: torch.Tensor) -> Image.Image:
if image.dim() == 4: # [B,H,W,C] -> first frame
image = image[0]
arr = (image.cpu().numpy() * 255.0).clip(0, 255).astype(np.uint8)
return Image.fromarray(arr)
def _pil_to_tensor(img: Image.Image) -> torch.Tensor:
arr = np.array(img.convert("RGB")).astype(np.float32) / 255.0
return torch.from_numpy(arr)[None,] # [1,H,W,C]
def _pil_to_data_url(img: Image.Image) -> str:
buf = io.BytesIO()
img.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
return f"data:image/png;base64,{b64}"
def _decode_image_item(item: dict) -> Image.Image:
b64 = item.get("b64_json")
if b64:
return Image.open(io.BytesIO(base64.b64decode(b64))).convert("RGB")
url = item.get("url")
if url:
if url.startswith("data:"):
raw = base64.b64decode(url.split(",", 1)[1])
else:
raw = _get_bytes(url)
return Image.open(io.BytesIO(raw)).convert("RGB")
raise RuntimeError("Hanzo Engine image item has neither 'b64_json' nor 'url'.")
def _model_combo() -> list:
"""["default", <ids from /v1/models>] — 'default' always routes to the
loaded model, so saved graphs stay portable across engines."""
combo, seen = [], set()
for name in ["default", *client.list_models()]:
if name not in seen:
seen.add(name)
combo.append(name)
return combo
class HanzoChat:
"""Text chat via Hanzo Engine /v1/chat/completions. Use for prompt
engineering, caption cleanup, and text chains inside a graph."""
"""LLM chat/completion. Streams tokens (live progress) and returns the
full text; feeds prompt chains and image prompts inside a graph."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("STRING", {"default": "default"}),
"model": (_model_combo(),),
"prompt": ("STRING", {"multiline": True, "default": ""}),
},
"optional": {
"system": ("STRING", {"multiline": True, "default": ""}),
"temperature": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 2.0, "step": 0.05}),
"max_tokens": ("INT", {"default": 512, "min": 1, "max": 32768}),
"stream": ("BOOLEAN", {"default": True}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
FUNCTION = "generate"
CATEGORY = "Hanzo/Engine"
CATEGORY = CATEGORY
OUTPUT_NODE = True
def generate(self, model, prompt, system="", temperature=0.7, max_tokens=512):
def generate(self, model, prompt, system="", temperature=0.7, max_tokens=512, stream=True):
messages = []
if system.strip():
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
data = _post(
"/chat/completions",
{"model": model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens},
)
return (_extract_message_text(data),)
payload = {"model": model, "messages": messages,
"temperature": temperature, "max_tokens": max_tokens}
if stream:
pbar = client.progress_bar(max_tokens)
parts = []
for i, delta in enumerate(client.stream_chat(payload)):
parts.append(delta)
if pbar:
pbar.update_absolute(min(i + 1, max_tokens))
text = "".join(parts).strip()
else:
text = client.extract_message_text(client.post_json("/v1/chat/completions", payload))
return {"ui": {"text": [text]}, "result": (text,)}
class HanzoImageGen:
"""Text-to-image via Hanzo Engine /v1/images/generations. Returns a
Hanzo Studio IMAGE tensor."""
"""Text-to-image via /v1/images/generations (native Rust). Returns a
Studio IMAGE tensor."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
"size": (["1024x1024", "1024x1536", "1536x1024", "768x768", "512x512"], {"default": "1024x1024"}),
"width": ("INT", {"default": 1024, "min": 64, "max": 4096, "step": 8}),
"height": ("INT", {"default": 1024, "min": 64, "max": 4096, "step": 8}),
},
"optional": {
"model": ("STRING", {"default": "default"}),
"steps": ("INT", {"default": 20, "min": 1, "max": 150}),
"model": (_model_combo(),),
"n": ("INT", {"default": 1, "min": 1, "max": 8}),
},
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "generate"
CATEGORY = "Hanzo/Engine"
CATEGORY = CATEGORY
def generate(self, prompt, size, model="default", steps=20, n=1):
data = _post(
"/images/generations",
{"model": model, "prompt": prompt, "size": size, "n": n, "steps": steps, "response_format": "b64_json"},
def generate(self, prompt, width, height, model="default", n=1):
data = client.post_json(
"/v1/images/generations",
{"model": model, "prompt": prompt, "width": width, "height": height,
"n": n, "response_format": "b64_json"},
timeout=600,
)
items = data.get("data") or []
if not items:
raise RuntimeError("Hanzo Engine /images/generations returned no image data.")
tensors = [_pil_to_tensor(_decode_image_item(it)) for it in items]
if len(tensors) == 1:
batch = tensors[0]
return (client.images_to_batch(data.get("data") or []),)
class HanzoTTS:
"""Text-to-speech via /v1/audio/speech (native Rust). Returns a Studio
AUDIO you can save/preview with the built-in audio nodes."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {"multiline": True, "default": ""}),
},
"optional": {
"model": (_model_combo(),),
"response_format": (["wav", "pcm"], {"default": "wav"}),
},
}
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("audio",)
FUNCTION = "speak"
CATEGORY = CATEGORY
def speak(self, text, model="default", response_format="wav"):
raw = client.post_bytes(
"/v1/audio/speech",
{"model": model, "input": text, "response_format": response_format},
)
return (client.audio_bytes_to_studio(raw),)
class HanzoASR:
"""Speech-to-text via /v1/audio/transcriptions (OpenAI multipart). Returns
the transcript text."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"audio": ("AUDIO",),
},
"optional": {
"model": (_model_combo(),),
"language": ("STRING", {"default": ""}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
FUNCTION = "transcribe"
CATEGORY = CATEGORY
def transcribe(self, audio, model="default", language=""):
wav = client.studio_audio_to_wav_bytes(audio)
data = {"model": model, "response_format": "json"}
if language.strip():
data["language"] = language.strip()
resp = client.post_multipart(
"/v1/audio/transcriptions",
files={"file": ("audio.wav", wav, "audio/wav")},
data=data,
)
return ((resp.get("text") or "").strip(),)
class HanzoEngineRequest:
"""Generic engine call: any `/v1` path with a JSON body -> the raw JSON
response as STRING. The forward-compatible escape hatch for endpoints that
do not have a dedicated node yet (music, dub, ...)."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"method": (["POST", "GET"], {"default": "POST"}),
"path": ("STRING", {"default": "/v1/chat/completions"}),
"json_body": ("STRING", {"multiline": True, "default": "{}"}),
},
"optional": {
"timeout": ("INT", {"default": 120, "min": 1, "max": 3600}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("response",)
FUNCTION = "call"
CATEGORY = CATEGORY
def call(self, method, path, json_body, timeout=120):
try:
payload = json.loads(json_body or "{}")
except ValueError as exc:
raise client.EngineError(f"HanzoEngineRequest json_body is not valid JSON: {exc}") from exc
if method == "GET":
data = client.get_json(path, timeout=timeout)
else:
same = len({t.shape for t in tensors}) == 1
batch = torch.cat(tensors, dim=0) if same else tensors[0]
return (batch,)
data = client.post_json(path, payload, timeout=timeout)
return (json.dumps(data, indent=2),)
class HanzoVisionCaption:
"""Caption an IMAGE via Hanzo Engine vision chat (OpenAI image_url format).
Feeds LoRA/dataset prep loops; pair with a vision model such as Qwen3-VL."""
"""Caption an IMAGE via vision chat (OpenAI image_url). Pair with a vision
model (e.g. Qwen3-VL) for LoRA/dataset prep."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"prompt": ("STRING", {"multiline": True, "default": "Describe this image in detail for a training caption."}),
"prompt": ("STRING", {"multiline": True,
"default": "Describe this image in detail for a training caption."}),
},
"optional": {
"model": ("STRING", {"default": "default"}),
"model": (_model_combo(),),
"system": ("STRING", {"multiline": True, "default": ""}),
"max_tokens": ("INT", {"default": 512, "min": 1, "max": 32768}),
},
@@ -200,27 +239,178 @@ class HanzoVisionCaption:
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("caption",)
FUNCTION = "caption"
CATEGORY = "Hanzo/Engine"
CATEGORY = CATEGORY
def caption(self, image, prompt, model="default", system="", max_tokens=512):
data_url = _pil_to_data_url(_tensor_to_pil(image))
content = [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": client.tensor_to_data_url(image)}},
]
messages = []
if system.strip():
messages.append({"role": "system", "content": system})
messages.append({
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": data_url}},
],
})
data = _post("/chat/completions", {"model": model, "messages": messages, "max_tokens": max_tokens}, timeout=300)
return (_extract_message_text(data),)
messages.append({"role": "user", "content": content})
data = client.post_json(
"/v1/chat/completions",
{"model": model, "messages": messages, "max_tokens": max_tokens},
timeout=300,
)
return (client.extract_message_text(data),)
class HanzoTextToVideo:
"""Text-to-video via the async job /v1/videos (native Rust). Returns the
decoded frames as an IMAGE batch so it composes with video-save nodes."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
"num_frames": ("INT", {"default": 81, "min": 1, "max": 1024}),
"width": ("INT", {"default": 832, "min": 64, "max": 2048, "step": 8}),
"height": ("INT", {"default": 480, "min": 64, "max": 2048, "step": 8}),
"steps": ("INT", {"default": 30, "min": 1, "max": 150}),
},
"optional": {
"model": (_model_combo(),),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("frames",)
FUNCTION = "generate"
CATEGORY = CATEGORY
def generate(self, prompt, num_frames, width, height, steps, model="default"):
pbar = client.progress_bar(100)
def report(p):
if pbar is not None and p is not None:
try:
pct = float(p)
pbar.update_absolute(max(0, min(100, int(pct * (100 if pct <= 1.0 else 1)))))
except (TypeError, ValueError):
pass
raw = client.run_job(
"videos",
{"model": model, "prompt": prompt, "num_frames": num_frames,
"width": width, "height": height, "steps": steps},
job_timeout=1800,
progress=report,
)
return (client.mp4_bytes_to_image_batch(raw),)
class HanzoImageTo3D:
"""Image-to-3D via the async job /v1/3d (native Rust TRELLIS/Pixal3D). Saves
the mesh to the output dir and returns its path for a 3D-viewer node. Enable
`texture` for the full SLAT + FlexiCubes stage (textured GLB vs coarse mesh)."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"format": (["glb", "ply", "obj"], {"default": "glb"}),
"texture": ("BOOLEAN", {"default": True}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF}),
"steps": ("INT", {"default": 30, "min": 1, "max": 150}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("mesh_path",)
FUNCTION = "generate"
CATEGORY = CATEGORY
OUTPUT_NODE = True
def generate(self, image, format, texture, seed, steps):
raw = client.run_job(
"3d",
{"image": client.tensor_to_data_url(image), "format": format,
"texture": texture, "seed": seed, "steps": steps},
job_timeout=900,
)
full_output_folder, filename, counter, _sub, _pre = folder_paths.get_save_image_path(
"hanzo3d", folder_paths.get_output_directory()
)
path = os.path.join(full_output_folder, f"{filename}_{counter:05}_.{format}")
with open(path, "wb") as fh:
fh.write(raw)
return {"ui": {"text": [path]}, "result": (path,)}
class HanzoMusic:
"""Text/tag/lyric prompt -> music via /v1/audio/music (native Rust ACE-Step).
Returns a Studio AUDIO you can save/preview with the built-in audio nodes."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"multiline": True,
"default": "epic cinematic orchestral, driving percussion, brass swells"}),
},
"optional": {
"model": (_model_combo(),),
"response_format": (["wav", "pcm"], {"default": "wav"}),
},
}
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("audio",)
FUNCTION = "compose"
CATEGORY = CATEGORY
def compose(self, prompt, model="default", response_format="wav"):
raw = client.post_bytes(
"/v1/audio/music",
{"model": model, "input": prompt, "response_format": response_format},
timeout=900,
)
return (client.audio_bytes_to_studio(raw),)
class HanzoDub:
"""Lip-sync / avatar dub via /v1/animate (native Rust MuseTalk). Drives a
portrait IMAGE (or the first video frame) with an AUDIO track and returns the
animated frames as an IMAGE batch plus the driving audio, so a video-combine
node can mux them."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"audio": ("AUDIO",),
},
"optional": {
"model": (_model_combo(),),
"fps": ("FLOAT", {"default": 25.0, "min": 1.0, "max": 60.0, "step": 1.0}),
},
}
RETURN_TYPES = ("IMAGE", "AUDIO")
RETURN_NAMES = ("frames", "audio")
FUNCTION = "dub"
CATEGORY = CATEGORY
def dub(self, image, audio, model="default", fps=25.0):
payload = {
"model": model,
"audio": client.studio_audio_to_data_url(audio),
"visual": client.tensor_to_data_url(image),
"fps": fps,
}
raw = client.post_bytes("/v1/animate", payload, timeout=1800)
return (client.mp4_bytes_to_image_batch(raw), audio)
class HanzoSaveText:
"""Write a STRING to a .txt file in the output directory (image/caption
pairs for dataset prep) and preview it in the node."""
"""Write a STRING to output/*.txt (image/caption pairs for dataset prep)
and preview it in the node."""
def __init__(self):
self.output_dir = folder_paths.get_output_directory()
@@ -230,17 +420,17 @@ class HanzoSaveText:
return {
"required": {
"text": ("STRING", {"forceInput": True}),
"filename_prefix": ("STRING", {"default": "caption"}),
"filename_prefix": ("STRING", {"default": "hanzo"}),
},
}
RETURN_TYPES = ()
FUNCTION = "save"
OUTPUT_NODE = True
CATEGORY = "Hanzo/Engine"
CATEGORY = CATEGORY
def save(self, text, filename_prefix="caption"):
full_output_folder, filename, counter, _subfolder, _prefix = folder_paths.get_save_image_path(
def save(self, text, filename_prefix="hanzo"):
full_output_folder, filename, counter, _sub, _pre = folder_paths.get_save_image_path(
filename_prefix, self.output_dir
)
path = os.path.join(full_output_folder, f"{filename}_{counter:05}_.txt")
@@ -252,13 +442,27 @@ class HanzoSaveText:
NODE_CLASS_MAPPINGS = {
"HanzoChat": HanzoChat,
"HanzoImageGen": HanzoImageGen,
"HanzoTTS": HanzoTTS,
"HanzoMusic": HanzoMusic,
"HanzoASR": HanzoASR,
"HanzoEngineRequest": HanzoEngineRequest,
"HanzoVisionCaption": HanzoVisionCaption,
"HanzoTextToVideo": HanzoTextToVideo,
"HanzoImageTo3D": HanzoImageTo3D,
"HanzoDub": HanzoDub,
"HanzoSaveText": HanzoSaveText,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"HanzoChat": "Hanzo Chat (Engine)",
"HanzoImageGen": "Hanzo Image Gen (Engine)",
"HanzoTTS": "Hanzo TTS (Engine)",
"HanzoMusic": "Hanzo Music (Engine)",
"HanzoASR": "Hanzo ASR (Engine)",
"HanzoEngineRequest": "Hanzo Engine Request",
"HanzoVisionCaption": "Hanzo Vision Caption (Engine)",
"HanzoTextToVideo": "Hanzo Text to Video (Engine)",
"HanzoImageTo3D": "Hanzo Image to 3D (Engine)",
"HanzoDub": "Hanzo Dub / Lip-Sync (Engine)",
"HanzoSaveText": "Hanzo Save Text",
}
+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)
+852
View File
@@ -0,0 +1,852 @@
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Hanzo Studio</title>
<style>
:root{--bg:#0a0a0b;--panel:#131317;--panel2:#17171c;--line:#232329;--txt:#ececf1;--dim:#8a8a95;
--brand:#f4f4f6;--ok:#3ddc84;--warn:#ffb648;--pub:#6aa5ff;font-size:15px}
*{box-sizing:border-box;margin:0}
body{background:var(--bg);color:var(--txt);font:400 1rem/1.5 Inter,system-ui,-apple-system,sans-serif}
.serif{font-family:"Iowan Old Style","Palatino Linotype",Georgia,serif}
a{color:inherit}
header{display:flex;align-items:center;gap:12px;padding:14px 26px;border-bottom:1px solid var(--line);
position:fixed;top:0;left:0;right:0;background:rgba(10,10,11,.82);backdrop-filter:blur(14px);z-index:30;
transition:transform .28s ease,opacity .28s ease}
/* immersive: header hides; a top hover-zone or mouse-near-top reveals it */
body.immersive header{transform:translateY(-100%);opacity:0;border-bottom-color:transparent}
body.immersive header.reveal{transform:translateY(0);opacity:1}
#tophover{position:fixed;top:0;left:0;right:0;height:24px;z-index:29}
header .logo{width:26px;height:26px;border-radius:7px;display:block;flex:0 0 auto}
header h1{font-size:1.02rem;font-weight:600;letter-spacing:.2px}
header .beta{font-size:.62rem;color:var(--dim);border:1px solid var(--line);border-radius:5px;padding:1px 5px;text-transform:uppercase;letter-spacing:.08em}
header .sp{flex:1}
header .lnk{color:var(--dim);font-size:.85rem;text-decoration:none}
header .user{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:99px;padding:3px 11px 3px 4px;cursor:pointer}
header .user .av{width:24px;height:24px;border-radius:50%;background:#26262e;display:flex;align-items:center;justify-content:center;font-size:.72rem;font-weight:600}
header .user .nm{font-size:.82rem;color:var(--dim)}
.btn{border:1px solid var(--line);background:var(--panel);color:var(--txt);border-radius:8px;padding:7px 13px;font-size:.85rem;cursor:pointer}
.btn:hover{border-color:var(--dim)} .btn.brand{background:var(--brand);border-color:var(--brand);color:#111;font-weight:600}
main{max-width:1120px;margin:0 auto;padding:12px 24px 80px}
body{padding-top:56px} /* clear the fixed header */
body.immersive{padding-top:0}
/* persistent live-queue bar — always visible so you can see what's generating */
#livebar{position:sticky;top:56px;z-index:19;max-width:1120px;margin:0 auto 8px;display:flex;align-items:center;gap:14px;
background:var(--panel);border:1px solid var(--line);border-radius:11px;padding:9px 15px;font-size:.84rem}
#livebar .lb-run{color:var(--ok);display:flex;align-items:center;gap:7px}
#livebar .lb-run .spin{width:9px;height:9px;border-radius:50%;background:var(--ok);animation:pulse 1.1s infinite}
@keyframes pulse{0%,100%{opacity:.35}50%{opacity:1}}
#livebar .lb-q{color:var(--dim)} #livebar .lb-eta{color:var(--warn)} #livebar .sp{flex:1}
#livebar a.lb-more{color:var(--dim);text-decoration:none;font-size:.8rem;border:1px solid var(--line);border-radius:7px;padding:3px 10px;cursor:pointer}
#livebar.idle{opacity:.72}
.hero{text-align:center;font-size:3rem;font-weight:500;margin:56px 0 26px;letter-spacing:-.01em}
/* prompt box */
.prompt{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:16px 16px 12px;max-width:820px;margin:0 auto}
.prompt textarea{width:100%;background:none;border:none;color:var(--txt);font:inherit;font-size:1.02rem;resize:none;outline:none;min-height:26px;max-height:180px}
.prompt textarea::placeholder{color:var(--dim)}
.pctl{display:flex;align-items:center;gap:8px;margin-top:12px}
.pchip{display:flex;flex-direction:column;line-height:1.1;border:1px solid var(--line);border-radius:9px;padding:5px 11px;background:var(--panel2);cursor:pointer;font-size:.8rem}
.pchip .k{color:var(--dim);font-size:.64rem;text-transform:uppercase;letter-spacing:.05em}
.pchip .v{color:var(--txt);font-weight:500}
.pico{border:1px solid var(--line);border-radius:9px;padding:8px 10px;background:var(--panel2);cursor:pointer;color:var(--dim);font-size:.9rem}
.psend{margin-left:auto;width:38px;height:38px;border-radius:10px;border:none;background:var(--brand);color:#111;font-size:1.1rem;cursor:pointer}
/* templates */
.sec-l{color:var(--dim);font-size:.82rem;margin:40px 0 12px}
.tgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px}
.tcard{background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden;cursor:pointer;transition:.12s}
.tcard:hover{border-color:#3a3a48;transform:translateY(-2px)}
.tcard .ic{aspect-ratio:16/10;display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:1.7rem;background:var(--panel2)}
.tcard .tl{padding:9px 11px;font-size:.83rem;font-weight:500}
.tcard .td{padding:0 11px 10px;font-size:.7rem;color:var(--dim)}
/* tabs */
.tabs{display:flex;gap:6px;align-items:center;margin:44px 0 16px;border-bottom:1px solid var(--line);padding-bottom:0}
.tabs button{background:none;border:none;color:var(--dim);font-size:.9rem;padding:8px 12px;cursor:pointer;border-radius:8px 8px 0 0;border-bottom:2px solid transparent}
.tabs button.on{color:var(--txt);border-bottom-color:var(--brand)}
.tabs .sp{flex:1}
.search{background:var(--panel);border:1px solid var(--line);border-radius:9px;padding:6px 12px;color:var(--txt);font:inherit;font-size:.85rem;width:220px}
.chips{display:flex;gap:8px;flex-wrap:wrap;margin:4px 0 18px}
.chip{border:1px solid var(--line);border-radius:99px;padding:4px 12px;font-size:.8rem;color:var(--dim);cursor:pointer}
.chip.on{color:#111;background:var(--brand);border-color:var(--brand);font-weight:600}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(215px,1fr));gap:15px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:13px;overflow:hidden;display:flex;flex-direction:column}
.card .im{aspect-ratio:2/3;background:#0f0f12 center/cover no-repeat;cursor:zoom-in}
.card .doc{aspect-ratio:2/3;display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:.8rem;text-align:center;padding:12px}
.card .meta{padding:10px 12px;display:flex;flex-direction:column;gap:7px}
.card .t{font-size:.82rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card .sub{font-size:.7rem;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.row{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
.st{font-size:.66rem;font-weight:600;border-radius:99px;padding:2px 8px;text-transform:uppercase;letter-spacing:.4px}
.st.draft{background:#2a2a31;color:var(--dim)}.st.approved{background:rgba(61,220,132,.15);color:var(--ok)}
.st.queued{background:rgba(255,182,72,.15);color:var(--warn)}.st.published{background:rgba(106,165,255,.18);color:var(--pub)}
.mini{border:1px solid var(--line);background:none;color:var(--dim);border-radius:7px;padding:4px 9px;font-size:.72rem;cursor:pointer}
.mini:hover{color:var(--txt);border-color:var(--dim)} .mini.hot{color:#111;background:var(--brand);border-color:var(--brand);font-weight:600}
.empty{color:var(--dim);padding:44px;text-align:center}
#toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:#1c1c22;border:1px solid var(--line);border-radius:10px;padding:10px 18px;font-size:.85rem;opacity:0;transition:.25s;pointer-events:none;z-index:50}
#toast.show{opacity:1}
#zoom{position:fixed;inset:0;background:rgba(0,0,0,.9);display:none;align-items:center;justify-content:center;z-index:40;cursor:zoom-out}
#zoom img{max-width:94vw;max-height:94vh;border-radius:8px}
dialog{background:var(--panel);color:var(--txt);border:1px solid var(--line);border-radius:14px;padding:22px;max-width:460px;width:92vw}
dialog::backdrop{background:rgba(0,0,0,.6)} dialog textarea{width:100%;background:#0f0f12;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:10px;font:inherit;font-size:.9rem;min-height:90px;margin:12px 0}
#bulkbar{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1a1a24;border:1px solid #34344a;border-radius:12px;padding:9px 14px;display:none;gap:9px;align-items:center;z-index:45;box-shadow:0 10px 32px #000a}
#bulkbar.on{display:flex} #bulkn{font-size:.82rem;color:#cfcfe0;margin-right:4px}
.card.sel-mode{cursor:pointer} .card.picked{outline:2px solid var(--brand);outline-offset:-2px}
/* cleaner grid: tools hidden until hover */
.card .tools{max-height:0;overflow:hidden;opacity:0;transition:.15s}
.card:hover .tools{max-height:60px;opacity:1;margin-top:2px}
.card .meta{gap:5px}
.card .pick{position:absolute;top:8px;left:9px;width:20px;height:20px;border-radius:5px;border:1.5px solid #fff;background:rgba(0,0,0,.5);display:none;align-items:center;justify-content:center;font-size:.8rem;color:#111;z-index:3}
.card{position:relative} .sel-mode .pick{display:flex} .picked .pick{background:var(--brand)}
/* YouTube-style queue + history */
.qcol{margin-bottom:26px} .qh{font-size:.9rem;font-weight:600;margin-bottom:10px;color:var(--txt);display:flex;align-items:center;gap:8px}
.qlist{display:flex;flex-direction:column;gap:8px}
.qrow{display:flex;align-items:center;gap:13px;background:var(--panel);border:1px solid var(--line);border-radius:11px;padding:9px 13px}
.qrow:hover{border-color:#333340}
.qrow .qthumb{width:60px;height:80px;border-radius:7px;background:#0f0f12 center/cover;flex:0 0 auto;font-size:1.1rem}
.qrow .qtitle{flex:1;min-width:0;font-size:.88rem;font-weight:500}
.qrow .qdesc{font-size:.76rem;color:#b3b3bd;margin-top:3px;line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.qrow .qsub{font-size:.72rem;color:var(--dim);margin-top:4px}
.stars{display:inline-flex;gap:1px;cursor:pointer} .stars span{font-size:1rem;color:#3a3a44;transition:.1s} .stars span.on{color:#ffcf4a}
.notebox{width:100%;background:#0f0f12;color:var(--txt);border:1px solid var(--line);border-radius:7px;padding:6px 9px;font:inherit;font-size:.76rem;margin-top:6px;min-height:34px;resize:vertical}
.fav{cursor:pointer;font-size:1rem;color:#3a3a44} .fav.on{color:#ffcf4a}
dialog .drop{border:1.5px dashed var(--line);border-radius:9px;padding:14px;text-align:center;color:var(--dim);font-size:.82rem;margin:8px 0;cursor:pointer}
.drop.drag{border-color:var(--brand);color:var(--txt)}
.prompt.drag{outline:2px dashed var(--brand);outline-offset:2px}
.dlgerr{display:none;color:#ff8f8f;background:rgba(192,57,43,.12);border:1px solid rgba(192,57,43,.4);border-radius:8px;padding:8px 11px;font-size:.8rem;margin:8px 0 0;white-space:pre-wrap}
.ctxlabel{font-size:.66rem;text-transform:uppercase;letter-spacing:.05em;color:var(--dim);margin:14px 0 6px}
.wst{font-size:.66rem;font-weight:600;border-radius:99px;padding:1px 7px}
.wst.queued{color:var(--warn)}.wst.running{color:var(--ok)}.wst.done{color:var(--pub)}.wst.failed{color:#ff8f8f}.wst.cancelled{color:var(--dim)}
.wmeta{font-size:.72rem;color:var(--dim);margin-top:3px;display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.wnode{border:1px solid var(--line);border-radius:6px;padding:0 6px;color:var(--txt)}
.qhead{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:0 0 14px;font-size:.8rem;color:var(--dim)}
.nodebadge{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:99px;padding:3px 11px;background:var(--panel)}
.nodebadge .dot{width:7px;height:7px;border-radius:50%;background:#555}
.nodebadge.on .dot{background:var(--ok)} .nodebadge.off{opacity:.6}
.qhead .mstat{margin-left:auto;color:var(--dim)}
#ctxdlg{max-width:560px}
.card .addref{position:absolute;top:8px;right:9px;width:26px;height:26px;border-radius:50%;border:none;background:rgba(0,0,0,.6);color:#fff;font-size:1rem;cursor:pointer;z-index:4;line-height:1}
.card .addref:hover{background:var(--brand);color:#111} .card .addref.on{background:var(--brand);color:#111}
.card .delx{position:absolute;top:8px;left:9px;width:26px;height:26px;border-radius:50%;border:none;background:rgba(0,0,0,.6);color:#fff;font-size:.85rem;cursor:pointer;z-index:4;line-height:1;opacity:.8}
.card .delx:hover{background:#c0392b;color:#fff;opacity:1}
#reftray{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1a1a24;border:1px solid #34344a;border-radius:12px;padding:10px 14px;display:none;gap:11px;align-items:center;z-index:44;box-shadow:0 10px 32px #000a}
#reftray.on{display:flex} #reftray .rt{width:38px;height:56px;object-fit:cover;border-radius:5px;border:1px solid var(--line)}
/* ── Immersive full-viewport stage (editorial hero) ────────────────────────── */
#stage{position:relative;width:100vw;height:100vh;overflow:hidden;background:#000;display:none;user-select:none}
body.immersive #stage{display:block}
#stage .slide{position:absolute;inset:0;background:#000 center/cover no-repeat;opacity:0;transition:opacity .6s ease}
#stage .slide.on{opacity:1}
#stage .cap{position:absolute;left:48px;bottom:56px;z-index:6;max-width:60vw;text-shadow:0 2px 20px rgba(0,0,0,.6)}
#stage .cap .k{font-size:.72rem;letter-spacing:.18em;text-transform:uppercase;color:rgba(255,255,255,.75)}
#stage .cap .t{font-size:2.2rem;font-weight:500;color:#fff;margin-top:6px;font-family:"Iowan Old Style",Georgia,serif}
#stage .cap .tools{display:flex;gap:8px;margin-top:14px;opacity:0;transition:.2s} #stage:hover .cap .tools{opacity:1}
#stage .nav{position:absolute;top:0;bottom:0;width:16vw;z-index:5;cursor:pointer;display:flex;align-items:center;color:rgba(255,255,255,.0);font-size:2rem;transition:.2s}
#stage .nav:hover{color:rgba(255,255,255,.9)} #stage .nav.prev{left:0;justify-content:flex-start;padding-left:28px} #stage .nav.next{right:0;justify-content:flex-end;padding-right:28px}
#stage .dots{position:absolute;bottom:26px;left:50%;transform:translateX(-50%);display:flex;gap:7px;z-index:6}
#stage .dots span{width:6px;height:6px;border-radius:50%;background:rgba(255,255,255,.35);cursor:pointer} #stage .dots span.on{background:#fff;width:20px;border-radius:4px}
#stage .exit{position:absolute;top:18px;right:22px;z-index:7;color:#fff;background:rgba(0,0,0,.4);border:1px solid rgba(255,255,255,.25);border-radius:8px;padding:6px 12px;font-size:.8rem;cursor:pointer;opacity:0;transition:.2s} #stage:hover .exit{opacity:1}
.viewbtn{border:1px solid var(--line);background:var(--panel);color:var(--txt);border-radius:8px;padding:7px 13px;font-size:.85rem;cursor:pointer}
/* ── Hanzo AI chat widget ──────────────────────────────────────────────────── */
#chatfab{position:fixed;bottom:22px;right:22px;width:52px;height:52px;border-radius:50%;background:var(--brand);color:#111;border:none;font-size:1.4rem;cursor:pointer;z-index:48;box-shadow:0 8px 24px #0008;display:flex;align-items:center;justify-content:center}
#chatpanel{position:fixed;bottom:22px;right:22px;width:380px;max-width:92vw;height:560px;max-height:82vh;background:#0e0e12;border:1px solid var(--line);border-radius:16px;z-index:49;display:none;flex-direction:column;overflow:hidden;box-shadow:0 20px 60px #000b}
#chatpanel.on{display:flex} #chatpanel .ch{display:flex;align-items:center;gap:9px;padding:14px 16px;border-bottom:1px solid var(--line)}
#chatpanel .ch .av{width:26px;height:26px;border-radius:7px;background:#111;display:flex;align-items:center;justify-content:center}
#chatpanel .ch b{font-size:.9rem} #chatpanel .ch .x{margin-left:auto;cursor:pointer;color:var(--dim);font-size:1.2rem}
#chatmsgs{flex:1;overflow:auto;padding:14px;display:flex;flex-direction:column;gap:10px}
.cmsg{max-width:86%;padding:9px 12px;border-radius:12px;font-size:.86rem;line-height:1.45;white-space:pre-wrap}
.cmsg.u{align-self:flex-end;background:var(--brand);color:#111;border-bottom-right-radius:3px}
.cmsg.a{align-self:flex-start;background:#1a1a20;border:1px solid var(--line);border-bottom-left-radius:3px}
#chatbar{display:flex;gap:8px;padding:12px;border-top:1px solid var(--line)}
#chatbar input{flex:1;background:#0a0a0d;border:1px solid var(--line);border-radius:9px;color:var(--txt);padding:9px 11px;font:inherit;font-size:.85rem}
/* ── Responsive: tablet ≤900px, phone ≤600px ─────────────────────────────── */
@media (max-width:900px){
main{padding:12px 16px 90px}
.hero{font-size:2.1rem;margin:34px 0 20px}
.grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:11px}
.tgrid{grid-template-columns:repeat(auto-fill,minmax(120px,1fr))}
#livebar{margin-left:16px;margin-right:16px}
#chatpanel{width:calc(100vw - 24px);height:70vh}
}
@media (max-width:600px){
header{padding:11px 14px;gap:9px}
header h1{font-size:.95rem} header .beta{display:none} header .lnk{display:none}
header .user .nm{display:none} /* avatar only on phone */
.hero{font-size:1.6rem;margin:22px 0 16px}
.prompt{padding:12px 12px 10px;border-radius:13px}
.prompt textarea{font-size:.95rem}
.pctl{flex-wrap:wrap;gap:6px} .psend{margin-left:0}
.grid{grid-template-columns:repeat(auto-fill,minmax(46vw,1fr));gap:9px}
.tgrid{grid-template-columns:repeat(auto-fill,minmax(44vw,1fr))}
.sec-l{margin:26px 0 10px}
.tabs{overflow-x:auto;white-space:nowrap;-webkit-overflow-scrolling:touch}
.tabs button{padding:8px 10px;font-size:.84rem}
.search{width:120px} .tabs .sp{display:none}
#livebar{flex-wrap:wrap;font-size:.78rem}
#bulkbar,#reftray{left:8px;right:8px;transform:none;flex-wrap:wrap;justify-content:center}
#chatfab{bottom:16px;right:16px}
#chatpanel{bottom:0;right:0;left:0;width:100vw;height:82vh;max-height:82vh;border-radius:16px 16px 0 0}
.card .delx,.card .addref{width:30px;height:30px} /* bigger tap targets */
}
/* touch: no hover-reveal — always show card tools on touch devices */
@media (hover:none){
.card .tools{max-height:none;opacity:1;margin-top:2px}
}
</style>
<header>
<svg class="logo" viewBox="0 0 520 520" xmlns="http://www.w3.org/2000/svg"><rect width="520" height="520" rx="120" fill="#111"/><g transform="translate(100,100) scale(4.78)" fill="#fff"><path d="M22.21 67V44.6369H0V67H22.21Z"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z"/><path d="M22.21 0H0V22.3184H22.21V0Z"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z"/></g></svg>
<h1>Hanzo Studio</h1><span class="beta">Beta</span><span class="sp"></span>
<a class="lnk" href="/?advanced=1">Advanced mode</a>
<button class="btn" onclick="load()">Refresh</button>
<div class="user" id="user" title="Account"><span class="av" id="av">·</span><span class="nm" id="org"></span></div>
</header>
<div id="tophover"></div>
<div id="livebar" class="idle">
<span id="lbstatus" class="lb-q">Checking queue…</span>
<span class="sp"></span>
<a class="lb-more" onclick="tab('runs');document.getElementById('runs').scrollIntoView({behavior:'smooth'})">Queue &amp; History →</a>
</div>
<main>
<h2 class="hero serif">What should we create?</h2>
<div class="prompt">
<textarea id="pin" rows="1" placeholder="Describe what you want to create…"></textarea>
<div class="pctl">
<span class="pico" title="Attach a photo" onclick="attachPick()">+</span>
<input id="attachfile" type="file" accept="image/*" multiple hidden>
<span class="pchip" onclick="pickPreset()"><span class="k">Design system</span><span class="v" id="ds">None</span></span>
<span class="pchip" onclick="tab('templates')"><span class="k">Template</span><span class="v" id="tmpl">None</span></span>
<span class="pico" title="Code">&lt;/&gt;</span>
<span class="pchip" style="margin-left:auto"><span class="k">Model</span><span class="v">Opus 4.8</span></span>
<button class="psend" onclick="create()" title="Create"></button>
</div>
</div>
<div class="sec-l">Use a template</div>
<div class="tgrid" id="tcards"></div>
<div class="tabs">
<button class="on" data-tab="assets" onclick="tab('assets')">Assets</button>
<button data-tab="templates" onclick="tab('templates')">Templates</button>
<button data-tab="pipelines" onclick="tab('pipelines')">Workflows</button>
<button data-tab="runs" onclick="tab('runs')">Queue &amp; History</button>
<span class="sp"></span>
<button class="btn" id="selbtn" onclick="toggleSel()" style="padding:6px 12px">Select</button>
<input class="search" id="q" placeholder="Search…" oninput="render()">
</div>
<section id="assets"><div class="chips" id="filters"></div><div class="grid" id="agrid"></div></section>
<div id="bulkbar"><span id="bulkn">0 selected</span>
<button class="mini hot" onclick="openCompose()" title="Combine selected assets into a new one">✦ Compose new</button>
<button class="mini" onclick="bulk('approved')">Approve</button>
<button class="mini" onclick="bulk('published')">Publish</button>
<button class="mini" onclick="bulkFork()">Fork / redo</button>
<button class="mini" onclick="bulk('deleted')">Delete</button>
<button class="mini" onclick="clearSel()">Cancel</button>
</div>
<section id="templates" hidden><div class="grid" id="tgall"></div></section>
<section id="pipelines" hidden><div class="grid" id="wfgrid"></div></section>
<section id="runs" hidden>
<div id="qhead" class="qhead"></div>
<div class="qcol"><h3 class="qh">🗂 Your work
<input class="search" id="wlq" placeholder="Search prompts…" oninput="worklog()" style="margin-left:10px"></h3>
<div class="qlist" id="wllist"></div></div>
<div class="qcol"><h3 class="qh">▶ Now running <span id="qrunN" class="cnt">0</span></h3><div class="qlist" id="qrun"></div></div>
<div class="qcol"><h3 class="qh">⏳ Up next <span id="qpendN" class="cnt">0</span> <button class="mini" onclick="clearQueue()" style="margin-left:8px">Clear all</button></h3><div class="qlist" id="qpend"></div></div>
<div class="qcol"><h3 class="qh">✓ History — rate &amp; note for better future generations</h3><div class="grid" id="rgrid"></div></div>
</section>
</main>
<div id="reftray">
<div style="font-size:.72rem;color:var(--dim);text-transform:uppercase;letter-spacing:.05em">Next generation</div>
<div id="refthumbs" style="display:flex;gap:6px;align-items:center"></div>
<input id="refprompt" placeholder="Describe what to make from these…" style="background:#0f0f12;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:7px 11px;font:inherit;font-size:.82rem;width:280px">
<button class="mini hot" onclick="genFromTray()">✦ Generate</button>
<button class="mini" onclick="clearTray()">Clear</button>
</div>
<div id="toast"></div>
<div id="zoom" onclick="this.style.display='none'"><img id="zoomimg"></div>
<dialog id="fixdlg">
<h3 id="fixtitle" style="font-size:1rem">Fix with chat</h3>
<p id="fixdesc" style="color:var(--dim);font-size:.83rem;margin-top:6px">Describe the change — a new render is queued from this image. Add reference photos (drag in, or pick from your history) to guide it.</p>
<div class="row" id="fixrefs" style="margin:12px 0 0;flex-wrap:wrap"></div>
<div class="drop" id="fixdrop">Drag images here, or <b>click to upload</b> references</div>
<input id="fixfile" type="file" accept="image/*" multiple hidden>
<textarea id="fixtext" placeholder="e.g. make the straps thicker, smooth the skin"></textarea>
<div id="gpuwarn" style="display:none;color:var(--warn);font-size:.78rem;margin:-4px 0 8px"></div>
<div id="fixerr" class="dlgerr"></div>
<div class="row" style="justify-content:space-between">
<button class="mini" id="fixhistbtn" onclick="toggleFixHistory()">+ From history</button>
<div class="row"><button class="mini" onclick="fixdlg.close()">Cancel</button><button class="mini hot" onclick="sendFix()">Queue fix</button></div>
</div>
<div id="fixhist" style="display:none;max-height:210px;overflow:auto;margin-top:10px;border-top:1px solid var(--line);padding-top:10px"></div>
</dialog>
<dialog id="tmpldlg">
<h3 style="font-size:1rem">★ Save as template</h3>
<p style="color:var(--dim);font-size:.83rem;margin-top:6px">Turn this favorited generation into a reusable template — its workflow becomes a one-click KIND you can run again with new inputs.</p>
<label style="display:block;font-size:.72rem;color:var(--dim);margin:12px 0 4px;text-transform:uppercase;letter-spacing:.04em">Template name</label>
<input id="tmplname" style="width:100%;background:#0f0f12;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:9px 11px;font:inherit" placeholder="e.g. Karma ghost mannequin — white studio">
<label style="display:block;font-size:.72rem;color:var(--dim);margin:12px 0 4px;text-transform:uppercase;letter-spacing:.04em">What it makes</label>
<input id="tmpldesc" style="width:100%;background:#0f0f12;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:9px 11px;font:inherit" placeholder="e.g. Product on invisible form, real fabric">
<div id="tmplsteps" style="color:var(--dim);font-size:.78rem;margin-top:12px;line-height:1.6"></div>
<div class="row" style="justify-content:flex-end;margin-top:8px"><button class="mini" onclick="tmpldlg.close()">Cancel</button><button class="mini hot" onclick="saveTemplate()">Save template</button></div>
</dialog>
<dialog id="ctxdlg">
<div class="row"><h3 id="ctxtitle" style="font-size:1rem;flex:1">Work item</h3><button class="mini" onclick="ctxdlg.close()">Close</button></div>
<div id="ctxbody" style="margin-top:6px"></div>
<div class="row" style="justify-content:flex-end;margin-top:14px;gap:6px">
<button class="mini" id="ctxcancel" onclick="cancelCtx()" style="display:none">✕ Cancel job</button>
<button class="mini" id="ctxedit" onclick="editCtx()" style="display:none">✎ Edit</button>
<button class="mini" id="ctxreuse" onclick="reuseCtx()" title="Open Fix primed with this prompt + references">↻ Reuse context</button>
<button class="mini hot" id="ctxfix" onclick="fixCtx()">Fix this version</button>
</div>
</dialog>
<dialog id="compdlg">
<h3 style="font-size:1rem">✦ Compose new from <span id="compn">0</span> assets</h3>
<p style="color:var(--dim);font-size:.83rem;margin-top:6px">Explain HOW to use the selected assets — e.g. "put the garment from the first onto the model in the second, keep the second's lighting."</p>
<div class="row" id="compthumbs" style="margin:10px 0 0;flex-wrap:wrap"></div>
<textarea id="comptext" placeholder="e.g. combine these — garment from #1 on the model + pose from #2, studio white background"></textarea>
<div id="gpuwarn2" style="display:none;color:var(--warn);font-size:.78rem;margin:-4px 0 8px"></div>
<div class="row" style="justify-content:flex-end"><button class="mini" onclick="compdlg.close()">Cancel</button><button class="mini hot" onclick="sendCompose()">Generate</button></div>
</dialog>
<script>
// ── Template / KIND catalog — everything Studio supports ─────────────────────
const TEMPLATES=[
// ── Fashion / commerce (the proven karma workflows, systematized) ──
{k:'cad_ghost', t:'CAD → Ghost mannequin', d:'Real fabric, invisible form', ic:'👕', wf:'cad_ghost', grp:'Fashion'},
{k:'outfit', t:'Outfit switcher', d:'Swap clothes, keep model+light', ic:'🔁', wf:'outfit', grp:'Fashion'},
{k:'product', t:'Product photography', d:'Ecom front·side·back', ic:'🛍', wf:'product', grp:'Fashion'},
{k:'lifestyle', t:'Lifestyle photography', d:'Same model/3D, in scene', ic:'🌅', wf:'lifestyle', grp:'Fashion'},
{k:'cad_mockup',t:'CAD → mockup', d:'Fashion / packaging', ic:'✳️', wf:'cad_mockup', grp:'Fashion'},
{k:'cad_3d', t:'CAD → 3D + drape', d:'Multi-view → 3D, fabric drape',ic:'🧊', wf:'cad_3d', grp:'Fashion'},
// ── Media ──
{k:'video', t:'Video', d:'Wan · LTX · Hunyuan', ic:'🎬', wf:'video', grp:'Media'},
{k:'music', t:'Music', d:'ACE-Step · song', ic:'🎵', wf:'music', grp:'Media'},
{k:'voice', t:'Voice', d:'Narration · TTS', ic:'🎙', wf:'voice', grp:'Media'},
// ── Marketing ──
{k:'social', t:'Social / Ad', d:'Posts · ads · campaigns', ic:'📣', wf:'social', grp:'Marketing'},
{k:'email', t:'Marketing email', d:'HTML / MJML', ic:'✉️', wf:'email', grp:'Marketing'},
{k:'deck', t:'Deck', d:'Sales · investor', ic:'📊', wf:'deck', grp:'Marketing'},
// ── Documents ──
{k:'doc', t:'Document', d:'Word · guide', ic:'📄', wf:'doc', grp:'Documents'},
{k:'sheet', t:'Spreadsheet', d:'xlsx · data', ic:'🧮', wf:'sheet', grp:'Documents'},
{k:'paper', t:'Paper (LaTeX)', d:'Academic · PDF', ic:'📐', wf:'paper', grp:'Documents'},
// ── Web ──
{k:'site', t:'Website / App', d:'From hanzo.app templates', ic:'🖥', wf:'site', grp:'Web'},
];
let DATA={assets:[],workflows:[]},FILTER='all';
const $=id=>document.getElementById(id);
const toast=m=>{const t=$('toast');t.textContent=m;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),3000)};
const imgURL=a=>`/v1/library/file?path=${encodeURIComponent(a.path)}`;
const thumbURL=a=>`/v1/library/file?thumb=1&path=${encodeURIComponent(a.path)}`;
function tcard(x){return `<div class="tcard" onclick="pickTemplate('${x.k}','${x.t.replace(/'/g,"")}')"><div class="ic">${x.ic}</div><div class="tl">${x.t}</div><div class="td">${x.d}</div></div>`;}
function tab(name){document.querySelectorAll('.tabs button').forEach(b=>b.classList.toggle('on',b.dataset.tab===name));
for(const s of ['assets','templates','pipelines','runs'])$(s).hidden=s!==name;
if(name==='runs')runs(); if(name==='pipelines')pipes();}
function pickTemplate(k,t){$('tmpl').textContent=t; tab('assets'); $('pin').focus(); toast('Template: '+t);}
function pickPreset(){toast('Design systems / presets — coming in the preset library');}
function create(){const p=$('pin').value.trim(); const t=$('tmpl').textContent;
if(!p&&t==='None'){toast('Describe what to create, or pick a template');return;}
toast('Creation flow wires copilot → exec/tasks (HIP-0506 phase 1)');
// real path once phase 1 lands: POST /v1/studio/create {prompt,template,design_system}
}
async function load(){
const r=await fetch('/v1/library'); DATA=await r.json(); $('org').textContent=DATA.org||'';
$('tcards').innerHTML=TEMPLATES.slice(0,7).map(tcard).join('');
// Templates tab: grouped by category
const grps=[...new Set(TEMPLATES.map(t=>t.grp))];
$('tgall').innerHTML=grps.map(g=>`<div style="grid-column:1/-1;color:var(--dim);font-size:.8rem;margin:8px 0 2px">${g}</div>`+
TEMPLATES.filter(t=>t.grp===g).map(tcard).join('')).join('');
const groups=[...new Set(DATA.assets.map(a=>a.design).filter(Boolean))].sort();
const delN=DATA.assets.filter(a=>a.status==='deleted').length;
const chips=['all','marketing',...groups];
$('filters').innerHTML=chips.map(g=>`<span class="chip ${g===FILTER?'on':''}" onclick="FILTER='${g}';render()">${g}</span>`).join('')
+`<span class="chip ${FILTER==='deleted'?'on':''}" onclick="FILTER='deleted';render()" style="margin-left:auto">🗑 Deleted${delN?' ('+delN+')':''}</span>`;
render();
}
let SELMODE=false; const SEL=new Set();
function toggleSel(){SELMODE=!SELMODE;$('selbtn').classList.toggle('brand',SELMODE);if(!SELMODE)SEL.clear();updateBulk();render();}
function clearSel(){SEL.clear();SELMODE=false;$('selbtn').classList.remove('brand');updateBulk();render();}
function updateBulk(){$('bulkbar').classList.toggle('on',SELMODE&&SEL.size>0);$('bulkn').textContent=SEL.size+' selected';}
function pick(ev,path){ev.stopPropagation();if(SEL.has(path))SEL.delete(path);else SEL.add(path);updateBulk();render();}
function render(){
const q=($('q').value||'').toLowerCase();
const showDel=FILTER==='deleted';
let rows=DATA.assets.filter(a=>{
if(showDel)return a.status==='deleted';
if(a.status==='deleted')return false; // hide trashed from normal views
return FILTER==='all'?true:FILTER==='marketing'?(a.path||'').startsWith('marketing/'):a.design===FILTER;
});
if(q)rows=rows.filter(a=>(a.path||'').toLowerCase().includes(q));
$('agrid').innerHTML=rows.map(a=>{
const i=DATA.assets.indexOf(a);
const img=/\.(png|jpe?g|webp)$/i.test(a.path||'');
const prov=a.prov&&a.prov.workflow?` · ${a.prov.workflow}`:'';
const picked=SEL.has(a.path);
const cardClick=SELMODE?`onclick="pick(event,'${a.path.replace(/'/g,"\\'")}')"`:'';
const inTray=trayHasLib(a.path);
const pe=a.path.replace(/'/g,"\\'");
return `<div class="card ${SELMODE?'sel-mode':''} ${picked?'picked':''}" ${cardClick}>
<div class="pick">${picked?'✓':''}</div>
${img&&!SELMODE?`<button class="addref ${inTray?'on':''}" title="Add to next generation" onclick="event.stopPropagation();addRef('${pe}')">${inTray?'✓':'+'}</button>`:''}
${img&&!SELMODE&&!showDel?`<button class="delx" title="Move to Deleted (recoverable)" onclick="event.stopPropagation();quickDel('${pe}')">🗑</button>`:''}
${img?`<div class="im" style="background-image:url('${thumbURL(a)}')" ${SELMODE?'':`onclick="zoom('${imgURL(a)}')"`}></div>`
:`<div class="doc">${(a.path||'').split('/').pop()}</div>`}
<div class="meta">
<span class="t">${(a.path||'').split('/').pop()}</span>
<span class="sub">${a.design||a.kind||''}${prov}</span>
<div class="row"><span class="st ${a.status}">${a.status}</span></div>
${SELMODE?'':`<div class="row tools">
${showDel?`<button class="mini" onclick="setSt(${i},'draft')">Recover</button>`:`
${a.status!=='approved'?`<button class="mini" onclick="setSt(${i},'approved')">Approve</button>`:''}
${a.status!=='published'?`<button class="mini" onclick="setSt(${i},'published')">Publish</button>`:''}
${img?`<button class="mini" onclick="rerun(${i})">Re-run</button><button class="mini" onclick="openLineage('${pe}')" title="Show this image's versions (what it came from + later fixes)">Versions</button><button class="mini hot" onclick="openFix(${i})">Fix</button>`:''}
<button class="mini" onclick="setSt(${i},'deleted')" title="Move to Deleted (recoverable)">🗑</button>`}
</div>`}
</div></div>`;}).join('')||`<div class="empty">${showDel?'Deleted is empty.':'No assets in this view yet.'}</div>`;
}
async function bulk(status){
if(!SEL.size)return;
if(status==='deleted'&&!confirm('Move '+SEL.size+' asset(s) to Deleted? (recoverable)'))return;
const paths=[...SEL];
for(const p of paths){await fetch('/v1/library/status',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:p,status})});
const a=DATA.assets.find(x=>x.path===p);if(a)a.status=status;}
toast(paths.length+' → '+status); clearSel();
}
async function bulkFork(){ if(!SEL.size)return; toast('Fork/redo '+SEL.size+' — re-running each with a fresh seed');
for(const p of [...SEL]){await fetch('/v1/library/rerun',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:p})});}
clearSel(); }
// ── Shared image-upload lane (ONE implementation) ────────────────────────────
// Every "add a photo" surface (composer "+", Fix dialog, drag/drop, paste) funnels
// through here → POST /upload/image, which lands the file in the caller's org input
// dir (multi-tenant) and returns its name. Errors are RETURNED, never swallowed, so
// every caller can SHOW them (no-silent-swallow rule).
function errMsg(j){ if(!j)return ''; const e=j.error; return (e&&(e.message||e))||j.message||''; }
async function uploadImage(file){
if(!file||!file.type||!file.type.startsWith('image/'))return {error:'not an image file'};
const fd=new FormData(); fd.append('image',file,file.name||'upload.png');
try{
const r=await fetch('/upload/image',{method:'POST',body:fd});
let j={}; try{j=await r.json();}catch(_){}
if(!r.ok)return {error:errMsg(j)||('upload failed ('+r.status+')')};
if(!j.name)return {error:'upload failed (no name returned)'};
return {name:j.name};
}catch(e){ return {error:'upload failed ('+((e&&e.message)||e)+')'}; }
}
async function uploadMany(files,onName,onErr){ for(const f of [...files]){ const r=await uploadImage(f); if(r.name)onName(r.name); else onErr(r.error); } }
function wireDrop(el,onFiles){
['dragenter','dragover'].forEach(ev=>el.addEventListener(ev,e=>{e.preventDefault();el.classList.add('drag');}));
['dragleave','dragend'].forEach(ev=>el.addEventListener(ev,e=>{e.preventDefault();el.classList.remove('drag');}));
el.addEventListener('drop',e=>{e.preventDefault();el.classList.remove('drag');if(e.dataTransfer&&e.dataTransfer.files&&e.dataTransfer.files.length)onFiles(e.dataTransfer.files);});
}
function wirePaste(el,onFiles){ el.addEventListener('paste',e=>{const items=(e.clipboardData&&e.clipboardData.items)||[]; const fs=[...items].filter(i=>i.kind==='file').map(i=>i.getAsFile()).filter(Boolean); if(fs.length){e.preventDefault();onFiles(fs);}}); }
const inputThumb=name=>`/v1/library/file?input=1&path=${encodeURIComponent(name)}`;
// ── Reference tray: "+" on any photo / composer attach → next generation ──────
// Holds mixed refs: {kind:'lib', ref:<library path>} and {kind:'up', ref:<input name>}.
let TRAY=[];
const trayHasLib=path=>TRAY.some(r=>r.kind==='lib'&&r.ref===path);
function addRef(path){ const i=TRAY.findIndex(r=>r.kind==='lib'&&r.ref===path); if(i>=0)TRAY.splice(i,1); else{ if(TRAY.length>=5){toast('Up to 5 references');return;} TRAY.push({kind:'lib',ref:path,thumb:thumbURL({path})}); } drawTray(); render(); }
function addTrayUpload(name){ if(TRAY.length>=5){toast('Up to 5 references');return;} TRAY.push({kind:'up',ref:name,thumb:inputThumb(name)}); drawTray(); }
function removeTrayRef(i){ TRAY.splice(i,1); drawTray(); render(); }
function clearTray(){ TRAY=[]; drawTray(); render(); }
function drawTray(){
$('reftray').classList.toggle('on',TRAY.length>0);
$('refthumbs').innerHTML=TRAY.map((r,i)=>`<span style="position:relative;display:inline-block">
<img class="rt" src="${r.thumb}" title="${r.ref}">
<button onclick="removeTrayRef(${i})" title="Remove" style="position:absolute;top:-6px;right:-6px;width:16px;height:16px;border-radius:50%;border:none;background:#c0392b;color:#fff;font-size:.62rem;line-height:1;cursor:pointer">×</button></span>`).join('');
}
function attachPick(){ $('attachfile').click(); }
async function attachFiles(files){ await uploadMany(files, addTrayUpload, err=>toast('Upload failed: '+err)); }
async function genFromTray(){
const t=$('refprompt').value.trim();
if(!TRAY.length){toast('Add photos with + first');return;}
if(t.length<3){toast('Describe what to make');return;}
if(!(await gpuOnline()))toast('⚠ No GPU online — queues until a BYO-GPU connects');
const libs=TRAY.filter(r=>r.kind==='lib').map(r=>r.ref), ups=TRAY.filter(r=>r.kind==='up').map(r=>r.ref);
let r;
if(libs.length+ups.length===1){ // single ref → edit (library or uploaded base)
r=await fetch('/v1/library/fix',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify(libs.length?{path:libs[0],instruction:t}:{upload:ups[0],instruction:t})});
}else{ // 2-5 refs → compose
r=await fetch('/v1/library/compose',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths:libs,uploads:ups,instruction:t})});
}
const j=await r.json().catch(()=>({}));
if(r.ok){ toast('Queued → see Queue & History'); $('refprompt').value=''; clearTray(); }
else toast('Failed: '+(errMsg(j)||('error '+r.status)));
}
// ── GPU status: fix/compose need an ONLINE GPU worker (Qwen models live there) ──
let GPU_ONLINE=null, GPU_TS=0;
async function gpuOnline(){
// Check the FLEET (same source dispatch uses), not /v1/engines — a `hanzo gpu
// connect` box registers with the fleet, so /v1/engines missed it and the UI
// wrongly said "connect a GPU". Cache 15s so we don't hammer it.
if(GPU_ONLINE!==null && Date.now()-GPU_TS<15000)return GPU_ONLINE;
try{const s=await (await fetch('/v1/gpu-status')).json(); GPU_ONLINE=!!s.online;}
catch(_){GPU_ONLINE=false;}
GPU_TS=Date.now(); return GPU_ONLINE;
}
async function openCompose(){
if(SEL.size<2){toast('Select 2+ assets to compose');return;}
const paths=[...SEL]; $('compn').textContent=paths.length;
$('compthumbs').innerHTML=paths.map((p,i)=>`<div style="text-align:center"><img src="/v1/library/file?thumb=1&path=${encodeURIComponent(p)}" style="width:64px;height:96px;object-fit:cover;border-radius:6px;border:1px solid var(--line)"><div style="font-size:.62rem;color:var(--dim)">#${i+1}</div></div>`).join('');
$('comptext').value='';
$('gpuwarn2').style.display=(await gpuOnline())?'none':'block';
$('gpuwarn2').textContent='⚠ No GPU worker online — this queues but renders only when a BYO-GPU (e.g. spark) is connected.';
$('compdlg').showModal();
}
async function sendCompose(){
const t=$('comptext').value.trim(); if(t.length<3){toast('Describe how to combine them');return;}
const paths=[...SEL]; toast('Composing from '+paths.length+' assets…');
const r=await fetch('/v1/library/compose',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths,instruction:t})});
const j=await r.json().catch(()=>({}));
if(r.ok){ $('compdlg').close(); toast(`Compose queued (${j.refs} refs) → see Queue & History`); clearSel(); }
else{ $('gpuwarn2').style.display='block'; $('gpuwarn2').textContent='Couldnt queue: '+(errMsg(j)||('error '+r.status)); }
}
function pipes(){ $('wfgrid').innerHTML=(DATA.workflows||[]).map(w=>`<div class="card"><div class="doc">⚙︎ ${w}</div><div class="meta"><div class="row"><a class="mini" href="/?advanced=1&workflow=${encodeURIComponent(w)}">Edit</a><a class="mini hot" href="/?advanced=1&workflow=${encodeURIComponent(w)}&run=1">Run</a></div></div></div>`).join('')||'<div class="empty">No saved workflows for this org yet.</div>'; }
// ── YouTube-style Queue & History ───────────────────────────────────────────
let RATINGS={};
const vURL=im=>`/view?filename=${encodeURIComponent(im.filename)}&subfolder=${encodeURIComponent(im.subfolder||'')}&type=${im.type||'output'}`;
const promptTitle=v=>{ // pull the SaveImage prefix / positive text from the run's graph
const g=(v.prompt&&v.prompt[2])||{};
for(const n of Object.values(g)){if(n&&n.class_type==='SaveImage')return (n.inputs.filename_prefix||'render').split('/').pop();}
return 'render';
};
// ── Work log: the org's dispatched renders with real status + full context ────
let WL=[], CTX=null;
// live queue status (positions/ETAs/node) computed SERVER-SIDE; QLOCAL seeds the
// client-side elapsed tick between polls. NODES = the org's GPU nodes for badges.
let QST={items:{},medians:{},lanes:{},now:0}, QLOCAL=Date.now(), NODES=[];
const WSTATUS={queued:['⏳','queued'],running:['●','running'],done:['✓','done'],failed:['⚠','failed'],cancelled:['✕','cancelled']};
const fmtDur=s=>{ s=Math.max(0,Math.round(s)); if(s<60)return s+'s'; const m=Math.floor(s/60),r=s%60; if(s<3600)return m+'m'+(r?(' '+r+'s'):''); return Math.floor(s/3600)+'h '+Math.floor((s%3600)/60)+'m'; };
const fmtEta=s=>{ s=Math.max(0,Math.round(s)); return s<60?('~'+s+'s est'):('~'+Math.round(s/60)+' min est'); };
function wlThumb(it){ if(it.output)return thumbURL({path:it.output});
if(it.refs&&it.refs[0])return inputThumb(it.refs[0]);
if(it.parents&&it.parents[0])return thumbURL({path:it.parents[0]}); return ''; }
function qmetaHTML(it,e){
if(!e)return '';
const node=`<span class="wnode">⚙ ${(e.node||it.node||'gpu')}</span>`;
if(e.status==='running'){
const el=(e.elapsed_seconds||0)+(Date.now()-QLOCAL)/1000;
return `<div class="wmeta">${node} · #${e.position}/${e.of} · running <span id="el-${it.id}">${fmtDur(el)}</span>${e.eta_seconds!=null?(' · ~'+fmtDur(e.eta_seconds)+' left'):''}</div>`;
}
return `<div class="wmeta">${node} · #${e.position}/${e.of} in line · ${fmtEta(e.eta_seconds||0)}</div>`;
}
function wlRow(it){
const e=QST.items[it.id]; // live status wins over the stored one
const status=(e&&e.status)||it.status;
const [ic,lbl]=WSTATUS[status]||['·',status||''];
const th=wlThumb(it), when=it.ts?new Date(it.ts*1000).toLocaleString():'';
const active=status==='queued'||status==='running';
return `<div class="qrow" style="cursor:pointer" onclick="openContext('${it.id}')">
<div class="qthumb" style="${th?`background-image:url('${th}');background-size:cover;background-position:center`:'display:flex;align-items:center;justify-content:center;color:var(--dim)'}">${th?'':'🗂'}</div>
<div class="qtitle">${it.kind} · <span class="wst ${status}">${ic} ${lbl}</span>
<div class="qdesc">${(it.prompt||'(no prompt)').replace(/</g,'&lt;')}</div>
${active&&e?qmetaHTML(it,e):`<div class="qsub">${when}${it.node?(' · ⚙ '+it.node):''}${it.error?` · <span style="color:#ff8f8f">${it.error.replace(/</g,'&lt;')}</span>`:''}</div>`}</div>
${active?`<button class="mini" onclick="event.stopPropagation();openAmend('${it.id}')" title="Amend prompt/refs (re-queues)">Edit</button><button class="mini" onclick="event.stopPropagation();cancelJob('${it.id}')">Cancel</button>`:''}
<button class="mini" onclick="event.stopPropagation();openContext('${it.id}')">Open</button></div>`;
}
// server does the position/ETA math over the org's OWN jobs; the client only renders.
async function loadQueueStatus(){ let d; try{d=await (await fetch('/v1/queue/status')).json();}catch(_){d=null;} if(d){QST=d;QLOCAL=Date.now();} }
async function loadNodes(){ try{const d=await (await fetch('/v1/nodes')).json(); NODES=d.nodes||[];}catch(_){NODES=[];} renderQhead(); }
function renderQhead(){
if(!$('qhead'))return;
const badges=(NODES||[]).map(n=>`<span class="nodebadge ${n.online?'on':'off'}"><span class="dot"></span>${n.name}${n.online?'':' · offline'}</span>`).join('')
+`<span class="nodebadge on"><span class="dot"></span>studio pod</span>`;
const m=QST.medians||{}, ks=Object.keys(m);
const ms=ks.length?('median '+ks.map(k=>k+' ~'+fmtDur(m[k])).join(' · ')):'';
$('qhead').innerHTML=badges+(ms?`<span class="mstat">${ms}</span>`:'');
}
async function refreshQueue(){
const before=Object.keys(QST.items||{});
await loadQueueStatus(); await worklog(); renderQhead();
let fin=0; before.forEach(id=>{ if(!QST.items[id]){ const w=WL.find(x=>x.id===id); if(w&&w.status==='done')fin++; } });
if(fin>0)toast('✓ '+fin+' render'+(fin>1?'s':'')+' finished — in your library');
}
async function worklog(){
const q=($('wlq')&&$('wlq').value||'').trim();
let d={items:[]}; try{d=await (await fetch('/v1/worklog'+(q?('?q='+encodeURIComponent(q)):''))).json();}catch(_){}
WL=d.items||[];
$('wllist').innerHTML=WL.map(wlRow).join('')||'<div class="qsub" style="padding:6px 2px">No work yet — your renders will appear here with full context.</div>';
}
function pv(url,label,click){ return `<div style="text-align:center">
<img src="${url}" ${click?`onclick="${click}" style="cursor:pointer;`:'style="'}width:60px;height:88px;object-fit:cover;border-radius:6px;border:1px solid var(--line)">
<div style="font-size:.58rem;color:var(--dim);margin-top:2px">${label}</div></div>`; }
function ctxHTML(it,lin){
const parents=(it.parents||[]).map(p=>pv(thumbURL({path:p}),'from',`openLibItem('${p.replace(/'/g,"\\'")}')`)).join('');
const refs=(it.refs||[]).map(n=>pv(inputThumb(n),(it.uploads||[]).includes(n)?'upload':'ref')).join('');
const result=it.output?pv(thumbURL({path:it.output}),'result',`openLibItem('${it.output.replace(/'/g,"\\'")}')`):'';
const params=it.params&&Object.keys(it.params).length?Object.entries(it.params).map(([k,v])=>k+':'+v).join(' · '):'';
const trail=(it.events||[]).map(e=>`${e.s}${e.ts?(' '+new Date(e.ts*1000).toLocaleTimeString()):''}${e.note?(' — '+e.note.replace(/</g,'&lt;')):''}`).join(' → ');
const chain=[...lin.ancestors.map(a=>[a,a.kind||'ancestor']),...(it.output?[[{path:it.output},'● this']]:[]),...lin.descendants.map(d=>[d,d.kind||'later'])];
const chainHTML=chain.length>1?`<div class="ctxlabel">Versions</div><div style="display:flex;gap:8px;overflow-x:auto;padding-bottom:4px">${chain.map(([n,role])=>pv(thumbURL({path:n.path}),role,role==='● this'?'':`openLibItem('${(n.path||'').replace(/'/g,"\\'")}')`)).join('')}</div>`:'';
return `${it.prompt?`<div class="ctxlabel">Prompt</div><div style="font-size:.86rem;background:#0f0f12;border:1px solid var(--line);border-radius:8px;padding:9px 11px">${it.prompt.replace(/</g,'&lt;')}</div>`:''}
${(parents||refs||result)?`<div class="ctxlabel">References &amp; result</div><div class="row" style="flex-wrap:wrap">${parents}${refs}${result}</div>`:''}
<div class="ctxlabel">Workflow</div><div style="font-size:.82rem;color:var(--dim)">${it.kind} · ${it.status||''}${it.lane&&it.lane!=='local'?(' · '+it.lane):''}${params?(' · '+params):''}</div>
${trail?`<div class="ctxlabel">Process</div><div style="font-size:.78rem;color:var(--dim)">${trail}</div>`:''}
${it.error?`<div class="dlgerr" style="display:block">${it.error.replace(/</g,'&lt;')}</div>`:''}${chainHTML}`;
}
async function lineageFor(path){ if(!path)return {ancestors:[],descendants:[],node:''}; try{return await (await fetch('/v1/worklog/lineage?path='+encodeURIComponent(path))).json();}catch(_){return {ancestors:[],descendants:[],node:path};} }
async function openContext(id){
const it=WL.find(x=>x.id===id); if(!it)return; CTX=it;
$('ctxtitle').textContent=it.kind+' — '+(it.status||'');
$('ctxbody').innerHTML=ctxHTML(it, await lineageFor(it.output));
const hasBase=!!(it.output||(it.parents&&it.parents[0]));
const live=it.status==='queued'||it.status==='running';
$('ctxreuse').style.display=hasBase?'':'none';
$('ctxfix').style.display=hasBase?'':'none';
$('ctxcancel').style.display=live?'':'none';
$('ctxedit').style.display=live?'':'none';
$('ctxdlg').showModal();
}
function cancelCtx(){ if(!CTX)return; ctxdlg.close(); cancelJob(CTX.id); }
function editCtx(){ if(!CTX)return; ctxdlg.close(); openAmend(CTX.id); }
async function openLineage(path){
CTX={output:path,parents:[],refs:[],uploads:[],prompt:'',kind:'asset',status:''};
$('ctxtitle').textContent='Versions'; $('ctxbody').innerHTML=ctxHTML(CTX, await lineageFor(path));
$('ctxreuse').style.display='none'; $('ctxfix').style.display=''; $('ctxdlg').showModal();
}
function openLibItem(path){ ctxdlg.close(); zoom(imgURL({path})); }
async function primeFix(base,prompt,libRefs,upRefs){
await openFixPath(base,prompt);
(libRefs||[]).filter(p=>p&&p!==base).forEach(p=>{ if(FIXREFS.length<MAXFIXREFS)FIXREFS.push({kind:'lib',ref:p,thumb:thumbURL({path:p})}); });
(upRefs||[]).forEach(n=>{ if(FIXREFS.length<MAXFIXREFS)FIXREFS.push({kind:'up',ref:n,thumb:inputThumb(n)}); });
drawFixRefs();
}
function reuseCtx(){ if(!CTX)return; const base=CTX.output||(CTX.parents&&CTX.parents[0]); if(!base){toast('No base image to reuse');return;} ctxdlg.close(); primeFix(base,CTX.prompt||'',CTX.parents||[],CTX.uploads||[]); }
function fixCtx(){ if(!CTX)return; const base=CTX.output||(CTX.parents&&CTX.parents[0]); if(!base){toast('No result to fix yet');return;} ctxdlg.close(); openFixPath(base,''); }
async function runs(){
refreshQueue(); loadNodes();
RATINGS=await (await fetch('/v1/library/ratings')).json().catch(()=>({}));
// live queue
let q={queue_running:[],queue_pending:[]}; try{q=await (await fetch('/queue')).json();}catch(_){}
const qr=q.queue_running||[], qp=q.queue_pending||[];
$('qrunN').textContent=qr.length; $('qpendN').textContent=qp.length;
// YouTube-style up-next row: reference thumbnail + title + brief description.
const qitem=(it,running,pos)=>{
const id=it[1], g=it[2]||{};
const nodes=Object.values(g);
const title=(nodes.find(n=>n.class_type==='SaveImage')?.inputs?.filename_prefix||id+'').split('/').pop();
// reference image(s) attached to this job (fix/compose sources live in input dir)
const refs=nodes.filter(n=>n.class_type==='LoadImage').map(n=>n.inputs?.image).filter(Boolean);
const thumb=refs.length?`/v1/library/file?thumb=1&input=1&path=${encodeURIComponent(refs[0])}`:'';
// brief description = the positive prompt / instruction
let desc='';
for(const n of nodes){ if(n.class_type&&n.class_type.indexOf('TextEncode')>=0){ const t=n.inputs?.prompt||n.inputs?.text||''; if(t&&!/blurry|deformed|watermark|ghosting/i.test(t.slice(0,40))){desc=t;break;} } }
desc=(desc||'').replace(/\.\s*(Photorealistic|Combine|keep the same).*$/i,'').trim().slice(0,140);
const eta=running?'rendering now':(pos!=null?`~${(pos+1)*ETA_MIN} min · #${pos+1} in line`:'queued');
return `<div class="qrow">
<div class="qthumb" style="${thumb?`background-image:url('${thumb}');background-size:cover;background-position:center`:'display:flex;align-items:center;justify-content:center;color:var(--dim)'}">${thumb?'':(running?'▶':'⏳')}</div>
<div class="qtitle">${title}<div class="qdesc">${desc||'(no description)'}</div><div class="qsub">${running?'<span style=color:var(--ok)></span>':''}${eta}${refs.length>1?` · ${refs.length} refs`:''}</div></div>
${running?'':`<button class="mini" onclick="cancelJob('${id}')">Remove</button>`}</div>`;};
// BYO-GPU renders (fix/compose on a connected GPU) — rendered on the worker,
// shown here so a dispatched fix is visible (was invisible before).
let gjobs=[]; try{gjobs=(await (await fetch('/v1/render-queue')).json()).jobs||[];}catch(_){}
const gjRow=j=>`<div class="qrow">
<div class="qthumb" style="${j.refs&&j.refs[0]?`background-image:url('/v1/library/file?thumb=1&input=1&path=${encodeURIComponent(j.refs[0])}');background-size:cover;background-position:center`:'display:flex;align-items:center;justify-content:center;color:var(--ok)'}">${j.refs&&j.refs[0]?'':'▶'}</div>
<div class="qtitle">${j.prefix||'render'}<div class="qsub"><span style=color:var(--ok)></span>rendering on ${j.node||'your GPU'} · ${Math.max(0,Math.round((j.age||0)/60))}m elapsed${QST.items[j.id]&&QST.items[j.id].eta_seconds!=null?(' · ~'+fmtDur(QST.items[j.id].eta_seconds)+' left'):''}</div></div>
${j.id?`<button class="mini" onclick="cancelJob('${j.id}')">Cancel</button>`:''}</div>`;
const gjHtml=gjobs.map(gjRow).join('');
$('qrun').innerHTML=gjHtml + qr.map(it=>qitem(it,true,null)).join('') || '<div class="qsub" style="padding:6px 2px">Idle — nothing rendering.</div>';
$('qpend').innerHTML=qp.map((it,i)=>qitem(it,false,i)).join('')||'<div class="qsub" style="padding:6px 2px">Nothing queued.</div>';
// history
const h=await (await fetch('/history?max_items=40')).json();
const items=Object.entries(h).reverse().map(([id,v])=>{const ims=[];for(const o of Object.values(v.outputs||{}))for(const im of (o.images||[]))if(im.type==='output')ims.push(im);
return {id,ok:(v.status||{}).status_str||'?',im:ims[0],title:promptTitle(v)};});
$('rgrid').innerHTML=items.map(r=>{
const rt=RATINGS[r.id]||{}; const st=rt.stars||0;
const stars=[1,2,3,4,5].map(n=>`<span class="${n<=st?'on':''}" onclick="rate('${r.id}',${n})"></span>`).join('');
return `<div class="card">
${r.im?`<div class="im" style="background-image:url('${vURL(r.im)}')" onclick="zoom('${vURL(r.im)}')"></div>`:`<div class="doc">no output</div>`}
<div class="meta">
<span class="t">${r.title}</span>
<div class="row" style="justify-content:space-between">
<span class="stars">${stars}</span>
<span class="fav ${rt.favorite?'on':''}" onclick="toggleFav('${r.id}','${r.title}')" title="Favorite → save as template">★fav</span>
</div>
<textarea class="notebox" placeholder="Notes for the AI to improve next time…" onblur="rateNote('${r.id}',this.value)">${(rt.notes||'').replace(/</g,'&lt;')}</textarea>
</div></div>`;}).join('')||'<div class="empty">No runs yet.</div>';
}
async function clearQueue(){if(!confirm('Clear all queued jobs?'))return;await fetch('/queue',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clear:true})});toast('Queue cleared');runs();}
async function rate(key,stars){await fetch('/v1/library/rate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key,stars})});RATINGS[key]={...(RATINGS[key]||{}),stars};runs();}
async function rateNote(key,notes){await fetch('/v1/library/rate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key,notes})});toast('Note saved — the AI will use it next time');}
let FAVKEY=null,FAVTITLE=null;
async function toggleFav(key,title){const cur=RATINGS[key]||{};const nv=!cur.favorite;
await fetch('/v1/library/rate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key,favorite:nv})});
RATINGS[key]={...cur,favorite:nv};
if(nv){FAVKEY=key;FAVTITLE=title;$('tmplname').value=title;$('tmpldesc').value='';
$('tmplsteps').innerHTML='This favorited run has an embedded workflow. Saving turns it into a reusable KIND:<br>1 · name it &amp; describe what it makes<br>2 · it appears under Templates for your org<br>3 · run it anytime with new inputs';
$('tmpldlg').showModal();}
else runs();
}
async function saveTemplate(){const nm=$('tmplname').value.trim();if(!nm){toast('Name the template');return;}
// Real save lands with the studio-app template store (HIP-0506 phase 3); for now
// persist intent on the rating entry so it's not lost and surfaces the guided flow.
await fetch('/v1/library/rate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key:FAVKEY,notes:'TEMPLATE: '+nm+' — '+$('tmpldesc').value})});
$('tmpldlg').close();toast('★ Saved "'+nm+'" — it will appear under Templates');runs();
}
async function setSt(i,status){const a=DATA.assets[i];const r=await fetch('/v1/library/status',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:a.path,status})});if(r.ok){a.status=status;render();toast(`${a.path.split('/').pop()} → ${status}`);}else toast('Failed');}
// Optimistic trash: mark deleted locally + re-render immediately (feels instant),
// then persist. On failure, revert. This is the always-visible 🗑 on each card.
async function quickDel(path){
const a=DATA.assets.find(x=>x.path===path); if(!a)return;
const prev=a.status; a.status='deleted'; render(); toast('Moved to Deleted');
try{const r=await fetch('/v1/library/status',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path,status:'deleted'})});
if(!r.ok)throw 0;}catch(_){a.status=prev;render();toast('Delete failed — restored');}
}
async function rerun(i){const a=DATA.assets[i];toast('Queueing re-run…');const r=await fetch('/v1/library/rerun',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:a.path})});const j=await r.json();toast(r.ok?`Re-run queued — see Runs`:'Failed');}
// ── Fix surface: base image + up to 4 references (history picks + drag/drop
// uploads). No references → the single-image edit (/v1/library/fix); any
// reference → the multi-input compose (/v1/library/compose) — same proven Qwen
// graph, base image first. 5-ref cap = base + 4. ─────────────────────────────
let FIXPATH=null; // the base image (library asset path)
let FIXREFS=[]; // [{kind:'lib'|'up', ref, thumb}] added references
let FIXMODE='fix'; // 'fix' (new render) | 'amend' (swap a queued job)
let AMENDID=null; // the queued prompt_id when FIXMODE==='amend'
let ORIGREFS=[]; // amend: the job's current refs (input-dir names), locked base
let MAXFIXREFS=4; // added-ref cap = 5 base count (set per open)
function drawFixRefs(){
let base;
if(FIXMODE==='amend'){
base=(ORIGREFS||[]).map(n=>`<div style="text-align:center"><img src="${inputThumb(n)}" style="width:56px;height:84px;object-fit:cover;border-radius:6px;border:2px solid var(--brand)"><div style="font-size:.6rem;color:var(--dim);margin-top:3px">current</div></div>`).join('')
||`<div style="font-size:.72rem;color:var(--dim)">Amending queued job</div>`;
}else{
base=`<div style="text-align:center"><img src="${thumbURL({path:FIXPATH})}" style="width:56px;height:84px;object-fit:cover;border-radius:6px;border:2px solid var(--brand)"><div style="font-size:.6rem;color:var(--dim);margin-top:3px">base</div></div>`;
}
const refs=FIXREFS.map((r,i)=>`<div style="position:relative;text-align:center">
<img src="${r.thumb}" style="width:56px;height:84px;object-fit:cover;border-radius:6px;border:1px solid var(--line)">
<button onclick="removeFixRef(${i})" title="Remove" style="position:absolute;top:-6px;right:-6px;width:18px;height:18px;border-radius:50%;border:none;background:#c0392b;color:#fff;font-size:.72rem;line-height:1;cursor:pointer">×</button>
<div style="font-size:.6rem;color:var(--dim);margin-top:3px">${r.kind==='up'?'upload':'ref'}</div></div>`).join('');
$('fixrefs').innerHTML=base+refs;
}
function removeFixRef(i){FIXREFS.splice(i,1);drawFixRefs();if($('fixhist').style.display!=='none')renderFixHist();}
function toggleFixHistory(){const h=$('fixhist');const show=h.style.display==='none';h.style.display=show?'block':'none';if(show)renderFixHist();}
function renderFixHist(){
const imgs=DATA.assets.filter(a=>/\.(png|jpe?g|webp)$/i.test(a.path||'')&&a.status!=='deleted'&&a.path!==FIXPATH);
$('fixhist').innerHTML=`<div style="font-size:.72rem;color:var(--dim);margin-bottom:8px">Tap a past generation to add it as a reference (max ${MAXFIXREFS}).</div>`
+`<div class="grid" style="grid-template-columns:repeat(auto-fill,minmax(70px,1fr));gap:8px">`
+imgs.map(a=>{const on=FIXREFS.some(r=>r.kind==='lib'&&r.ref===a.path);
return `<img src="${thumbURL(a)}" onclick="addFixHistory('${a.path.replace(/'/g,"\\'")}')" title="${a.path}" style="width:100%;aspect-ratio:2/3;object-fit:cover;border-radius:6px;cursor:pointer;border:2px solid ${on?'var(--brand)':'transparent'}">`;
}).join('')+`</div>`;
}
function addFixHistory(path){
const at=FIXREFS.findIndex(r=>r.kind==='lib'&&r.ref===path);
if(at>=0){FIXREFS.splice(at,1);}
else{ if(FIXREFS.length>=MAXFIXREFS){toast('Up to '+MAXFIXREFS+' references');return;} FIXREFS.push({kind:'lib',ref:path,thumb:thumbURL({path})}); }
drawFixRefs(); renderFixHist();
}
function showFixErr(m){ const e=$('fixerr'); e.textContent=m||''; e.style.display=m?'block':'none'; }
async function uploadFixFiles(files){
for(const f of [...files]){
if(FIXREFS.length>=MAXFIXREFS){toast('Up to '+MAXFIXREFS+' references');break;}
const r=await uploadImage(f);
if(r.name){FIXREFS.push({kind:'up',ref:r.name,thumb:inputThumb(r.name)}); drawFixRefs(); showFixErr('');}
else showFixErr('Upload failed: '+r.error);
}
}
async function openFixPath(path,prompt){FIXMODE='fix';AMENDID=null;ORIGREFS=[];MAXFIXREFS=4;
FIXPATH=path;FIXREFS=[];$('fixtext').value=prompt||'';showFixErr('');
$('fixtitle').textContent='Fix with chat';
$('fixdesc').textContent='Describe the change — a new render is queued from this image. Add reference photos (drag in, or pick from your history) to guide it.';
$('fixhist').style.display='none'; drawFixRefs();
$('gpuwarn').style.display=(await gpuOnline())?'none':'block';
$('gpuwarn').textContent='⚠ No GPU worker online — this queues but renders only when a BYO-GPU (e.g. spark) is connected.';
$('fixdlg').showModal();}
async function openFix(i){ return openFixPath(DATA.assets[i].path,''); }
async function openAmend(id){
const it=WL.find(x=>x.id===id); if(!it){toast('Job not found');return;}
FIXMODE='amend';AMENDID=id;FIXPATH=null;ORIGREFS=it.refs||[];FIXREFS=[];MAXFIXREFS=Math.max(0,5-ORIGREFS.length);
$('fixtext').value=it.prompt||'';showFixErr('');
$('fixtitle').textContent='Edit queued job';
$('fixdesc').textContent='Change the prompt and/or add references. Saving cancels the queued job and re-queues it with the amendment — it re-enters the queue, so its position resets.';
$('fixhist').style.display='none'; drawFixRefs();
$('gpuwarn').style.display='none';
$('fixdlg').showModal();
}
async function sendFix(){const t=$('fixtext').value.trim();if(t.length<3){showFixErr('Describe the change (3+ characters).');return;}
showFixErr(''); toast(FIXMODE==='amend'?'Amending…':'Queueing fix…');
const paths=FIXREFS.filter(x=>x.kind==='lib').map(x=>x.ref);
const uploads=FIXREFS.filter(x=>x.kind==='up').map(x=>x.ref);
let r;
if(FIXMODE==='amend'){ // atomic swap: cancel the queued job + re-dispatch amended
r=await fetch('/v1/queue/amend',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt_id:AMENDID,instruction:t,paths,uploads})});
}else if(!FIXREFS.length){ // single base image → the proven edit path
r=await fetch('/v1/library/fix',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:FIXPATH,instruction:t})});
}else{ // base + references → multi-reference compose
r=await fetch('/v1/library/compose',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths:[FIXPATH,...paths],instruction:t,uploads})});
}
const j=await r.json().catch(()=>({}));
if(r.ok){ $('fixdlg').close(); toast(FIXMODE==='amend'?'Amended — re-queued (position reset)':'Fix queued → see Queue & History'); if(typeof runs==='function')runs(); }
else showFixErr('Couldnt '+(FIXMODE==='amend'?'amend':'queue')+': '+(errMsg(j)||('error '+r.status))); // stay open so the reason is visible
}
async function cancelJob(id){
if(!confirm('Cancel this job?'))return;
const r=await fetch('/v1/queue/cancel',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt_id:id})});
const j=await r.json().catch(()=>({}));
if(r.ok)toast('Cancelled'+(j.canceled==='gpu'?' — if it was already rendering on your GPU it may still finish':''));
else toast('Cancel failed: '+(errMsg(j)||('error '+r.status)));
if(typeof runs==='function')runs();
}
// One shared drag/drop/paste + click-to-upload wiring, applied to every add-photo
// surface: the Fix dialog dropzone AND the composer prompt box + "+" button.
(function(){
const fdrop=$('fixdrop'),ffile=$('fixfile');
fdrop.addEventListener('click',()=>ffile.click());
ffile.addEventListener('change',e=>{uploadFixFiles(e.target.files);e.target.value='';});
wireDrop($('fixdlg'),uploadFixFiles); wirePaste($('fixdlg'),uploadFixFiles);
const af=$('attachfile');
af.addEventListener('change',e=>{attachFiles(e.target.files);e.target.value='';});
const box=document.querySelector('.prompt'); if(box){ wireDrop(box,attachFiles); wirePaste(box,attachFiles); }
})();
function zoom(u){$('zoomimg').src=u;$('zoom').style.display='flex';}
const ta=$('pin'); ta.addEventListener('input',()=>{ta.style.height='auto';ta.style.height=Math.min(ta.scrollHeight,180)+'px';});
async function loadSession(){
let s={}; try{ s=await (await fetch('/v1/session')).json(); }catch(e){ return; }
const nm=(s.user||s.email||'').trim(); $('av').textContent=(nm[0]||'·').toUpperCase(); if(s.org)$('org').textContent=s.org;
$('user').onclick=()=>{ const ex=$('omenu'); if(ex){ex.remove();return;}
const orgs=(s.orgs&&s.orgs.length?s.orgs:[s.org]).filter(Boolean);
const m=document.createElement('div'); m.id='omenu';
m.style.cssText='position:fixed;top:54px;right:24px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:6px;min-width:190px;z-index:30;box-shadow:0 12px 34px #000b';
m.innerHTML=orgs.map(o=>`<div class="omi" data-o="${o}" style="padding:8px 11px;border-radius:8px;cursor:pointer;font-size:.85rem;color:${o===s.org?'var(--txt)':'var(--dim)'}">${o===s.org?'● ':'○ '}${o}</div>`).join('')
+`<div style="height:1px;background:var(--line);margin:5px 4px"></div>`+(s.console_url?`<a href="${s.console_url}" style="display:block;padding:8px 11px;color:var(--dim);text-decoration:none;font-size:.85rem">Console ↗</a>`:'')+`<a href="/logout" style="display:block;padding:8px 11px;color:var(--dim);text-decoration:none;font-size:.85rem">Sign out</a>`;
document.body.appendChild(m);
m.querySelectorAll('.omi').forEach(el=>el.onclick=async()=>{try{await fetch('/v1/session/org',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({org:el.dataset.o})});}catch(e){} location.reload();});
setTimeout(()=>document.addEventListener('click',function h(e){if(!m.contains(e.target)&&!$('user').contains(e.target)){m.remove();document.removeEventListener('click',h);}}),0);
};
}
// ── persistent live-queue bar: always shows what's generating + ETA ──────────
const ETA_MIN=9; // ~min per render on a warm BYO-GPU
async function updateLivebar(){
// Two queues: the pod's local /queue (in-pod renders) AND the org's BYO-GPU
// renders on gpu-jobs (fix/compose/rerun dispatched to a connected GPU). The
// latter is why a queued fix "never showed" — it renders on the worker, not
// the pod. Merge both.
let q={queue_running:[],queue_pending:[]}; try{q=await (await fetch('/queue')).json();}catch(_){}
let rq={jobs:[]}; try{rq=await (await fetch('/v1/render-queue')).json();}catch(_){}
const run=q.queue_running||[], pend=q.queue_pending||[];
const gpuJobs=rq.jobs||[];
const lb=$('livebar'), st=$('lbstatus');
const name=it=>{try{return (Object.values(it[2]).find(n=>n.class_type==='SaveImage')?.inputs?.filename_prefix||'render').split('/').pop();}catch(_){return 'render';}};
if(gpuJobs.length){
lb.classList.remove('idle');
const first=gpuJobs[0], more=gpuJobs.length-1;
st.innerHTML=`<span class="lb-run"><span class="spin"></span>Generating on your GPU: <b>${first.prefix||'render'}</b></span>`
+ (more>0?` <span class="lb-q">· ${more} more</span>`:'')
+ ` <span class="lb-eta">· ~${ETA_MIN} min each</span>`;
} else if(run.length){
lb.classList.remove('idle');
const q1=pend.length;
st.innerHTML=`<span class="lb-run"><span class="spin"></span>Generating: <b>${name(run[0])}</b></span>`
+ (q1?` <span class="lb-q">· ${q1} queued</span>`:'')
+ ` <span class="lb-eta">· ~${ETA_MIN}${q1?`${(q1+1)*ETA_MIN}`:''} min</span>`;
} else if(pend.length){
lb.classList.remove('idle');
st.innerHTML=`<span class="lb-q">⏳ ${pend.length} queued · ~${pend.length*ETA_MIN} min · waiting for a GPU</span>`;
} else {
lb.classList.add('idle');
const gpu=await gpuOnline();
st.innerHTML=`<span class="lb-q">Idle — nothing generating.</span>${gpu?'':' <span class="lb-eta">· connect a GPU to generate</span>'}`;
}
}
// header auto-hide (immersive) — reveal on top-hover / mouse near top
document.getElementById('tophover').addEventListener('mouseenter',()=>document.querySelector('header').classList.add('reveal'));
document.addEventListener('mousemove',e=>{if(e.clientY<8)document.querySelector('header').classList.add('reveal');else if(e.clientY>90&&document.body.classList.contains('immersive'))document.querySelector('header').classList.remove('reveal');});
load(); loadSession(); updateLivebar(); setInterval(updateLivebar, 5000);
// Queue transparency: refresh positions/ETAs while the queue view is open (sane
// cadence, no hammering); node badges less often; tick the running item's elapsed.
setInterval(()=>{ if(!$('runs').hidden)refreshQueue(); }, 6000);
setInterval(()=>{ if(!$('runs').hidden)loadNodes(); }, 20000);
setInterval(()=>{ for(const id in (QST.items||{})){ const e=QST.items[id]; if(e.status==='running'){ const el=$('el-'+id); if(el)el.textContent=fmtDur((e.elapsed_seconds||0)+(Date.now()-QLOCAL)/1000); } } }, 1000);
</script>
+966
View File
@@ -0,0 +1,966 @@
"""Studio Home — the content-forward default view (flow-style), with the node
editor demoted to "Advanced mode".
The normal user works per DESIGN, not per node graph: see the assets a pipeline
produced, judge them (status chips make blotchy rejects vs approved picks
obvious), re-run or chat-fix a given output, and step into the graph editor only
when they mean to. This module adds that surface without inventing any new
storage or lifecycle:
GET /studio the home page (auth-gated like everything else)
GET /v1/library org's library.json + per-asset workflow provenance
POST /v1/library/status {path, status} draft|approved|queued|published
POST /v1/library/rerun {path, count?} re-queue the EXACT embedded graph, new seed
POST /v1/library/fix {path, instruction} chat-fix: Qwen edit pass on that output
POST /v1/library/compose {paths[], uploads[]?, instruction} multi-reference render
(library assets and/or images uploaded via /upload/image)
One source of truth per concern, all pre-existing:
* assets + curation state -> orgs/<org>/output/library.json (library_manifest.py)
* what made an image -> the API graph ComfyUI embeds in the PNG itself
* running work -> the ONE /prompt path (self-POST, so gpu_dispatch,
billing and content_publish all apply unchanged)
* identity -> the caller's own session; headers are forwarded
verbatim on the self-POST, no token is stored
* workflow editing -> the 0.15.1 deep-link opener (?workflow=&run=1)
Env:
STUDIO_HOME_DEFAULT "1"/"true": browser GET / redirects to /studio
(the SPA stays reachable at /?advanced=1 and all
non-root paths). Off by default so local dev and
worker pods are untouched.
"""
from __future__ import annotations
import json
import logging
import os
import random
import re
import time
from pathlib import Path
import aiohttp
from aiohttp import web
import folder_paths
from middleware import worklog
log = logging.getLogger("studio.home")
_HTML = Path(__file__).with_name("studio_home.html")
_STATUSES = ("draft", "approved", "queued", "published", "deleted")
_PROV_CACHE: dict[str, tuple[float, dict]] = {} # abspath -> (mtime, provenance)
_NEG = ("ghosting, double exposure, duplicated body, transparent fabric, sheer "
"fabric, illustration, painterly, blotchy skin, mottled skin, jpeg "
"artifacts, pixelation, noise, deformed hands, extra limbs, text, watermark")
def _home_default() -> bool:
return os.environ.get("STUDIO_HOME_DEFAULT", "").lower() in ("1", "true", "yes")
def _org_of(request: web.Request) -> str:
user = request.get("iam_user") or {}
return user.get("org_id") or os.environ.get("STUDIO_ORG_ID") or "default"
def _library_root(org: str) -> Path | None:
"""The org's asset tree. Pod layout first (orgs/<org>/output via
folder_paths), then the flat-dev staging layout (output/orgs/<org>/output)."""
for cand in (
Path(folder_paths.get_org_output_directory(org)),
Path(folder_paths.get_output_directory()) / "orgs" / org / "output",
):
if (cand / "library.json").is_file():
return cand
return None
def _view_params(abs_path: Path) -> dict:
"""Query params for the existing org-scoped /view endpoint."""
out = Path(folder_paths.get_output_directory())
try:
rel = abs_path.relative_to(out)
except ValueError:
# org-scoped dir outside the flat output dir (pod layout): /view resolves
# the org dir itself in multi-tenant mode, so make the path org-relative.
for org_base in abs_path.parents:
if org_base.name == "output" and org_base.parent.parent.name == "orgs":
rel = abs_path.relative_to(org_base)
break
else:
rel = Path(abs_path.name)
return {"filename": rel.name, "subfolder": str(rel.parent) if str(rel.parent) != "." else "", "type": "output"}
def _provenance(abs_path: Path) -> dict:
"""What made this image, from the graph ComfyUI embeds in the PNG."""
try:
mtime = abs_path.stat().st_mtime
except OSError:
return {}
key = str(abs_path)
hit = _PROV_CACHE.get(key)
if hit and hit[0] == mtime:
return hit[1]
prov: dict = {}
if abs_path.suffix.lower() == ".png":
try:
from PIL import Image
info = Image.open(abs_path).info
graph = json.loads(info.get("prompt", "null"))
if isinstance(graph, dict):
for node in graph.values():
ct = node.get("class_type", "")
ins = node.get("inputs", {})
if ct == "SaveImage":
prov["workflow"] = ins.get("filename_prefix", "")
elif "KSampler" in ct:
prov.update(steps=ins.get("steps"), cfg=ins.get("cfg"),
seed=ins.get("seed", ins.get("noise_seed")))
prov["nodes"] = len(graph)
except Exception: # non-Comfy PNG, truncated file — provenance is optional
prov = {}
_PROV_CACHE[key] = (mtime, prov)
return prov
def _load_library(root: Path) -> dict:
try:
return json.loads((root / "library.json").read_text())
except Exception:
return {"assets": []}
def _save_library(root: Path, lib: dict) -> None:
tmp = root / "library.json.tmp"
tmp.write_text(json.dumps(lib, indent=1, sort_keys=False))
tmp.replace(root / "library.json")
def _safe_asset(root: Path, rel: str) -> Path | None:
p = (root / rel).resolve()
return p if p.is_file() and str(p).startswith(str(root.resolve())) else None
_REF_EXT = (".png", ".jpg", ".jpeg", ".webp")
def _resolve_uploads(org: str, uploads: list | None) -> list[str]:
"""Validate reference images already uploaded via ``POST /upload/image`` — the
core engine route lands them in this org's input dir (the SAME place fix/compose
stage their library-sourced references), so the compose graph's LoadImage nodes
can name them directly and the BYO-GPU collector ships them with the job. Return
the safe basenames, in order, de-duped. A traversal/absent/non-image entry is
dropped, never fatal the caller enforces the reference count."""
in_dir = Path(folder_paths.get_org_input_directory(org))
root = str(in_dir.resolve())
names: list[str] = []
for u in uploads or []:
if not isinstance(u, str):
continue
name = os.path.basename(u.strip())
if not name or name in names or os.path.splitext(name)[1].lower() not in _REF_EXT:
continue
p = (in_dir / name).resolve()
if p.is_file() and str(p).startswith(root):
names.append(name)
return names
def _reseed(graph: dict) -> dict:
for node in graph.values():
ins = node.get("inputs", {})
for k in ("seed", "noise_seed"):
if isinstance(ins.get(k), (int, float)):
ins[k] = random.randrange(2**31)
return graph
def _fix_graph(ref_name: str, instruction: str, prefix: str) -> dict:
"""The proven Qwen-Image-Edit-2511 wiring, instruction-edit form: the chosen
output is the sole reference; the user's words are the edit."""
prompt = (f"{instruction.strip()}. Photorealistic, keep the same model, "
f"pose, framing, lighting and background; only apply the "
f"requested change.")
return {
"1": {"class_type": "LoadImage", "inputs": {"image": ref_name}},
"2": {"class_type": "FluxKontextImageScale", "inputs": {"image": ["1", 0]}},
"3": {"class_type": "CLIPLoader", "inputs": {"clip_name": "qwen2.5-vl-7b.safetensors", "type": "qwen_image", "device": "default"}},
"4": {"class_type": "UNETLoader", "inputs": {"unet_name": "qwen-image-edit-2511.safetensors", "weight_dtype": "default"}},
"5": {"class_type": "VAELoader", "inputs": {"vae_name": "qwen-image-vae.safetensors"}},
"6": {"class_type": "TextEncodeQwenImageEditPlus", "inputs": {"clip": ["3", 0], "vae": ["5", 0], "image1": ["2", 0], "prompt": prompt}},
"7": {"class_type": "TextEncodeQwenImageEditPlus", "inputs": {"clip": ["3", 0], "prompt": _NEG}},
"8": {"class_type": "FluxKontextMultiReferenceLatentMethod", "inputs": {"conditioning": ["6", 0], "reference_latents_method": "index_timestep_zero"}},
"9": {"class_type": "FluxKontextMultiReferenceLatentMethod", "inputs": {"conditioning": ["7", 0], "reference_latents_method": "index_timestep_zero"}},
"10": {"class_type": "EmptySD3LatentImage", "inputs": {"width": 1024, "height": 1536, "batch_size": 1}},
"11": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["4", 0], "shift": 3.1}},
"12": {"class_type": "CFGNorm", "inputs": {"model": ["11", 0], "strength": 1.0}},
"13": {"class_type": "KSampler", "inputs": {"model": ["12", 0], "positive": ["8", 0], "negative": ["9", 0], "latent_image": ["10", 0],
"seed": random.randrange(2**31), "steps": 30, "cfg": 4.0, "sampler_name": "euler", "scheduler": "simple", "denoise": 1.0}},
"14": {"class_type": "VAEDecode", "inputs": {"samples": ["13", 0], "vae": ["5", 0]}},
"15": {"class_type": "SaveImage", "inputs": {"images": ["14", 0], "filename_prefix": prefix}},
}
def _compose_graph(ref_names: list[str], instruction: str, prefix: str) -> dict:
"""Multi-reference compose: several chosen assets become the references and the
user's words say HOW to combine them into a new output. Same proven Qwen
reference-conditioned wiring as _fix_graph (TextEncodeQwenImageEditPlus takes
image1..imageN), so no new pipeline the multi-input case of the good graph."""
prompt = (f"{instruction.strip()}. Combine the provided reference images as "
f"described. Photorealistic, coherent single result.")
g: dict = {
"3": {"class_type": "CLIPLoader", "inputs": {"clip_name": "qwen2.5-vl-7b.safetensors", "type": "qwen_image", "device": "default"}},
"4": {"class_type": "UNETLoader", "inputs": {"unet_name": "qwen-image-edit-2511.safetensors", "weight_dtype": "default"}},
"5": {"class_type": "VAELoader", "inputs": {"vae_name": "qwen-image-vae.safetensors"}},
}
pos_inputs: dict = {"clip": ["3", 0], "vae": ["5", 0], "prompt": prompt}
nid = 20
for i, name in enumerate(ref_names[:5], start=1): # up to 5 references
li, si = str(nid), str(nid + 1)
g[li] = {"class_type": "LoadImage", "inputs": {"image": name}}
g[si] = {"class_type": "FluxKontextImageScale", "inputs": {"image": [li, 0]}}
pos_inputs[f"image{i}"] = [si, 0]
nid += 2
g["6"] = {"class_type": "TextEncodeQwenImageEditPlus", "inputs": pos_inputs}
g["7"] = {"class_type": "TextEncodeQwenImageEditPlus", "inputs": {"clip": ["3", 0], "prompt": _NEG}}
g["8"] = {"class_type": "FluxKontextMultiReferenceLatentMethod", "inputs": {"conditioning": ["6", 0], "reference_latents_method": "index_timestep_zero"}}
g["9"] = {"class_type": "FluxKontextMultiReferenceLatentMethod", "inputs": {"conditioning": ["7", 0], "reference_latents_method": "index_timestep_zero"}}
g["10"] = {"class_type": "EmptySD3LatentImage", "inputs": {"width": 1024, "height": 1536, "batch_size": 1}}
g["11"] = {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["4", 0], "shift": 3.1}}
g["12"] = {"class_type": "CFGNorm", "inputs": {"model": ["11", 0], "strength": 1.0}}
g["13"] = {"class_type": "KSampler", "inputs": {"model": ["12", 0], "positive": ["8", 0], "negative": ["9", 0], "latent_image": ["10", 0],
"seed": random.randrange(2**31), "steps": 30, "cfg": 4.0, "sampler_name": "euler", "scheduler": "simple", "denoise": 1.0}}
g["14"] = {"class_type": "VAEDecode", "inputs": {"samples": ["13", 0], "vae": ["5", 0]}}
g["15"] = {"class_type": "SaveImage", "inputs": {"images": ["14", 0], "filename_prefix": prefix}}
return g
class DispatchError(ValueError):
"""A create/fix/compose request that can't be honored — bad input, unknown
asset, or a downstream /prompt failure. Carries a human message; the routes
return it as ``{"error": <message>}`` so a dispatch NEVER fails silently."""
def _prompt_error_message(body) -> str:
"""A short, human reason from a /prompt non-200 body. On a model-less cloud
pod the graph fails validation ("unet_name '' not in []"); the router returns
'no GPU worker' / 'provisioning'. This is what the user must SEE instead of a
silent nothing."""
err = body.get("error") if isinstance(body, dict) else None
if isinstance(err, dict):
msg = err.get("message") or err.get("type") or "render could not be queued"
det = err.get("details")
return f"{msg}{det}" if det and det != msg else msg
if isinstance(err, str) and err.strip():
return err.strip()
if isinstance(body, dict) and body.get("message"):
return str(body["message"])
return "render could not be queued — no GPU worker online for this org"
async def _queue_prompt(request: web.Request, server, graph: dict) -> str:
"""Self-POST to the ONE /prompt path with the caller's own credentials, so
dispatch/billing/tenancy/content-publish behave exactly as a UI run. A non-200
from /prompt (model-less pod fails validation; router has no GPU worker) is
raised as a ``DispatchError`` carrying the real reason NOT a raw 502 whose
truncated body the dialog can't parse (that was the deployed 'nothing happened /
nothing queued' — the error was swallowed)."""
port = getattr(server, "port", None) or int(os.environ.get("PORT", "8188"))
headers = {"Content-Type": "application/json"}
for h in ("Authorization", "Cookie"):
if request.headers.get(h):
headers[h] = request.headers[h]
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60)) as s:
async with s.post(f"http://127.0.0.1:{port}/prompt", json={"prompt": graph}, headers=headers) as r:
text = await r.text()
try:
body = json.loads(text) if text else {}
except ValueError:
body = {} # non-JSON (e.g. an HTML error page) — don't 500, report it
if r.status != 200:
raise DispatchError(_prompt_error_message(body) if body
else f"render engine returned HTTP {r.status}")
pid = body.get("prompt_id", "") if isinstance(body, dict) else ""
if not pid:
raise DispatchError("render was not queued (no prompt_id returned)")
return pid
except aiohttp.ClientError as e:
raise DispatchError(f"could not reach the render engine ({e})")
def _graph_params(graph: dict) -> dict:
"""The render params worth showing in the work-log context panel."""
p: dict = {}
for n in graph.values():
if not isinstance(n, dict):
continue
ct, ins = n.get("class_type", ""), n.get("inputs", {})
if "KSampler" in ct:
p.update(steps=ins.get("steps"), cfg=ins.get("cfg"),
sampler=ins.get("sampler_name"), scheduler=ins.get("scheduler"),
seed=ins.get("seed", ins.get("noise_seed")))
elif ct == "EmptySD3LatentImage":
p.update(width=ins.get("width"), height=ins.get("height"))
return {k: v for k, v in p.items() if v is not None}
def _in_render_jobs(org: str, pid: str) -> bool:
"""True if a prompt_id was dispatched to this org's BYO-GPU lane (gpu-jobs) —
gpu_dispatch._track_dispatched writes it to render_jobs.json during the self-POST."""
return _render_job(org, pid) is not None
def _render_job(org: str, pid: str) -> dict | None:
try:
jf = Path(folder_paths.get_org_output_directory(org)) / "render_jobs.json"
for j in json.loads(jf.read_text()):
if j.get("id") == pid:
return j
except Exception:
pass
return None
def _render_job_node(org: str, pid: str) -> str:
"""The node label the gpu-jobs lane recorded for this dispatch (spark/dbc/…)."""
j = _render_job(org, pid)
return (j.get("node") if j else None) or "gpu"
def _output_mtime(root: Path | None, rel: str) -> int | None:
try:
return int((root / rel).stat().st_mtime) if root and rel else None
except OSError:
return None
def _remove_render_job(org: str, pid: str) -> bool:
"""Drop a BYO-GPU tracking row from this org's render_jobs.json. The file is
per-org so a match is inherently owned. Returns True if one was removed."""
try:
jf = Path(folder_paths.get_org_output_directory(org)) / "render_jobs.json"
jobs = json.loads(jf.read_text())
except Exception:
return False
keep = [j for j in jobs if j.get("id") != pid]
if len(keep) == len(jobs):
return False
tmp = jf.with_name(jf.name + ".tmp")
tmp.write_text(json.dumps(keep))
tmp.replace(jf)
return True
def _owns_job(server, org: str, pid: str) -> bool:
"""Does this prompt_id belong to the caller's org? True if it's in the org's
work log, or a local-queue item stamped with this org, or the org's BYO-GPU
tracking file. A cross-tenant id matches none of these the route 404s and
never reveals that it exists for someone else."""
if any(r.get("id") == pid for r in worklog.load(org)):
return True
try:
running, pending = server.prompt_queue.get_current_queue_volatile()
if any(it[1] == pid and (it[3] or {}).get("org_id") == org
for it in list(running) + list(pending)):
return True
except Exception:
pass
return _in_render_jobs(org, pid)
def _cancel_job(server, org: str, pid: str) -> str:
"""Cancel an OWNED job across both realms (caller must have checked ownership).
Local pod queue: delete a pending item, or interrupt a running one both
org-scoped so one tenant can't touch another's render. BYO-GPU: drop the
tracking row (a queued gpu-job stops showing; one already rendering on the
worker may still finish the pod can't stop a remote process). Marks the work
log cancelled. Returns the realm acted on."""
realm = "gone"
try:
running, pending = server.prompt_queue.get_current_queue_volatile()
except Exception:
running, pending = [], []
if any(it[1] == pid and (it[3] or {}).get("org_id") == org for it in pending):
server.prompt_queue.delete_queue_item(
lambda it: it[1] == pid and (it[3] or {}).get("org_id") == org)
realm = "local"
elif any(it[1] == pid and (it[3] or {}).get("org_id") == org for it in running):
try:
import nodes
nodes.interrupt_processing()
realm = "local"
except Exception:
pass
if _remove_render_job(org, pid) and realm == "gone":
realm = "gpu"
worklog.set_status(org, pid, "cancelled")
return realm
def _landed_output(root: Path | None, prefix: str) -> str | None:
"""Library-relative path of the newest file a SaveImage prefix produced, or None
if it hasn't landed yet. Lets the work-log report done + wire lineage edges."""
if root is None or not prefix:
return None
try:
parent = root / os.path.dirname(prefix)
stem = os.path.basename(prefix)
# Match SaveImage's exact "<prefix>_<counter>_.png" — NOT a loose "<prefix>*"
# which also matches a CHILD prefix ("a" would swallow "a_00001__00001_"),
# collapsing distinct lineage steps onto one output.
pat = re.compile(r"^" + re.escape(stem) + r"_\d+_\.png$")
hits = [p for p in parent.glob(stem + "_*_.png") if pat.match(p.name)]
hits.sort(key=lambda p: p.stat().st_mtime, reverse=True)
if hits:
return str(hits[0].relative_to(root))
except Exception:
pass
return None
async def _dispatch(request, server, org: str, graph: dict, *, kind: str, prompt: str,
refs: list, uploads: list, parents: list, output_prefix: str) -> str:
"""The ONE place a render is queued AND recorded. On success writes a `queued`
work-log row (lane detected); on failure writes a `failed` row with the reason
so a dispatch is NEVER invisible, whatever lane it took or however it failed."""
common = dict(kind=kind, prompt=prompt, refs=refs, uploads=uploads,
parents=parents, output_prefix=output_prefix, params=_graph_params(graph))
try:
pid = await _queue_prompt(request, server, graph)
except DispatchError as e:
worklog.record(org, status="failed", error=str(e), **common)
raise
lane = "gpu" if _in_render_jobs(org, pid) else "local"
node = _render_job_node(org, pid) if lane == "gpu" else "studio pod"
worklog.record(org, pid=pid, status="queued", lane=lane, node=node, **common)
return pid
async def dispatch_fix(request: web.Request, server, org: str, rel: str,
instruction: str, upload: str | None = None) -> dict:
"""Run the proven Qwen single-image edit. The base image is EITHER a library
asset (``rel``, staged into the org input dir) OR an image the caller just
uploaded via ``/upload/image`` (``upload``, already resident there) so a fresh
photo can be edited without first landing it in the library. The ONE fix path:
the /v1/library/fix route and the chat `create` capability both call this."""
instruction = (instruction or "").strip()
if not 3 <= len(instruction) <= 500:
raise DispatchError("instruction required (3-500 chars)")
in_dir = Path(folder_paths.get_org_input_directory(org))
in_dir.mkdir(parents=True, exist_ok=True)
if upload:
names = _resolve_uploads(org, [upload])
if not names:
raise DispatchError(f"unknown upload: {upload}")
ref_name = names[0]
stem = re.sub(r"[^a-zA-Z0-9_-]", "_", Path(ref_name).stem)[:48]
else:
root = _library_root(org)
p = _safe_asset(root, rel) if root else None
if p is None or p.suffix.lower() != ".png":
raise DispatchError(f"unknown asset: {rel}")
ref_name = f"fix_src_{abs(hash((str(p), p.stat().st_mtime))) % 10**10}.png"
(in_dir / ref_name).write_bytes(p.read_bytes())
stem = re.sub(r"[^a-zA-Z0-9_-]", "_", p.stem)[:48]
pid = await _dispatch(request, server, org, _fix_graph(ref_name, instruction, f"fixes/{stem}"),
kind="fix", prompt=instruction, refs=[ref_name],
uploads=[upload] if upload else [], parents=[] if upload else [rel],
output_prefix=f"fixes/{stem}")
return {"ok": True, "prompt_id": pid, "output_prefix": f"fixes/{stem}"}
async def dispatch_compose(request: web.Request, server, org: str, paths: list,
instruction: str, uploads: list | None = None) -> dict:
"""Stage 2-5 references and run the multi-input Qwen graph. References are
library assets (``paths``, staged into the org input dir) and/or images the
caller just uploaded via ``/upload/image`` (``uploads``, already resident in
that dir). Shared by the /v1/library/compose route, the Fix surface's
add-reference flow, and the chat `create` capability."""
instruction = (instruction or "").strip()
if not 3 <= len(instruction) <= 500:
raise DispatchError("instruction required (3-500 chars)")
paths = paths or []
uploaded = _resolve_uploads(org, uploads)
if not 2 <= len(paths) + len(uploaded) <= 5:
raise DispatchError("select 2-5 references to compose")
root = _library_root(org)
in_dir = Path(folder_paths.get_org_input_directory(org))
in_dir.mkdir(parents=True, exist_ok=True)
ref_names = []
for rel in paths:
p = _safe_asset(root, rel) if root else None
if p is None or p.suffix.lower() != ".png":
raise DispatchError(f"unknown asset: {rel}")
nm = f"compose_src_{abs(hash((str(p), p.stat().st_mtime))) % 10**10}.png"
(in_dir / nm).write_bytes(p.read_bytes())
ref_names.append(nm)
ref_names.extend(uploaded)
labels = [Path(x).stem for x in paths] + [Path(u).stem for u in uploaded]
stem = "compose_" + "_".join(re.sub(r"[^a-zA-Z0-9]", "", s)[:12] for s in labels[:2])
pid = await _dispatch(request, server, org, _compose_graph(ref_names, instruction, f"composes/{stem}"),
kind="compose", prompt=instruction, refs=ref_names,
uploads=uploaded, parents=list(paths), output_prefix=f"composes/{stem}")
return {"ok": True, "prompt_id": pid, "output_prefix": f"composes/{stem}", "refs": len(ref_names)}
class NotOwned(DispatchError):
"""The prompt_id isn't the caller's — routes map this to 404 (never reveals it)."""
async def dispatch_amend(request: web.Request, server, org: str, prompt_id: str,
instruction: str, paths: list | None = None,
uploads: list | None = None) -> dict:
"""Amend a still-queued job as an honest ATOMIC SWAP: queued graphs are
immutable, so cancel the original and re-dispatch with the amended prompt and an
augmented reference set. The original's references (already staged in the org
input dir, read from its work-log row the authoritative ownership record) are
kept and combined with any new library picks (``paths``) and uploads
(``uploads``). The re-dispatch goes through the ONE funnel, so it lands in the
right lane and records a fresh work-log row. Position resets (it re-enters the
queue) the caller SHOWS that honestly."""
instruction = (instruction or "").strip()
if not 3 <= len(instruction) <= 500:
raise DispatchError("instruction required (3-500 chars)")
orig = next((r for r in worklog.load(org) if r.get("id") == prompt_id), None)
if orig is None:
raise NotOwned(f"no such job: {prompt_id}")
if orig.get("status") in ("done", "failed", "cancelled"):
raise DispatchError("this job already finished — start a new fix instead")
root = _library_root(org)
in_dir = Path(folder_paths.get_org_input_directory(org))
in_dir.mkdir(parents=True, exist_ok=True)
# keep the original references that are still present (already input-dir names)
ref_names = _resolve_uploads(org, orig.get("refs") or [])
new_uploads = _resolve_uploads(org, uploads)
for rel in (paths or []):
p = _safe_asset(root, rel) if root else None
if p is None or p.suffix.lower() != ".png":
raise DispatchError(f"unknown asset: {rel}")
nm = f"amend_src_{abs(hash((str(p), p.stat().st_mtime))) % 10**10}.png"
(in_dir / nm).write_bytes(p.read_bytes())
ref_names.append(nm)
# de-dupe, keep order, cap at the graph's 5
seen: set = set()
refs = [n for n in ref_names + new_uploads if not (n in seen or seen.add(n))][:5]
if not refs:
raise DispatchError("no reference images available to amend")
_cancel_job(server, org, prompt_id)
parents = list(orig.get("parents") or []) + list(paths or [])
stem = re.sub(r"[^a-zA-Z0-9_-]", "_", Path(refs[0]).stem)[:40] + "_amended"
if len(refs) == 1:
graph, kind, out = _fix_graph(refs[0], instruction, f"fixes/{stem}"), "fix", f"fixes/{stem}"
else:
graph, kind, out = _compose_graph(refs, instruction, f"composes/{stem}"), "compose", f"composes/{stem}"
all_uploads = list(dict.fromkeys(list(orig.get("uploads") or []) + new_uploads))
pid = await _dispatch(request, server, org, graph, kind=kind, prompt=instruction,
refs=refs, uploads=all_uploads, parents=parents, output_prefix=out)
return {"ok": True, "prompt_id": pid, "amended_from": prompt_id,
"output_prefix": out, "refs": len(refs)}
def _workflows(org: str) -> list[str]:
"""Saved workflow files for the org (any user), for the Pipelines tab."""
base = Path(folder_paths.get_user_directory())
roots = [base / "orgs" / org / "user", base / "default"]
seen: set[str] = set()
for root in roots:
if not root.is_dir():
continue
for wf in root.rglob("workflows/*/*.json"):
seen.add(str(wf).split("workflows/", 1)[1])
for wf in root.rglob("workflows/*.json"):
seen.add(str(wf).split("workflows/", 1)[1])
return sorted(seen)
@web.middleware
async def home_redirect(request: web.Request, handler):
"""Deployed default: land on the content view. The editor stays one click
away (?advanced=1) and every non-root path is untouched."""
if (_home_default() and request.path == "/" and request.method == "GET"
and "advanced" not in request.query
and "text/html" in request.headers.get("Accept", "")):
raise web.HTTPFound("/studio")
return await handler(request)
def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
@routes.get("/studio")
async def studio_home(request: web.Request):
# no-store: the SPA HTML changes every release; without this, browsers
# (and any proxy) heuristically cache it and serve a STALE page after a
# deploy — which is why UI fixes (e.g. delete) appeared "not working" to
# a user still running yesterday's cached studio_home.html. The page is
# tiny; always serve fresh. Static assets keep their own long cache.
return web.Response(text=_HTML.read_text(), content_type="text/html",
headers={"Cache-Control": "no-store, must-revalidate"})
@routes.get("/v1/library")
async def get_library(request: web.Request):
org = _org_of(request)
root = _library_root(org)
if root is None:
return web.json_response({"org": org, "assets": [], "workflows": _workflows(org),
"note": "no library.json for this org yet"})
lib = _load_library(root)
assets = []
for a in lib.get("assets", []):
p = _safe_asset(root, a.get("path", ""))
if p is None:
continue
row = dict(a)
row["view"] = _view_params(p)
row["mtime"] = int(p.stat().st_mtime)
if p.suffix.lower() == ".png":
row["prov"] = _provenance(p)
assets.append(row)
return web.json_response({"org": org, "assets": assets, "workflows": _workflows(org)})
@routes.get("/v1/library/file")
async def get_asset_file(request: web.Request):
# Org-scoped asset serving. The Studio engine's core /view serves the
# GLOBAL output dir, so org assets (orgs/<org>/output/…) 404 there → blank
# thumbnails. This resolves the caller's org and serves from its library root.
# ?thumb=1 → a cached, downsized JPEG (grid loads full-res 1-2MB PNGs one by
# one otherwise, which makes the whole page crawl). Full-res on click/zoom.
org = _org_of(request)
root = _library_root(org)
# ?input=1 → a staged reference in orgs/<org>/input (fix/compose sources),
# so YouTube-style queue rows can show the referenced work as a thumbnail.
if request.query.get("input"):
in_dir = Path(folder_paths.get_org_input_directory(org))
name = os.path.basename(request.query.get("path", ""))
ip = in_dir / name
if not name or not ip.is_file() or not str(ip.resolve()).startswith(str(in_dir.resolve())):
return web.Response(status=404)
p = ip
else:
p = _safe_asset(root, request.query.get("path", "")) if root else None
if p is None:
return web.Response(status=404)
if request.query.get("thumb") and p.suffix.lower() in (".png", ".jpg", ".jpeg", ".webp"):
try:
tdir = root / ".thumbs"
tdir.mkdir(exist_ok=True)
key = re.sub(r"[^a-zA-Z0-9]", "_", str(p))[-120:]
tp = tdir / f"{key}.jpg"
if not tp.is_file() or tp.stat().st_mtime < p.stat().st_mtime:
from PIL import Image
im = Image.open(p)
im.thumbnail((480, 720)) # grid-sized; keeps aspect
im.convert("RGB").save(tp, "JPEG", quality=82)
return web.FileResponse(tp, headers={"Cache-Control": "public, max-age=86400"})
except Exception:
pass # fall through to full-res on any thumbnail failure
return web.FileResponse(p)
@routes.get("/v1/gpu-status")
async def gpu_status(request: web.Request):
"""Does the caller's org have an online GPU to render on? The UI showed
'connect a GPU' even with a GPU connected because it checked /v1/engines
(the worker-federation) but `hanzo gpu connect` registers with the FLEET
(gpu-jobs), which is exactly what dispatch uses. Report the SAME source of
truth as dispatch (_has_online_gpu) so the UI matches what actually works."""
try:
from middleware.gpu_dispatch import _bearer, _has_online_gpu
except ImportError:
from .middleware.gpu_dispatch import _bearer, _has_online_gpu
try:
tok = _bearer(request)
online = bool(tok and _has_online_gpu(tok))
except Exception:
online = False
return web.json_response({"online": online})
@routes.get("/v1/render-queue")
async def render_queue(request: web.Request):
"""The org's IN-FLIGHT BYO-GPU renders (fix/compose/rerun dispatched to a
connected GPU). These run on the worker's gpu-jobs queue, NOT the pod's
local /queue, so the UI could not see them this is what makes a queued
fix visible. Drops entries whose output has landed in the library or that
have aged out (~25 min). Cheap: reads one small per-org JSON."""
org = _org_of(request)
root = _library_root(org)
if root is None:
return web.json_response({"jobs": []})
jf = root / "render_jobs.json"
try:
jobs = json.loads(jf.read_text())
except Exception:
jobs = []
now = int(time.time())
live = []
for j in jobs:
age = now - int(j.get("ts", 0))
if age > 1500: # aged out (worker deadline is 1200s)
continue
# Completed only when the ACTUAL output for THIS job exists: the render
# saves to "<outPrefix>_NNNNN_.png". Check precisely (glob the exact
# output prefix) — not a fuzzy base match, which collided with the source
# image and made the job disappear from the queue after 30s.
out_prefix = j.get("outPrefix") or j.get("prefix") or ""
landed = False
if out_prefix:
try:
parent = root / os.path.dirname(out_prefix)
stem = os.path.basename(out_prefix)
landed = any(parent.glob(stem + "*.png"))
except Exception:
landed = False
if landed and age > 20:
continue
j["age"] = age
live.append(j)
return web.json_response({"jobs": live})
@routes.post("/v1/library/status")
async def set_status(request: web.Request):
body = await request.json()
rel, status = body.get("path", ""), body.get("status", "")
if status not in _STATUSES:
return web.json_response({"error": f"status must be one of {_STATUSES}"}, status=400)
org = _org_of(request)
root = _library_root(org)
if root is None or _safe_asset(root, rel) is None:
return web.json_response({"error": "unknown asset"}, status=404)
lib = _load_library(root)
for a in lib.get("assets", []):
if a.get("path") == rel:
a["status"] = status
a["updatedAt"] = int(time.time())
_save_library(root, lib)
return web.json_response({"ok": True, "path": rel, "status": status})
return web.json_response({"error": "asset not in library"}, status=404)
@routes.post("/v1/library/rerun")
async def rerun(request: web.Request):
body = await request.json()
org = _org_of(request)
root = _library_root(org)
p = _safe_asset(root, body.get("path", "")) if root else None
if p is None or p.suffix.lower() != ".png":
return web.json_response({"error": "unknown asset"}, status=404)
try:
from PIL import Image
graph = json.loads(Image.open(p).info.get("prompt", "null"))
except Exception:
graph = None
if not isinstance(graph, dict):
return web.json_response({"error": "no embedded workflow in this image"}, status=422)
rel = body.get("path", "")
out_prefix = ""
instr = ""
for n in graph.values():
if not isinstance(n, dict):
continue
if n.get("class_type") == "SaveImage":
out_prefix = n.get("inputs", {}).get("filename_prefix", "") or out_prefix
elif "TextEncode" in n.get("class_type", "") and not instr:
t = n.get("inputs", {}).get("prompt") or n.get("inputs", {}).get("text") or ""
if t and t != _NEG:
instr = t
ids = []
try:
for _ in range(max(1, min(int(body.get("count", 1)), 4))):
ids.append(await _dispatch(
request, server, org, _reseed(json.loads(json.dumps(graph))),
kind="rerun", prompt=instr, refs=[], uploads=[], parents=[rel],
output_prefix=out_prefix))
except DispatchError as e:
return web.json_response({"error": str(e)}, status=400)
return web.json_response({"ok": True, "prompt_ids": ids})
@routes.post("/v1/queue/cancel")
async def cancel_job(request: web.Request):
"""Cancel one of the caller's OWN queued/running jobs (local pod queue or
BYO-GPU lane). Ownership is resolved server-side; a prompt_id that isn't the
caller's 404s — never confirming another tenant's job exists."""
org = _org_of(request)
pid = (await request.json()).get("prompt_id", "")
if not pid:
return web.json_response({"error": "prompt_id required"}, status=400)
if not _owns_job(server, org, pid):
return web.json_response({"error": "not found"}, status=404)
return web.json_response({"ok": True, "canceled": _cancel_job(server, org, pid)})
@routes.post("/v1/queue/amend")
async def amend_job(request: web.Request):
"""Amend a still-queued job (atomic cancel + re-dispatch with more context)."""
body = await request.json()
try:
return web.json_response(await dispatch_amend(
request, server, _org_of(request), body.get("prompt_id", ""),
body.get("instruction", ""), body.get("paths"), body.get("uploads")))
except NotOwned as e:
return web.json_response({"error": str(e)}, status=404)
except DispatchError as e:
return web.json_response({"error": str(e)}, status=400)
@routes.get("/v1/queue/status")
async def queue_status(request: web.Request):
"""This org's LIVE queue with where + when: per-lane position N of M, the
node each job runs on, elapsed for the running item, and an ETA computed
server-side from the org's OWN measured render medians. Tenant-scoped — only
the caller's active jobs and their positions. Cheap: reads the org work log,
stamps started/finished as it observes them, no fleet call."""
org = _org_of(request)
root = _library_root(org)
now = int(time.time())
rows = worklog.load(org)
# finalize any job whose output has landed (real finish time = file mtime)
for r in rows:
if r.get("status") in ("done", "failed", "cancelled"):
continue
landed = _landed_output(root, r.get("output_prefix"))
if landed:
mt = _output_mtime(root, landed) or now
worklog.mark_finished(org, r["id"], mt)
r["status"], r["finished_at"] = "done", mt # mirror in-memory
active = [r for r in rows if r.get("status") not in ("done", "failed", "cancelled")
and now - int(r.get("ts", now)) <= 1800]
# the oldest job in each lane is the one rendering — stamp its start once
by_lane: dict[str, list[dict]] = {}
for r in active:
by_lane.setdefault(r.get("lane") or "local", []).append(r)
for jobs in by_lane.values():
head = min(jobs, key=lambda j: j.get("ts", 0))
if not head.get("started_at"):
worklog.mark_started(org, head["id"], now)
head["started_at"] = now
head["status"] = "running"
medians = worklog.median_durations(rows)
est, counts = worklog.estimate_lanes(active, medians, now)
items = {}
for r in active:
items[r["id"]] = {**est.get(r["id"], {}), "id": r["id"], "kind": r.get("kind", ""),
"prompt": r.get("prompt", ""), "status": r.get("status"),
"node": r.get("node")}
return web.json_response({"items": items, "medians": medians, "lanes": counts, "now": now})
@routes.get("/v1/nodes")
async def get_nodes(request: web.Request):
"""The caller-org's GPU nodes (online/offline) for the queue header badges,
plus this studio pod. The fleet is token-scoped, so only the caller's org's
nodes are ever returned."""
try:
from middleware.gpu_dispatch import _bearer, _fleet_machines, _node_label
except ImportError:
from .middleware.gpu_dispatch import _bearer, _fleet_machines, _node_label
nodes = []
try:
tok = _bearer(request)
for m in (_fleet_machines(tok) if tok else []):
if not m.get("gpu"):
continue
nodes.append({"name": _node_label(m), "online": m.get("status") == "online"})
except Exception:
nodes = []
return web.json_response({"nodes": nodes, "pod": {"name": "studio pod", "online": True}})
@routes.get("/v1/worklog")
async def get_worklog(request: web.Request):
"""This org's dispatched renders (fix/compose/rerun) with REAL status — the
queue/history backbone. Reverse-chronological; ?q= filters over prompt+kind.
Server-resolved org: a caller only ever sees its OWN log file."""
org = _org_of(request)
root = _library_root(org)
rows = worklog.load(org)
out = []
for r in reversed(rows):
row = dict(r)
landed = _landed_output(root, r.get("output_prefix"))
row["output"] = landed
if landed and r.get("status") not in ("failed", "cancelled"):
row["status"] = "done"
out.append(row)
q = (request.query.get("q") or "").lower().strip()
if q:
out = [r for r in out if q in (r.get("prompt", "") or "").lower() or q in (r.get("kind", "") or "")]
return web.json_response({"org": org, "items": out[:200]})
@routes.get("/v1/worklog/lineage")
async def get_lineage(request: web.Request):
"""The version chain for one library asset — ancestors (what it was derived
from, with the prompt at each step) and descendants (later fixes of it).
Pure over THIS org's rows; cross-tenant lineage is impossible by construction."""
org = _org_of(request)
root = _library_root(org)
target = request.query.get("path", "")
rows = worklog.load(org)
for r in rows:
r["output"] = _landed_output(root, r.get("output_prefix"))
return web.json_response(worklog.build_lineage(rows, target))
@routes.post("/v1/library/fix")
async def fix(request: web.Request):
body = await request.json()
try:
return web.json_response(await dispatch_fix(
request, server, _org_of(request), body.get("path", ""),
body.get("instruction", ""), body.get("upload")))
except DispatchError as e:
return web.json_response({"error": str(e)}, status=400)
@routes.post("/v1/library/compose")
async def compose(request: web.Request):
"""Select N references (library assets and/or freshly uploaded images) + say
HOW to combine them a new generated output. The multi-input case of the
proven Qwen reference-conditioned graph."""
body = await request.json()
try:
return web.json_response(await dispatch_compose(
request, server, _org_of(request), body.get("paths") or [],
body.get("instruction", ""), body.get("uploads")))
except DispatchError as e:
return web.json_response({"error": str(e)}, status=400)
# ── Ratings + notes: the curation feedback loop (HIP-0506 §5) ──────────────
# Stored per-org in orgs/<org>/output/ratings.json keyed by asset path OR run
# prompt_id. 0-5 stars + free notes; the copilot reads these before assembling
# a workflow so generations get closer to intent each pass.
@routes.get("/v1/library/ratings")
async def get_ratings(request: web.Request):
root = _library_root(_org_of(request))
if root is None:
return web.json_response({})
try:
return web.json_response(json.loads((root / "ratings.json").read_text()))
except Exception:
return web.json_response({})
@routes.post("/v1/library/rate")
async def rate(request: web.Request):
body = await request.json()
key = (body.get("key") or "").strip() # asset path or run prompt_id
if not key:
return web.json_response({"error": "key required"}, status=400)
stars = body.get("stars")
if stars is not None and not (isinstance(stars, int) and 0 <= stars <= 5):
return web.json_response({"error": "stars must be 0-5"}, status=400)
root = _library_root(_org_of(request))
if root is None:
return web.json_response({"error": "no library for org"}, status=404)
rf = root / "ratings.json"
try:
data = json.loads(rf.read_text())
except Exception:
data = {}
entry = data.get(key, {})
if stars is not None:
entry["stars"] = stars
if "notes" in body:
entry["notes"] = str(body.get("notes") or "")[:2000]
if body.get("favorite") is not None:
entry["favorite"] = bool(body.get("favorite"))
entry["updatedAt"] = int(time.time())
data[key] = entry
tmp = root / "ratings.json.tmp"
tmp.write_text(json.dumps(data, indent=1))
tmp.replace(rf)
return web.json_response({"ok": True, "key": key, "entry": entry})
+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():
+107 -24
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:
@@ -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,342 @@
"""Unit tests for the Studio Home surface (middleware/studio_home.py)."""
import json
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from middleware import studio_home as sh
def test_reseed_randomizes_every_sampler_seed():
graph = {
"a": {"class_type": "KSampler", "inputs": {"seed": 7, "steps": 30}},
"b": {"class_type": "KSamplerAdvanced", "inputs": {"noise_seed": 7}},
"c": {"class_type": "SaveImage", "inputs": {"filename_prefix": "x"}},
}
out = sh._reseed(json.loads(json.dumps(graph)))
assert out["a"]["inputs"]["steps"] == 30
# 2**31 space: a collision with the old seed twice over is not a thing.
assert (out["a"]["inputs"]["seed"], out["b"]["inputs"]["noise_seed"]) != (7, 7)
def test_fix_graph_keeps_recipe_invariants():
g = sh._fix_graph("ref.png", "make the straps thicker", "fixes/test")
ks = g["13"]["inputs"]
assert ks["cfg"] == 4.0 and ks["steps"] == 30 # proven floor, not the weak 3.0
assert g["12"]["class_type"] == "CFGNorm"
assert g["8"]["inputs"]["reference_latents_method"] == "index_timestep_zero"
assert g["6"]["inputs"]["image1"] == ["2", 0] # scaled ref, never raw
assert "straps thicker" in g["6"]["inputs"]["prompt"]
assert g["15"]["inputs"]["filename_prefix"] == "fixes/test"
def test_safe_asset_blocks_traversal(tmp_path):
root = tmp_path / "out"
(root / "designs").mkdir(parents=True)
(root / "designs" / "a.png").write_bytes(b"x")
(tmp_path / "secret.txt").write_text("no")
assert sh._safe_asset(root, "designs/a.png") is not None
assert sh._safe_asset(root, "../secret.txt") is None
assert sh._safe_asset(root, "designs/missing.png") is None
def test_status_vocab_matches_library_lifecycle():
assert sh._STATUSES == ("draft", "approved", "queued", "published", "deleted")
def test_resolve_uploads_keeps_only_safe_existing_images(tmp_path, monkeypatch):
in_dir = tmp_path / "orgs" / "acme" / "input"
in_dir.mkdir(parents=True)
(in_dir / "ref.png").write_bytes(b"\x89PNG")
(in_dir / "shot.jpg").write_bytes(b"jpg")
(tmp_path / "secret.txt").write_text("no")
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda org=None: str(in_dir))
out = sh._resolve_uploads("acme", [
"ref.png", # kept
"sub/dir/shot.jpg", # basename kept (upload names are flat)
"ref.png", # de-duped
"../secret.txt", # traversal + non-image -> dropped
"missing.png", # absent -> dropped
"notes.txt", # non-image -> dropped
42, # non-string -> dropped
])
assert out == ["ref.png", "shot.jpg"]
@pytest.mark.asyncio
async def test_compose_accepts_uploaded_references(tmp_path, monkeypatch):
"""A base library asset + one uploaded image is a valid 2-reference compose:
the upload is named directly (already in the input dir), the library asset is
staged, and both reach the graph no lower bound violation."""
org = "acme"
root = tmp_path / "orgs" / org / "output"
root.mkdir(parents=True)
(root / "library.json").write_text(json.dumps({"assets": [{"path": "a.png"}]}))
(root / "a.png").write_bytes(b"\x89PNG-base")
in_dir = tmp_path / "orgs" / org / "input"
in_dir.mkdir(parents=True)
(in_dir / "up_0001_.png").write_bytes(b"\x89PNG-up")
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda o=None: str(in_dir))
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
captured = {}
async def fake_queue(request, server, graph):
captured["graph"] = graph
return "pid-1"
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
out = await sh.dispatch_compose(None, None, org, ["a.png"], "merge them", uploads=["up_0001_.png"])
assert out["ok"] and out["refs"] == 2
load_names = [n["inputs"]["image"] for n in captured["graph"].values()
if n["class_type"] == "LoadImage"]
assert "up_0001_.png" in load_names # upload referenced by its own name
assert any(n.startswith("compose_src_") for n in load_names) # library asset staged
class _FakeQueue:
def __init__(self, running=None, pending=None):
self.running = running or []
self.pending = pending or []
self.deleted = []
def get_current_queue_volatile(self):
return (self.running, self.pending)
def delete_queue_item(self, fn):
before = len(self.pending)
self.pending = [it for it in self.pending if not fn(it)]
self.deleted.append(before - len(self.pending))
class _FakeServer:
port = 1
def __init__(self, **kw):
self.prompt_queue = _FakeQueue(**kw)
def _orgaware(tmp_path, monkeypatch):
def outd(o=None):
d = tmp_path / "orgs" / (o or "default") / "output"
d.mkdir(parents=True, exist_ok=True)
return str(d)
def ind(o=None):
d = tmp_path / "orgs" / (o or "default") / "input"
d.mkdir(parents=True, exist_ok=True)
return str(d)
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", outd)
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", ind)
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
return outd, ind
def test_cancel_marks_cancelled_and_removes_gpu_row_org_scoped(tmp_path, monkeypatch):
outd, _ = _orgaware(tmp_path, monkeypatch)
(Path(outd("acme")) / "library.json").write_text('{"assets":[]}')
sh.worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
output_prefix="fixes/x", pid="p1", lane="gpu")
(Path(outd("acme")) / "render_jobs.json").write_text(json.dumps([{"id": "p1", "prefix": "x"}]))
srv = _FakeServer()
assert sh._owns_job(srv, "acme", "p1")
assert not sh._owns_job(srv, "globex", "p1") # cross-tenant: not owned
assert sh._cancel_job(srv, "acme", "p1") == "gpu"
assert sh.worklog.load("acme")[0]["status"] == "cancelled"
assert json.loads((Path(outd("acme")) / "render_jobs.json").read_text()) == []
def test_cancel_deletes_local_pending_only_when_org_matches(tmp_path, monkeypatch):
_orgaware(tmp_path, monkeypatch)
mine = (1, "p1", {}, {"org_id": "acme"}, [])
theirs = (2, "p2", {}, {"org_id": "globex"}, [])
srv = _FakeServer(pending=[mine, theirs])
sh.worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
output_prefix="fixes/x", pid="p1")
assert sh._cancel_job(srv, "acme", "p1") == "local"
assert [it[1] for it in srv.prompt_queue.pending] == ["p2"] # only mine deleted
@pytest.mark.asyncio
async def test_amend_atomic_swap_keeps_original_refs_and_adds_new(tmp_path, monkeypatch):
outd, ind = _orgaware(tmp_path, monkeypatch)
(Path(outd("acme")) / "library.json").write_text('{"assets":[]}')
(Path(ind("acme")) / "fix_src_1.png").write_bytes(b"\x89PNG") # original ref, still present
(Path(ind("acme")) / "drop_9.png").write_bytes(b"\x89PNG") # new upload
sh.worklog.record("acme", kind="fix", prompt="brighten", refs=["fix_src_1.png"],
uploads=[], parents=["a.png"], output_prefix="fixes/a", pid="p1", lane="local")
captured = {}
async def fake_queue(request, server, graph):
captured["graph"] = graph
return "p2"
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
out = await sh.dispatch_amend(None, _FakeServer(), "acme", "p1", "brighten a lot more",
paths=[], uploads=["drop_9.png"])
assert out["amended_from"] == "p1" and out["prompt_id"] == "p2" and out["refs"] == 2
rows = {r["id"]: r for r in sh.worklog.load("acme")}
assert rows["p1"]["status"] == "cancelled" # original swapped out
assert rows["p2"]["status"] == "queued" and rows["p2"]["prompt"] == "brighten a lot more"
loads = [n["inputs"]["image"] for n in captured["graph"].values() if n.get("class_type") == "LoadImage"]
assert "fix_src_1.png" in loads and "drop_9.png" in loads # original kept + new added
@pytest.mark.asyncio
async def test_amend_unknown_job_is_not_owned(tmp_path, monkeypatch):
_orgaware(tmp_path, monkeypatch)
with pytest.raises(sh.NotOwned):
await sh.dispatch_amend(None, _FakeServer(), "acme", "ghost", "change it")
@pytest.mark.asyncio
async def test_amend_terminal_job_rejected(tmp_path, monkeypatch):
outd, _ = _orgaware(tmp_path, monkeypatch)
sh.worklog.record("acme", kind="fix", prompt="x", refs=["r.png"], uploads=[], parents=[],
output_prefix="fixes/x", pid="p1")
sh.worklog.set_status("acme", "p1", "done")
with pytest.raises(sh.DispatchError):
await sh.dispatch_amend(None, _FakeServer(), "acme", "p1", "too late")
def test_prompt_error_message_extracts_real_reason():
# model-less pod: /prompt returns a validation error — the user must SEE it.
body = {"error": {"type": "prompt_outputs_failed_validation",
"message": "Prompt outputs failed validation",
"details": "UNETLoader: unet_name 'qwen-image-edit-2511.safetensors' not in []"}}
msg = sh._prompt_error_message(body)
assert "Prompt outputs failed validation" in msg and "not in []" in msg
assert sh._prompt_error_message({}) and "silent" not in sh._prompt_error_message({}).lower()
assert sh._prompt_error_message({"error": "no GPU worker"}) == "no GPU worker"
@pytest.mark.asyncio
async def test_text_only_fix_dispatches_single_image(tmp_path, monkeypatch):
"""REGRESSION LOCK: click Fix, type text, no references → the single-image
/v1/library/fix path (base library asset staged, proven Qwen edit graph)."""
org = "acme"
root = tmp_path / "orgs" / org / "output"
root.mkdir(parents=True)
(root / "library.json").write_text(json.dumps({"assets": [{"path": "a.png"}]}))
(root / "a.png").write_bytes(b"\x89PNG")
in_dir = tmp_path / "orgs" / org / "input"
in_dir.mkdir(parents=True)
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda o=None: str(in_dir))
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
captured = {}
async def fake_queue(request, server, graph):
captured["graph"] = graph
return "pid-fix"
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
out = await sh.dispatch_fix(None, None, org, "a.png", "make the straps thicker")
assert out["ok"] and out["prompt_id"] == "pid-fix"
g = captured["graph"]
assert g["1"]["class_type"] == "LoadImage" and g["1"]["inputs"]["image"].startswith("fix_src_")
assert g["15"]["class_type"] == "SaveImage" and "straps thicker" in g["6"]["inputs"]["prompt"]
@pytest.mark.asyncio
async def test_fix_accepts_uploaded_base_image(tmp_path, monkeypatch):
"""A freshly uploaded photo can be the fix base (upload=) without landing it in
the library first the '+ add a photo' single-ref path."""
org = "acme"
in_dir = tmp_path / "orgs" / org / "input"
in_dir.mkdir(parents=True)
(in_dir / "shot_1.png").write_bytes(b"\x89PNG-up")
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda o=None: str(in_dir))
captured = {}
async def fake_queue(request, server, graph):
captured["graph"] = graph
return "pid-up"
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
out = await sh.dispatch_fix(None, None, org, "", "brighten it", upload="shot_1.png")
assert out["ok"] and captured["graph"]["1"]["inputs"]["image"] == "shot_1.png"
with pytest.raises(sh.DispatchError):
await sh.dispatch_fix(None, None, org, "", "brighten it", upload="../etc/passwd")
@pytest.mark.asyncio
async def test_queue_prompt_surfaces_downstream_error(tmp_path, monkeypatch):
"""A non-200 from the self-POST /prompt (model-less pod) becomes a DispatchError
carrying the REAL reason never a raw 502 the dialog swallows into 'Failed:'."""
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
class Srv:
port = None
srv = Srv()
routes = web.RouteTableDef()
@routes.post("/prompt")
async def prompt(request):
return web.json_response({"error": {"message": "Prompt outputs failed validation",
"details": "UNETLoader: unet_name '' not in []"}}, status=400)
async def edit(request):
graph = (await request.json())["graph"]
try:
pid = await sh._queue_prompt(request, srv, graph)
return web.json_response({"prompt_id": pid})
except sh.DispatchError as e:
return web.json_response({"error": str(e)}, status=400)
routes.post("/edit")(edit)
app = web.Application()
app.add_routes(routes)
ts = TestServer(app)
await ts.start_server()
srv.port = ts.port
client = TestClient(ts)
await client.start_server()
r = await client.post("/edit", json={"graph": {"1": {"class_type": "SaveImage", "inputs": {}}}})
body = await r.json()
assert r.status == 400 and "error" in body
assert "failed validation" in body["error"] and "not in []" in body["error"] # real reason, shown
await client.close()
@pytest.mark.asyncio
async def test_compose_rejects_single_reference(tmp_path, monkeypatch):
org = "acme"
root = tmp_path / "orgs" / org / "output"
root.mkdir(parents=True)
(root / "library.json").write_text(json.dumps({"assets": [{"path": "a.png"}]}))
(root / "a.png").write_bytes(b"\x89PNG")
in_dir = tmp_path / "orgs" / org / "input"
in_dir.mkdir(parents=True)
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda o=None: str(in_dir))
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
with pytest.raises(sh.DispatchError):
await sh.dispatch_compose(None, None, org, ["a.png"], "just one", uploads=[])
@pytest.mark.asyncio
async def test_home_redirect_only_hits_root_html(monkeypatch):
from aiohttp import web
from aiohttp.test_utils import make_mocked_request
monkeypatch.setenv("STUDIO_HOME_DEFAULT", "1")
async def handler(_):
return web.Response(text="spa")
# root + html accept -> redirect to /studio
req = make_mocked_request("GET", "/", headers={"Accept": "text/html"})
with pytest.raises(web.HTTPFound) as e:
await sh.home_redirect(req, handler)
assert e.value.location == "/studio"
# advanced escape hatch and non-root paths pass through untouched
for path, accept in [("/?advanced=1", "text/html"), ("/assets/x.js", "*/*"), ("/", "application/json")]:
req = make_mocked_request("GET", path, headers={"Accept": accept})
resp = await sh.home_redirect(req, handler)
assert resp.text == "spa"
# env off (local dev / worker pods): root is untouched
monkeypatch.delenv("STUDIO_HOME_DEFAULT")
req = make_mocked_request("GET", "/", headers={"Accept": "text/html"})
resp = await sh.home_redirect(req, handler)
assert resp.text == "spa"
@@ -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
}
@@ -0,0 +1,99 @@
{
"id": "hanzo-native-pipeline",
"revision": 0,
"last_node_id": 4,
"last_link_id": 3,
"nodes": [
{
"id": 1,
"type": "HanzoChat",
"pos": [40, 40],
"size": [400, 320],
"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]}
],
"properties": {"Node name for S&R": "HanzoChat"},
"widgets_values": [
"default",
"/no_think Write one vivid comma-separated image prompt for a serene alpine lake at dawn. Output only the prompt, no preamble.",
"You are a concise text-to-image prompt engineer.",
0.4,
200,
true
]
},
{
"id": 2,
"type": "HanzoImageGen",
"pos": [480, 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": [3]}
],
"properties": {"Node name for S&R": "HanzoImageGen"},
"widgets_values": ["", 512, 512, "default", 1]
},
{
"id": 3,
"type": "SaveImage",
"pos": [860, 40],
"size": [400, 400],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{"name": "images", "type": "IMAGE", "link": 3},
{"name": "filename_prefix", "type": "STRING", "widget": {"name": "filename_prefix"}, "link": null}
],
"outputs": [],
"properties": {"Node name for S&R": "SaveImage"},
"widgets_values": ["hanzo_native_pipeline"]
},
{
"id": 4,
"type": "HanzoSaveText",
"pos": [480, 320],
"size": [340, 180],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{"name": "text", "type": "STRING", "link": 2},
{"name": "filename_prefix", "type": "STRING", "widget": {"name": "filename_prefix"}, "link": null}
],
"outputs": [],
"properties": {"Node name for S&R": "HanzoSaveText"},
"widgets_values": ["hanzo_native_prompt"]
}
],
"links": [
[1, 1, 0, 2, 0, "STRING"],
[2, 1, 0, 4, 0, "STRING"],
[3, 2, 0, 3, 0, "IMAGE"]
],
"groups": [],
"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"
}
}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useRef, useState } from 'react'
import {
chat, getSession, getLibrary, getRenderQueue, gpuOnline, thumbURL,
type ChatMessage, type Session, type Asset, type RenderJob,
} from './api'
// Studio-chat: the specialized chat surface. You say what to create; the cloud
// /v1/chat "create" capability drives studio's render tools (fix/compose) and the
// outputs land in the library strip. Increment 1 uses a minimal own-UI; the next
// increment swaps the transcript/composer for @hanzo/chat's <Chat> on @hanzo/gui.
export function App() {
const [session, setSession] = useState<Session>({})
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState('')
const [busy, setBusy] = useState(false)
const [assets, setAssets] = useState<Asset[]>([])
const [jobs, setJobs] = useState<RenderJob[]>([])
const [gpu, setGpu] = useState(false)
const scroller = useRef<HTMLDivElement>(null)
useEffect(() => { getSession().then(setSession) }, [])
// Poll the library + in-flight queue so dispatched renders appear as they land.
useEffect(() => {
let alive = true
const tick = async () => {
if (!alive) return
const [lib, q, on] = await Promise.all([getLibrary(), getRenderQueue(), gpuOnline()])
if (!alive) return
setAssets(lib.assets.filter(a => a.status !== 'deleted').slice(0, 24))
setJobs(q.jobs); setGpu(on)
}
tick()
const t = setInterval(tick, 5000)
return () => { alive = false; clearInterval(t) }
}, [])
useEffect(() => { scroller.current?.scrollTo(0, scroller.current.scrollHeight) }, [messages, busy])
async function send() {
const text = input.trim()
if (!text || busy) return
const next = [...messages, { role: 'user' as const, content: text }]
setMessages(next); setInput(''); setBusy(true)
try {
const r = await chat(next)
const note = r.actions.length
? `\n\n${r.actions.map(a => a.error ? `⚠︎ ${a.name}: ${a.error}` : `${a.name} dispatched`).join('\n')}`
: ''
setMessages([...next, { role: 'assistant', content: (r.reply || '…') + note }])
} catch (e) {
setMessages([...next, { role: 'assistant', content: `Couldn't reach the studio chat (${(e as Error).message}).` }])
} finally {
setBusy(false)
}
}
return (
<div className="app">
<header className="top">
<div className="brand">Hanzo <b>Studio</b></div>
<div className="spacer" />
<div className={`gpu ${gpu ? 'on' : 'off'}`}>{gpu ? 'GPU online' : 'no GPU'}</div>
<div className="who">{session.authenticated ? (session.org || session.user || 'signed in') : 'sign in'}</div>
</header>
<main className="body">
<section className="chat">
<div className="scroll" ref={scroller}>
{messages.length === 0 && (
<div className="hero">
<h1>What should we create?</h1>
<p>Describe an edit or a new shot make the Valentina back crotchless,
put design 13 on model 01. Ill run it on your GPU and it lands below.</p>
</div>
)}
{messages.map((m, i) => (
<div key={i} className={`msg ${m.role}`}><div className="bubble">{m.content}</div></div>
))}
{busy && <div className="msg assistant"><div className="bubble dim">working</div></div>}
</div>
<div className="composer">
<textarea
value={input} placeholder="Describe what to create or change…"
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }}
/>
<button onClick={send} disabled={busy || !input.trim()}>Send</button>
</div>
</section>
<aside className="rail">
{jobs.length > 0 && (
<div className="queue">
<div className="rail-h">Rendering ({jobs.length})</div>
{jobs.map(jb => <div key={jb.id} className="job">{jb.prefix || jb.id.slice(0, 8)}</div>)}
</div>
)}
<div className="rail-h">Library</div>
<div className="grid">
{assets.map(a => (
<a key={a.path} className="cell" href={thumbURL(a.path)} target="_blank" rel="noreferrer">
<img loading="lazy" src={thumbURL(a.path)} alt="" />
</a>
))}
{assets.length === 0 && <div className="empty">Renders appear here.</div>}
</div>
</aside>
</main>
</div>
)
}
+52
View File
@@ -0,0 +1,52 @@
// The studio-chat data layer. Two backends, no proxy layer between them:
// - the cloud chat orchestrator: POST api.hanzo.ai/v1/chat (clients/chat) —
// called DIRECTLY, no studio pass-through. The hanzo_token cookie is scoped to
// .hanzo.ai so credentials:'include' carries it cross-origin, and the handler
// replays it into the per-org-billed completion. (Gateway CORS must allow the
// studio.hanzo.ai origin with credentials.)
// - the studio engine API: /v1/session, /v1/library, /v1/render-queue, … —
// same-origin on studio.hanzo.ai (same cookie).
// No token handling in the browser: the cookie is the credential for both.
// VITE_STUDIO_API / VITE_CHAT_API override the bases for local dev.
const BASE = (import.meta.env.VITE_STUDIO_API ?? '').replace(/\/$/, '')
const CHAT_API = (import.meta.env.VITE_CHAT_API ?? 'https://api.hanzo.ai').replace(/\/$/, '')
async function j<T>(url: string, init?: RequestInit): Promise<T> {
const r = await fetch(url, { credentials: 'include', ...init })
if (!r.ok) throw new Error(`${url} -> ${r.status}`)
return r.json() as Promise<T>
}
export type Role = 'user' | 'assistant'
export interface ChatMessage { role: Role; content: string }
export interface Action { name: string; args?: Record<string, unknown>; result?: unknown; error?: string }
export interface Op { name: string; args?: Record<string, unknown> }
export interface ChatReply { reply: string; actions: Action[]; ops: Op[] }
// One turn of the create capability: the model edits/combines library assets;
// `actions` are the renders it dispatched (each result carries a prompt_id).
export function chat(messages: ChatMessage[], capability = 'create'): Promise<ChatReply> {
return j<ChatReply>(`${CHAT_API}/v1/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ capability, messages }),
})
}
export interface Session {
user?: string; email?: string; org?: string; orgs?: string[]
authenticated?: boolean; iam_url?: string
}
export const getSession = () => j<Session>(`${BASE}/v1/session`).catch(() => ({ authenticated: false } as Session))
export interface Asset { path: string; status?: string; mtime?: number; view?: unknown }
export const getLibrary = () => j<{ org: string; assets: Asset[] }>(`${BASE}/v1/library`).catch(() => ({ org: '', assets: [] }))
export interface RenderJob { id: string; prefix?: string; refs?: string[]; ts?: number; age?: number; status?: string }
export const getRenderQueue = () => j<{ jobs: RenderJob[] }>(`${BASE}/v1/render-queue`).catch(() => ({ jobs: [] }))
export const gpuOnline = () => j<{ online: boolean }>(`${BASE}/v1/gpu-status`).then(r => r.online).catch(() => false)
// Thumbnail URL for a library asset (the studio pod serves a downsized JPEG).
export const thumbURL = (path: string) => `${BASE}/v1/library/file?thumb=1&path=${encodeURIComponent(path)}`
export const fileURL = (path: string) => `${BASE}/v1/library/file?path=${encodeURIComponent(path)}`
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import './styles.css'
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+48
View File
@@ -0,0 +1,48 @@
:root {
--bg: #0d0d0f; --panel: #151517; --line: #26262b; --text: #f4f4f6;
--dim: #9a9aa2; --accent: #f4f4f6; --accent-ink: #0d0d0f;
}
@media (prefers-color-scheme: light) {
:root { --bg: #fafafa; --panel: #fff; --line: #e6e6ea; --text: #16161a; --dim: #6b6b73; --accent: #16161a; --accent-ink: #fff; }
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body { background: var(--bg); color: var(--text); font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.app { display: flex; flex-direction: column; height: 100%; }
.top { display: flex; align-items: center; gap: 12px; padding: 12px 18px; border-bottom: 1px solid var(--line); }
.brand { font-weight: 500; letter-spacing: .2px; } .brand b { font-weight: 700; }
.spacer { flex: 1; }
.gpu { font-size: 12px; padding: 3px 8px; border-radius: 999px; border: 1px solid var(--line); color: var(--dim); }
.gpu.on { color: var(--text); } .gpu.on::before { content: "● "; color: #35c26b; }
.who { font-size: 13px; color: var(--dim); }
.body { flex: 1; display: grid; grid-template-columns: 1fr 320px; min-height: 0; }
.chat { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--line); }
.scroll { flex: 1; overflow-y: auto; padding: 22px; }
.hero { max-width: 560px; margin: 8vh auto 0; text-align: center; }
.hero h1 { font-size: 30px; font-weight: 600; margin: 0 0 10px; }
.hero p { color: var(--dim); }
.msg { display: flex; margin: 10px 0; } .msg.user { justify-content: flex-end; }
.bubble { max-width: 74%; padding: 10px 14px; border-radius: 14px; white-space: pre-wrap; border: 1px solid var(--line); background: var(--panel); }
.msg.user .bubble { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
.bubble.dim { color: var(--dim); }
.composer { display: flex; gap: 10px; padding: 14px; border-top: 1px solid var(--line); }
.composer textarea { flex: 1; resize: none; height: 44px; padding: 11px 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); color: var(--text); font: inherit; }
.composer button { padding: 0 18px; border-radius: 12px; border: 0; background: var(--accent); color: var(--accent-ink); font-weight: 600; cursor: pointer; }
.composer button:disabled { opacity: .5; cursor: default; }
.rail { overflow-y: auto; padding: 16px; }
.rail-h { font-size: 12px; text-transform: uppercase; letter-spacing: .6px; color: var(--dim); margin: 14px 0 8px; }
.queue .job { font-size: 12px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 8px; margin-bottom: 6px; color: var(--dim); }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
.cell { aspect-ratio: 3 / 4; overflow: hidden; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); }
.cell img { width: 100%; height: 100%; object-fit: cover; display: block; }
.empty { color: var(--dim); font-size: 13px; }
@media (max-width: 820px) {
.body { grid-template-columns: 1fr; }
.rail { display: none; }
.chat { border-right: 0; }
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// Served same-origin by the studio Python pod at /studio, so the app is based at
// /studio/ and every /v1/* fetch rides the same-origin hanzo_token cookie (no CORS).
export default defineConfig({
base: '/studio/',
plugins: [react()],
build: { outDir: 'dist', sourcemap: false },
})
+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")