Compare commits

...
Author SHA1 Message Date
Hanzo AI ffea1234a8 upload: 100MB cap — 4k masters run 30-60MB and belong in the library 2026-07-17 12:21:51 -07:00
9993da806e chat: surface the server's error message — a 402 says where to add credits (#19)
Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 12:14:44 -07:00
b5bbbfd724 library: dotfiles are never assets (#18)
The indexer skips them; _safe_asset refuses them (one gate for file serving,
fix sources, and every other path lookup). AppleDouble forks carry image
extensions, serve resource-fork bytes, and rendered 0x0 across the library.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 12:14:02 -07:00
73abeb1109 chat: same-origin /v1/agent bridge — the cookie never leaves the host (#17)
The SPA called the gateway cross-origin; the session cookie is host-only, so
every signed-in user got 403 on their first message (401 once the cookie was
widened — the gateway takes bearers, not cookies). The bridge forwards the
caller's bearer server-side, the wallet pattern. VITE_AGENT_API still selects
direct mode.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 11:52:55 -07:00
Hanzo AI f75cb4b0cc studio-chat: keep renders reachable on mobile (library becomes a bottom strip)
On a phone (<=820px) the library rail was display:none, so a user could chat and
dispatch renders but never see them. Now the body stacks (chat fills, library
drops under it) and the rail shows as a compact scrollable bottom strip — but only
when there is something to show (jobs or assets set the `active` class), so the
empty chat state stays clean. Verified with Playwright at iPhone 14 (390x844):
empty = clean chat; with renders = 4-col strip under the composer, no overflow.
2026-07-17 09:38:42 -07:00
c2f16445f2 upload: reject dotfiles and sniff magic bytes (#16)
* upload: reject dotfiles and sniff magic bytes — the name lies, the bytes never do

AppleDouble forks (._foo.png) passed the extension check; 700+ poisoned a
library within an hour of the mirror going live. PNG/JPEG/WebP signatures
required; hidden names rejected.

* upload: size cap precedes the sniff; test payloads carry the full PNG signature

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 01:18:17 -07:00
Hanzo AI 2bf5421313 studio: serve the /v1/agent chat SPA (web/dist) at /studio
The studio-chat frontend (web/, Vite on @hanzo/ai talking to POST /v1/agent) was
repointed + committed but never served. Build it in a node stage of the Dockerfile
(-> /app/web/dist) and serve it at GET /studio, with GET /studio/assets/{name} for
the hashed bundle (immutable) and a traversal guard. Falls back to the legacy
studio_home.html when the build is absent so /studio is never a dark page. This is
the decided cutover: studio.hanzo.ai/studio becomes the /v1/agent chat app.
2026-07-17 01:13:51 -07:00
93688cac99 worklog: widen ?q= search to paths + refs, not just prompt (#15)
An ingested render row has an empty prompt, so filtering Queue & History by
filename found nothing. Widen the /v1/worklog ?q= filter to also match kind,
output_prefix, output (the landed path), and refs — so a render is findable by
its filename or source image, not only by a typed prompt. Test covers filename,
ref, and prompt matches.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 00:49:53 -07:00
be313e8068 Library ingest: POST /v1/library/upload — every render lands in the library (#13)
* Library ingest: POST /v1/library/upload — every render lands in the library

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 20:44:50 -07:00
96a633d3ce render: cap startToClose at 4h — the tasks default (~1h) reaped live renders (#12)
Liveness is the 1200s heartbeat; the cap only bounds a live-but-stuck render.
Observed: activities reaped at ~68m mid-render, queue re-ran the same job six
times, results stranded on the node.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 19:31:45 -07:00
722ea7fd39 Queue transparency: which node runs it + position/ETA/elapsed (#10)
Makes the unified queue honest about WHERE and WHEN, all server-computed and
tenant-scoped (an org sees only its own jobs' positions and its own nodes).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

docs/federation.md rewritten to the durable-queue reality with one future
paragraph (remote Tasks backend + Go/unified-binary HIP-0106 migration + leased
machines). Tests: middleware_test/{tasks_queue,engine_selector}_test.py, incl.
crash-recovery across queue instances.
2026-07-03 15:43:42 -07:00
hanzo-dev cea6d8029e ci: target arc scale set hanzo-build-linux-amd64 by name
ci@v1 defaults runs-on to [self-hosted,linux,amd64]; ARC scale sets are
matched by installation name, so the job sat queued with no runner. Pin the
runner to the hanzo build pool (same as cloud/release.yml).
2026-07-03 15:27:03 -07:00
hanzo-dev e632401bc5 ci: drop legacy deploy.yml (superseded by cicd.yml); make ruff green
- Remove .github/workflows/deploy.yml — the canonical build+test+deploy path
  is cicd.yml (imports hanzoai/ci build.yml@v1, reads hanzo.yml). One way to
  build. The old Docker workflow called hanzoai/.github docker-build.yml and
  failed at 0s.
- prompt_queue_persist_test.py: drop the 4 diagnostic prints (T201); pytest
  reports pass/fail and the standalone runner raises on failure.
- zen_video_adapter.py: remove unused json/asyncio imports (F401).
2026-07-03 15:13:51 -07:00
hanzo-dev b521933ca3 fix(server): graceful SIGTERM/SIGINT shutdown + crash-durable queue
Root cause of the "Event loop stopped before Future completed" crash that
wiped the in-memory queue/history (seen 3x today): the fork's _graceful_shutdown
handler (added in a45a441) called event_loop.stop(). The server coroutine passed
to run_until_complete() never completes on its own, so stopping the loop makes
run_until_complete() raise RuntimeError; the surrounding try only caught
KeyboardInterrupt, so the process died non-zero with a traceback and discarded
the queue. The dirty exit also failed to release :8188 promptly, and a duplicate
transient/user `hanzo-studio.service` racing the canonical studio.service turned
that into an EADDRINUSE restart storm.

main.py:
- handler is now idempotent, flags the queue closed, snapshots it, then stops
  the loop; the outer try treats RuntimeError('Event loop stopped before Future
  completed.') as the expected graceful-exit path (logs "Server stopped by
  shutdown signal", exits 0). Verified live in journald.

execution.py:
- opt-in crash-durable PromptQueue behind STUDIO_PERSIST_QUEUE=1. Pending +
  in-flight prompts are snapshotted atomically to user/queue_snapshot.json on
  every change and re-queued on boot. Best-effort: all I/O is guarded so it
  never affects the render path; inert when the flag is unset.

ops/systemd/studio.service:
- enable STUDIO_PERSIST_QUEUE=1 on the single canonical unit.

scripts/studio-service-swap.sh:
- batch-safe reconcile+apply: waits for an empty queue, removes any stray
  duplicate unit, reinstalls the canonical unit, restarts, health-checks.

docs/ops-crash-and-service.md:
- topology (one canonical unit), how to read crash history, the fix, persistence.

tests-unit/execution_test/prompt_queue_persist_test.py:
- round-trip, inert-when-disabled, and corrupt-row-skipping tests.
2026-07-03 14:42:03 -07:00
hanzo-dev 748e335add studio: native IAM auth + multi-tenant isolation + GPU federation
Auth (middleware/iam_auth_middleware.py): local JWKS JWT validation against
Hanzo IAM (signature + exp + iss + aud=hanzo-studio; org from the `owner`
claim). Standard OIDC Authorization Code flow with PKCE and a /callback
session cookie for the standalone studio.hanzo.ai app; anonymous localhost
bypass unchanged. verify_jwt() extracted as a pure, unit-tested function.

Tenancy (folder_paths.py, app/user_manager.py): make get_public_user_directory
org-rooted so multi-tenant userdata resolves under user/orgs/{org}/... (it
previously always returned None). Scope execution outputs to output/orgs/{org}/
via set_execution_org(), bound by the prompt worker from the queue item org_id.
Also block a token subject from escaping into a System User namespace.

Federation (middleware/{worker_client,prompt_router}.py, server.py): move the
worker registry + execute surface to /v1/workers/register, /v1/workers,
/v1/worker/execute; add X-Worker-Token coordinator trust. Outbound pull channel
for NAT'd local boxes (GB10) specced in docs/federation.md.

Deploy: hanzo.yml + .github/workflows/cicd.yml (hanzoai/ci reusable) build
ghcr.io/hanzoai/studio and roll the studio operator Service CR at
studio.hanzo.ai. PyJWT[crypto] pinned.

Tests: tests-unit/middleware_test/iam_auth_test.py (RS256 sign/verify, state
CSRF, path exemptions) + folder_paths_test/org_scoping_test.py; 140 passing
across the auth/tenancy/user-manager/prompt-server suites.
2026-07-03 14:38:19 -07:00
hanzo-dev 8d74ce9fca model-shot workflows: anoeses-style calm luxury (pure white, front/back poses, pose menu in notes) 2026-07-03 13:20:59 -07:00
hanzo-dev 7fc06c38c9 website catalog architecture: no-body product base + dynamic-model hover variants (8 designs) + workspace seeder
Anoeses-style PDP pattern: product_<design> = garment-only ghost presentation on pure white (base image); hover_<design> = model in dynamic pose on subtle white studio (rollover image). Workspace: index.html seeds Comfy.OpenWorkflowsPaths with all 25 fashion tabs on first load (set-once, localStorage); Comfy.Workflow.Persist on. Seeder added to apply-branding.sh replay path.
2026-07-03 13:12:34 -07:00
hanzo-dev c2f99d072d fashion_product_shot: fix stale model note, point designers at per-design tabs 2026-07-03 12:49:03 -07:00
hanzo-dev e5132a2d7f fashion workflows: per-design shoot_/product_ sets with proven Qwen-Image-Edit-2511 recipe
17 workflows (8 lifestyle shoots + 8 white-seamless product shots + base CAD->lifestyle), each: FluxKontextImageScale 1MP ref, ModelSamplingAuraFlow 3.1, ref-method(index_timestep_zero) on pos+neg conditioning, CFGNorm, EmptySD3Latent 1024x1536, cfg 4.0, randomized seeds, per-design output dirs. UI-lane proof: shot_00004 matches scripted-lane quality (design-exact festival_fuchsia on beach). Rate limiter: default unlimited via STUDIO_RPM env (single-user box).
2026-07-03 12:27:05 -07:00
hanzo-dev ff0dee00a4 studio: add ready-to-run Antje CAD -> lifestyle workflow (Qwen-Image-Edit-2511)
Native TextEncodeQwenImageEditPlus path wired against the live /object_info
schema: LoadImage -> Plus encoder (image1+vae) + VAEEncode -> KSampler
(30 steps, cfg 2.5, euler/simple) -> VAEDecode -> SaveImage swimwear/antje.
Models installed under models/{diffusion_models,text_encoders,vae} (merged
from the Qwen-Image-Edit-2511 diffusers shards, gitignored). 8 of Antje's
CAD drawings preloaded in input/ and enumerated by LoadImage.
2026-07-03 09:10:22 -07:00
hanzo-dev ca9a72f2a7 studio: finish Hanzo rebrand — sidebar mark, black+purple theme, progress favicon
- Sidebar logo: replace the inline ComfyLogo Vue component (top-left mark,
  compiled into the JS bundle, missed by the file-asset pass) with the Hanzo
  mark via branding/patch_sidebar_logo.py (keyed off the stable __name:`ComfyLogo`).
- Theme: branding/hanzo-theme.css forces a pure-black shell + Hanzo-purple
  accents (AA contrast), injected as the last stylesheet; patch_theme.py also
  rewrites the dark palette's canvas clear color to #000000 (CSS can't reach the
  litegraph <canvas>).
- Favicon: make_progress_favicons.py renders the 10 progress frames with the
  Hanzo mark + purple ring, fixing the tab reverting to the Comfy mark during
  generations. Committed PNGs like favicon.ico (Docker needs no rasterizer).
- Tab title: rebrand the runtime ' - ComfyUI' title suffix the string pass missed.
- apply-branding.sh replays all of the above on frontend-package upgrades.
- Rename fork-owned update_comfyui{,_stable}.bat -> update{,_stable}.bat.
2026-07-03 02:20:33 -07:00
hanzo-dev 1ff6280992 Rebrand fork to Hanzo Studio: licensing, branding, Engine nodes, fashion workflows
Licensing (GPL-3.0 preserved):
- NOTICE attributes the ComfyUI upstream and the fork's modifications, GPL-3.0.
- README credits upstream ComfyUI prominently and states GPL-3.0.

Branding (frontend served from the comfyui-frontend-package static dir):
- apply-branding.sh installs the Hanzo favicon (svg + multi-res ico), injects
  icon links, sets <title>Hanzo Studio</title>, and patches display strings.
- make_favicon.py renders the committed favicon.ico from favicon.svg.

Hanzo Engine nodes (custom_nodes/hanzo_engine/, OpenAI-compatible HTTP):
- HanzoChat, HanzoImageGen, HanzoVisionCaption, HanzoSaveText. Graceful node
  errors on connection failure.

Fashion/swimwear starter workflows (user/default/workflows/fashion/):
- fashion_product_shot (FLUX.2 klein t2i), fashion_edit_garment
  (Qwen-Image-Edit i2i), fashion_caption_dataset (Hanzo vision caption loop).
  Each carries a Note node with usage and required files.
2026-07-02 23:17:57 -07:00
z a45a4413dd docs(brand): add hero banner 2026-06-28 20:06:25 -07:00
z 050911912f chore(brand): dynamic hero banner 2026-06-28 20:06:24 -07:00
a025d1236c fix(iam): migrate get-account to OIDC /v1/iam/oauth/userinfo (HIP-0111) (#4)
Co-authored-by: Zach Kelling <z@zeekay.io>
2026-06-24 19:16:12 -07:00
Antje WorringandClaude Opus 4.8 2ecd73d1f0 docs: tidy LLM.md indexes; CLAUDE.md -> LLM.md symlink convention
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 09:23:06 -07:00
Hanzo DevandGitHub 7b6b0115e8 ci: migrate to canonical hanzoai/.github/docker-build.yml reusable (#1) 2026-04-23 19:06:49 -07:00
Hanzo AI 3a30e79cfa chore: symlink AGENTS.md and CLAUDE.md to LLM.md
Canonical project context lives in LLM.md. Symlinks ensure
agentic coding tools (Claude Code, Cursor, etc.) find context
automatically regardless of which filename they look for.
2026-04-01 14:14:38 -07:00
Hanzo AI 5354c63777 ci: update GitHub Actions workflows 2026-03-03 14:00:00 -08:00
Hanzo Dev 110139fb28 fix: inline deploy with KMS auth (replace broken reusable workflow ref) 2026-03-12 00:13:02 -07:00
Hanzo Dev c24dee9a5e fix: use secrets inherit for reusable workflow 2026-03-12 00:10:50 -07:00
Hanzo Dev fcc30e1894 fix: use KMS Universal Auth for deploy (replace legacy HANZO_API_KEY) 2026-03-12 00:03:28 -07:00
Hanzo Dev 528f997fc4 ci: migrate deploy to HANZO_API_KEY + KMS via reusable workflow
Replace direct DO_API_TOKEN with reusable-deploy-service.yml from
hanzoai/universe that fetches credentials from KMS using HANZO_API_KEY.
2026-03-11 14:34:53 -07:00
Hanzo Dev 83a9fc6536 docs: add LLM.md project guide 2026-03-11 11:04:48 -07:00
Hanzo Dev bfee9e4b07 Redesign login page to monochrome style matching hanzo.ai
Black background, white text, opacity-based visual hierarchy,
pill-shaped CTA button, Inter font, feature cards with subtle
white/5-10% backgrounds. Removes red accent entirely.
2026-02-23 15:31:30 -08:00
Hanzo Dev dafd40026c Fix lint: remove unused imports in main.py and billing_middleware.py 2026-02-23 15:21:20 -08:00
Hanzo Dev ece384a3d0 Add GPU worker system, compute config API, and login gate
- Compute profiles: per-org CPU/GPU config stored in user dir with
  thread-safe CRUD and atomic file writes
- Prompt routing: forwards GPU prompts to registered workers, falls
  back to local execution when no worker available
- Worker mode: --worker-mode flag runs Studio as headless executor
  that registers with coordinator via heartbeats
- Visor integration: provision/terminate GPU VMs on AWS (T4/V100/A100/H100)
  with shell-safe startup scripts
- Login page: unauthenticated browsers see branded login gate with
  "Sign in with Hanzo" button instead of broken ComfyUI interface
- 6 new API endpoints under /api/compute/*
2026-02-23 15:17:59 -08:00
Hanzo Dev c43cab93ba Fix metrics endpoint content_type for aiohttp compatibility 2026-02-23 14:31:07 -08:00
Hanzo Dev 91654b973a Add metrics, billing, and rate limiting middleware
- /metrics endpoint (Prometheus text format) for VictoriaMetrics scraping
- Commerce billing integration: usage recording after prompt execution,
  balance check before accepting prompts (402 Payment Required)
- Per-org rate limiting on prompt submissions (default 60 rpm)
- All features toggled via STUDIO_* env vars for K8s deployment
- Metrics tracks: prompts total/active, queue depth, websocket connections,
  execution duration histogram, HTTP request counters
2026-02-23 14:17:24 -08:00
Hanzo Dev 8c9652bb78 Fix P0 production readiness issues
- Add /health and /ready endpoints for K8s probes
- Add SIGTERM graceful shutdown handler in main.py
- Fix IAM middleware: shared aiohttp session (not per-request),
  60s token validation cache, stale entry eviction
- Remove /api/system_stats from unauthenticated public paths
- Update .dockerignore to exclude tests, scripts, temp data
2026-02-23 13:17:32 -08:00
Hanzo Dev eb5935be5f Exclude build scripts from ruff linting
scripts/ and branding/ are build-time utilities, not runtime code.
2026-02-23 13:10:59 -08:00
Hanzo Dev 79f0bf36a1 Add IAM auth, multi-tenant storage, and fix frontend branding
- Fix branding patcher to target actual upstream logo filenames
  (comfy-logo-*.svg, not studio-logo-*.svg)
- Add more display string patterns to patch_frontend.py
- Add IAM authentication middleware (hanzo.id/api/get-account)
  with localhost bypass and browser login redirect
- Add multi-tenant org-scoped storage (folder_paths.get_org_*)
  with per-org output/input/temp/user/models directories
- Add CLI flags: --enable-iam-auth, --iam-url, --multi-tenant,
  --org-id, --no-localhost-bypass
- Support env var toggles for Docker: STUDIO_ENABLE_IAM_AUTH,
  STUDIO_MULTI_TENANT, STUDIO_ORG_ID, STUDIO_IAM_URL
- Integrate IAM user context into UserManager and server routes
2026-02-23 13:00:26 -08:00
Hanzo Dev 5cf6ce2508 Rename all internal modules: comfy* → studio*
Complete module rename across 552 files:
- comfy/ → studio/
- comfy_extras/ → studio_extras/
- comfy_api/ → studio_api/
- comfy_api_nodes/ → studio_api_nodes/
- comfy_config/ → studio_config/
- comfy_execution/ → studio_execution/
- comfy/comfy_types/ → studio/node_types/
- comfyui_version.py → studio_version.py
- All test directories renamed
- All import statements updated
- All internal identifiers renamed (ComfyNodeABC → StudioNodeABC, etc.)
- All wire protocol types renamed (COMFY_* → STUDIO_*)
- No backward compatibility shims — clean break
- External pip packages preserved (comfyui-frontend-package, comfy-kitchen, etc.)
- 518 Python files syntax-validated, 0 errors
2026-02-23 12:49:38 -08:00
Hanzo Dev fdb4b5aab8 Complete Hanzo Studio rebrand: purge all remaining ComfyUI references
99 files changed across the entire codebase:
- CLI help strings, error messages, and display text
- README.md fully rewritten for Hanzo Studio
- CONTRIBUTING.md, CODEOWNERS, issue templates updated
- All GitHub workflow references updated
- Default output filename prefixes (ComfyUI -> HanzoStudio)
- .ci/ batch files and READMEs for Windows portable builds
- Core file headers ("This file is part of Hanzo Studio")
- API docs URLs (docs.comfy.org -> docs.hanzo.ai)
- API base URL default (api.comfy.org -> api.hanzo.ai)
- comfy_extras node descriptions and default paths
- Test file comments and docstrings
- extra_model_paths.yaml.example config comments

Preserved as-is (would break imports/packages):
- Python package names (comfyui_frontend_package, comfyui_manager, etc.)
- Internal module paths (comfy/, comfy_extras/, comfy_api/, etc.)
- API contract values (AUTH_TOKEN_COMFY_ORG, etc.)
- Third-party source attribution URLs
- custom_nodes/ directory contents
2026-02-23 10:13:55 -08:00
Hanzo Dev 4f0ddab1b8 Refine branding patcher: remove catch-all Comfy-Org replacement
- Remove bare 'Comfy-Org' and 'ComfyOrg' from plain text replacements
  (was corrupting i18n keys like OpenComfy-OrgDiscord)
- Only replace Comfy-Org/ComfyOrg in multi-word display phrases
- Add regex patterns for quoted string value contexts
- Add standalone quoted value patterns ("ComfyUI" → "Hanzo Studio")
- Add Comfy Cloud → Hanzo Cloud display text replacement
2026-02-23 09:51:59 -08:00
Hanzo Dev ee7da07a4e Fix branding: use smart Python patcher instead of blind sed
The previous sed-based approach broke JavaScript by replacing code
identifiers (class names, import paths, i18n keys) alongside display
strings. The new Python patcher:

- Only replaces display strings within quoted contexts
- Safely replaces all URLs (comfy.org → hanzo.ai, Discord, GitHub)
- Preserves JS class names (ComfyUI, ComfyApp, etc.)
- Preserves CSS class names (comfyui-button, etc.)
- Preserves dynamic import paths (./ComfyOrgHeader-*.js)
- Preserves i18n keys while updating i18n values
- Recursively patches all files including extensions/
2026-02-23 09:46:46 -08:00
Hanzo Dev 4526acbfa6 Comprehensive rebrand: ComfyUI → Hanzo Studio
- Replace all user-facing "ComfyUI" strings with "Hanzo Studio"
- Update Help menu links: Discord → discord.gg/hanzoai, GitHub → hanzoai/studio
- Replace comfy.org URLs with hanzo.ai equivalents
- Update support email to support@hanzo.ai
- Rewrite branding script to patch ALL JS bundles comprehensively
- Update default filename prefixes from ComfyUI to HanzoStudio
- Fix CI to trigger on main branch (not hanzo/main)
- Update pyproject.toml metadata (name, URLs)
2026-02-23 09:32:01 -08:00
Hanzo Dev ee087ae50e Rebrand ComfyUI to Hanzo Studio
- Replace ComfyUI logo with Hanzo geometric H mark
- Replace favicon with Hanzo H favicon
- Patch index.html title, PWA manifest, and JS bundles
- Update AboutPanel and OrgHeader references
- Branding applied as Docker build step after pip install
2026-02-23 09:21:18 -08:00
Hanzo Dev e6873292a6 Add --cpu flag to ComfyUI startup for non-CUDA environments
ComfyUI's model_management crashes on startup without CUDA unless
--cpu flag is passed. GPU deployments will override CMD.
2026-02-23 01:54:06 -08:00
Hanzo Dev 54b02fac6c Fix Dockerfile: libgl1-mesa-glx obsoleted in Debian Trixie, use libgl1 2026-02-23 01:38:56 -08:00
Hanzo Dev cd55589499 Fix CI trigger branch to hanzo/main 2026-02-23 01:37:48 -08:00
Hanzo Dev d816711e01 Add Dockerfile, CI/CD, and .dockerignore for Hanzo Studio
ComfyUI deployment via GHCR + K8s on hanzo-k8s cluster.
CPU-only base image, GPU support via nvidia runtime at deploy.
2026-02-23 01:36:42 -08:00
comfyanonymousandGitHub f7a591ffa2 Fix issue loading fp8 ltxav checkpoints. (#12582) 2026-02-22 16:00:02 -05:00
comfyanonymousandGitHub 4e31960261 Fix dtype issue in embeddings connector. (#12570) 2026-02-22 03:18:20 -05:00
comfyanonymousandGitHub dd19d1b91a Move LTXAV av embedding connectors to diffusion model. (#12569) 2026-02-21 22:29:58 -05:00
Christian ByrneandGitHub 2c8d4e628f chore: tune CodeRabbit config to limit review scope and disable for drafts (#12567)
* chore: tune CodeRabbit config to limit review scope and disable for drafts

- Add tone_instructions to focus only on newly introduced issues
- Add global path_instructions entry to ignore pre-existing issues in moved/reformatted code
- Disable draft PR reviews (drafts: false) and add WIP title keywords
- Disable ruff tool to prevent linter-based outside-diff-range comments

Addresses feedback from maintainers about CodeRabbit flagging pre-existing
issues in code that was merely moved or de-indented (e.g., PR #12557),
which can discourage community contributions and cause scope creep.

Amp-Thread-ID: https://ampcode.com/threads/T-019c82de-0481-7253-ad42-20cb595bb1ba

* chore: add 'DO NOT MERGE' to ignore_title_keywords

Amp-Thread-ID: https://ampcode.com/threads/T-019c82de-0481-7253-ad42-20cb595bb1ba
2026-02-21 18:32:15 -08:00
Christian ByrneandGitHub 9668d4d48b Add category to Normalized Attention Guidance node (#12565) 2026-02-21 19:51:21 -05:00
664f312d50 fix: specify UTF-8 encoding when reading subgraph files (#12563)
On Windows, Python defaults to cp1252 encoding when no encoding is
specified. JSON files containing UTF-8 characters (e.g., non-ASCII
characters) cause UnicodeDecodeError when read with cp1252.

This fixes the error that occurs when loading blueprint subgraphs
on Windows systems.

https://claude.ai/code/session_014WHi3SL9Gzsi3U6kbSjbSb

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-21 15:05:00 -08:00
rattusandGitHub a8eac8219c comfy-aimdo 0.2 - Improved pytorch allocator integration (#12557)
Integrate comfy-aimdo 0.2 which takes a different approach to
installing the memory allocator hook. Instead of using the complicated
and buggy pytorch MemPool+CudaPluggableAlloctor, cuda is directly hooked
making the process much more transparent to both comfy and pytorch. As
far as pytorch knows, aimdo doesnt exist anymore, and just operates
behind the scenes.

Remove all the mempool setup stuff for dynamic_vram and bump the
comfy-aimdo version. Remove the allocator object from memory_management
and demote its use as an enablment check to a boolean flag.

Comfy-aimdo 0.2 also support the pytorch cuda async allocator, so
remove the dynamic_vram based force disablement of cuda_malloc and
just go back to the old settings of allocators based on command line
input.
2026-02-21 10:52:57 -08:00
pythongosssssandGitHub b777ae17bc add support for pyopengl < 3.1.4 where the size parameter does not exist (#12555) 2026-02-21 06:14:57 -08:00
9e4b3e8cd6 fix: swap essentials_category from CLIPTextEncode to PrimitiveStringMultiline (#12553)
Remove CLIPTextEncode from Basics essentials category and add
PrimitiveStringMultiline (String Multiline) in its place.

Amp-Thread-ID: https://ampcode.com/threads/T-019c7efb-d916-7244-8c43-77b615ba0622

Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-02-20 23:46:46 -08:00
Christian ByrneandGitHub 0ffa3f22ca feat: add essential subgraph blueprints (#12552)
Add 24 non-cloud essential blueprints from comfyui-wiki/Subgraph-Blueprints.
These cover common workflows: text/image/video generation, editing,
inpainting, outpainting, upscaling, depth maps, pose, captioning, and more.

Cloud-only blueprints (5) are excluded and will be added once
client-side distribution filtering lands.

Amp-Thread-ID: https://ampcode.com/threads/T-019c6f43-6212-7308-bea6-bfc35a486cbf
2026-02-20 23:40:13 -08:00
2b512fd559 update glsl blueprint with gradient (#12548)
Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-02-20 23:02:36 -08:00
4f2e01051c feat: add gradient-slider display mode for FLOAT inputs (#12536)
* feat: add gradient-slider display mode for FLOAT inputs

* fix: use precise type annotation list[list[float]] for gradient_stops

Amp-Thread-ID: https://ampcode.com/threads/T-019c7eea-be2b-72ce-a51f-838376f9b7a7

---------

Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
2026-02-20 22:52:32 -08:00
Arthur R LongbottomandGitHub 9677992b83 Fix non-contiguous audio waveform crash in video save (#12550)
Fixes #12549
2026-02-20 23:37:55 -05:00
comfyanonymousandGitHub 3ca8a3c278 Update nightly installation command for ROCm (#12547) 2026-02-20 21:32:05 -05:00
b558b8343a chore: add CodeRabbit configuration for automated code review (#12539)
Amp-Thread-ID: https://ampcode.com/threads/T-019c7915-2abf-743c-9c74-b93d87d63055

Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-02-20 00:25:54 -08:00
619ff184a4 [API Nodes] add ElevenLabs nodes (#12207)
* feat(api-nodes): add ElevenLabs API nodes

* added price badge for ElevenLabsInstantVoiceClone node

---------

Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-02-19 22:12:28 -08:00
ca26c5c104 Add GLSL shader node using PyOpenGL (#12148)
* adds support for executing simple glsl shaders
using moderngl package

* tidy

* Support multiple outputs

* Try fix build

* fix casing

* fix line endings

* convert to using PyOpenGL and glfw

* remove cpu support

* tidy

* add additional support for egl & osmesa backends

* fix ci
perf: only read required outputs

* add diagnostics, update mac initialization

* GLSL glueprints + node fixes (#12492)

* Add image operation blueprints

* Add channels

* Add glow

* brightness/contrast

* hsb

* add glsl shader update system

* shader nit iteration

* add multipass for faster blur

* more fixes

* rebuild blueprints

* print -> logger

* Add edge preserving blur

* fix: move _initialized flag to end of GLContext.__init__

Prevents '_vao' attribute error when init fails partway through
and subsequent calls skip initialization due to early _initialized flag.

* update valid ranges
- threshold 0-100
- step 0+

* fix value ranges

* rebuild node to remove extra inputs

* Fix gamma step

* clamp saturation in colorize instead of wrapping

* Fix crash on 1x1 px images

* rework description

* remove unnecessary f


Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
Co-authored-by: Hunter Senft-Grupp <hunter@comfy.org>
2026-02-19 23:22:13 -05:00
comfyanonymousandGitHub 287981e1c3 Force min length 1 when tokenizing for text generation. (#12538) 2026-02-19 22:57:44 -05:00
comfyanonymousandGitHub a73a5974b1 Small cleanup and try to get qwen 3 work with the text gen. (#12537) 2026-02-19 22:42:28 -05:00
47585f08c0 feat: mark 429 widgets as advanced for collapsible UI (#12197)
* feat: mark 429 widgets as advanced for collapsible UI

Mark widgets as advanced across core, comfy_extras, and comfy_api_nodes
to support the new collapsible advanced inputs section in the frontend.

Changes:
- 267 advanced markers in comfy_extras/
- 162 advanced markers in comfy_api_nodes/
- All files pass python3 -m py_compile verification

Widgets marked advanced (hidden by default):
- Scheduler internals: sigma_max, sigma_min, rho, mu, beta, alpha
- Sampler internals: eta, s_noise, order, rtol, atol, h_init, pcoeff, etc.
- Memory optimization: tile_size, overlap, temporal_size, temporal_overlap
- Pipeline controls: add_noise, start_at_step, end_at_step
- Timing controls: start_percent, end_percent
- Layer selection: stop_at_clip_layer, layers, block_number
- Video encoding: codec, crf, format
- Device/dtype: device, noise_device, dtype, weight_dtype

Widgets kept basic (always visible):
- Core params: strength, steps, cfg, denoise, seed, width, height
- Model selectors: ckpt_name, lora_name, vae_name, sampler_name
- Common controls: upscale_method, crop, batch_size, fps, opacity

Related: frontend PR #11939
Amp-Thread-ID: https://ampcode.com/threads/T-019c1734-6b61-702e-b333-f02c399963fc

* fix: remove advanced=True from DynamicCombo.Input (unsupported)

Amp-Thread-ID: https://ampcode.com/threads/T-019c1734-6b61-702e-b333-f02c399963fc

* fix: address review - un-mark model merge, video, image, and training node widgets as advanced

Per comfyanonymous review:
- Model merge arguments should not be advanced (all 14 model-specific merge classes)
- SaveAnimatedWEBP lossless/quality/method should not be advanced
- SaveWEBM/SaveVideo codec/crf/format should not be advanced
- TrainLoraNode options should not be advanced (7 inputs)

Amp-Thread-ID: https://ampcode.com/threads/T-019c322b-a3a8-71b7-9962-d44573ca6352

* fix: un-mark batch_size and webcam width/height as advanced (should stay basic)

Amp-Thread-ID: https://ampcode.com/threads/T-019c3236-1417-74aa-82a3-bcb365fbe9d1

---------

Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-02-19 19:20:02 -08:00
3e33a7508c feat: add essentials_category (#12357)
* feat: add essentials_category field to node schema

Amp-Thread-ID: https://ampcode.com/threads/T-019c2b25-cd90-7218-9071-03cb46b351b3

* feat: add ESSENTIALS_CATEGORY to core nodes

Marked nodes:
- Basic: LoadImage, SaveImage, LoadVideo, SaveVideo, Load3D, CLIPTextEncode
- Image Tools: ImageScale, ImageInvert, ImageBatch, ImageCrop, ImageRotate, ImageBlur
- Image Tools/Preprocessing: Canny
- Image Generation: LoraLoader
- Audio: LoadAudio, SaveAudio

Amp-Thread-ID: https://ampcode.com/threads/T-019c2b25-cd90-7218-9071-03cb46b351b3

* Add ESSENTIALS_CATEGORY to more nodes

- SaveGLB (Basic)
- GetVideoComponents (Video Tools)
- TencentTextToModelNode, TencentImageToModelNode (3D)
- RecraftRemoveBackgroundNode (Image Tools)
- KlingLipSyncAudioToVideoNode (Video Generation)
- OpenAIChatNode (Text Generation)
- StabilityTextToAudio (Audio)

Amp-Thread-ID: https://ampcode.com/threads/T-019c2b69-81c1-71c3-8096-450a39e20910

* fix: correct essentials category for Canny node

Amp-Thread-ID: https://ampcode.com/threads/T-019c7303-ab53-7341-be76-a5da1f7a657e
Co-authored-by: Amp <amp@ampcode.com>

* refactor: replace essentials_category string literals with constants

Amp-Thread-ID: https://ampcode.com/threads/T-019c7303-ab53-7341-be76-a5da1f7a657e
Co-authored-by: Amp <amp@ampcode.com>

* refactor: revert constants, use string literals for essentials_category

Amp-Thread-ID: https://ampcode.com/threads/T-019c7303-ab53-7341-be76-a5da1f7a657e
Co-authored-by: Amp <amp@ampcode.com>

* fix: update basics

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-02-19 19:00:26 -08:00
Alexander PiskunandGitHub f9da0e53be fix(api-nodes): force Gemini to return uncompressed images (#12516) 2026-02-19 04:10:39 -08:00
Jukka SeppänenandGitHub d3771ede35 feat: Add basic text generation support with native models, initially supporting Gemma3 (#12392) 2026-02-18 20:49:43 -05:00
comfyanonymousandGitHub 402c9bbeab Add simple 3 band equalizer node for audio. (#12519) 2026-02-18 18:36:35 -05:00
Alexander PiskunandGitHub 6ead1c10d3 fix(api-nodes): add price badge for Rodin Gen-2 node (#12512) 2026-02-17 23:15:23 -08:00
HunterandGitHub 8ee1cb2bdc fix: use glob matching for Gemini image MIME types (#12511)
gemini-3-pro-image-preview nondeterministically returns image/jpeg
instead of image/png. get_image_from_response() hardcoded
get_parts_by_type(response, "image/png"), silently dropping JPEG
responses and falling back to torch.zeros (all-black output).

Add _mime_matches() helper using fnmatch for glob-style MIME matching.
Change get_image_from_response() to request "image/*" so any image
format returned by the API is correctly captured.
2026-02-18 00:03:54 -05:00
1e5c7e99a1 BBox widget (#11594)
* Boundingbox widget

* code improve

---------

Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-17 17:13:39 -08:00
f3bf4ec617 Bump comfyui-frontend-package to 1.39.14 (#12494)
* Bump comfyui-frontend-package to 1.39.13

* Update requirements.txt

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-17 13:41:34 -08:00
rattusandGitHub 3f59f0576e ops: limit return of requants (#12506)
This check was far too broad and the dtype is not a reliable indicator
of wanting the requant (as QT returns the compute dtype as the dtype).
So explictly plumb whether fp8mm wants the requant or not.
2026-02-17 15:32:27 -05:00
comfyanonymous c5ba0ada07 ComfyUI v0.14.1 2026-02-17 13:28:06 -05:00
ComfyUI WikiandGitHub 696a980bf4 chore: update workflow templates to v0.8.43 (#12507) 2026-02-17 13:25:55 -05:00
Alexander PiskunandGitHub 270582a5af feat(api-nodes): add Recraft V4 nodes (#12502) 2026-02-17 13:25:44 -05:00
Alexander PiskunandGitHub 1b0600eba3 feat(api-nodes): add "viduq3-turbo" model and Vidu3StartEnd node; fix the price badges (#12482) 2026-02-17 10:07:14 -08:00
chaObservandGitHub 169bc124a7 Fix anima LLM adapter forward when manual cast (#12504) 2026-02-17 07:56:44 -08:00
comfyanonymous 0de6f548d2 ComfyUI v0.14.0 2026-02-17 00:39:54 -05:00
comfyanonymousandGitHub bc072f111d Fix anima preprocess text embeds not using right inference dtype. (#12501) 2026-02-17 00:29:20 -05:00
comfyanonymousandGitHub 9ff688ba3b Implement NAG on all the models based on the Flux code. (#12500)
Use the Normalized Attention Guidance node.

Flux, Flux2, Klein, Chroma, Chroma radiance, Hunyuan Video, etc..
2026-02-16 23:30:34 -05:00
Jedrzej KosinskiandGitHub 6072e457f1 Allow control_after_generate to be type ControlAfterGenerate in v3 schema (#12187) 2026-02-16 22:20:21 -05:00
Alex ButlerandGitHub e61e047bcf add venv* to gitignore (#12431) 2026-02-16 22:16:19 -05:00
comfyanonymousandGitHub 4836097ab7 Remove code to support RMSNorm on old pytorch. (#12499) 2026-02-16 20:09:24 -05:00
ComfyUI WikiandGitHub 1c540d6337 chore: update workflow templates to v0.8.42 (#12491) 2026-02-16 17:33:43 -05:00
comfyanonymousandGitHub 74cb3d8dd0 Remove workaround for old pytorch. (#12480) 2026-02-15 20:43:53 -05:00
rattusandGitHub 84377afb49 MPDynamic: force load flux img_in weight (Fixes flux1 canny+depth lora crash) (#12446)
* lora: add weight shape calculations.

This lets the loader know if a lora will change the shape of a weight
so it can take appropriate action.

* MPDynamic: force load flux img_in weight

This weight is a bit special, in that the lora changes its geometry.
This is rather unique, not handled by existing estimate and doesn't
work for either offloading or dynamic_vram.

Fix for dynamic_vram as a special case. Ideally we can fully precalculate
these lora geometry changes at load time, but just get these models
working first.
2026-02-15 20:30:09 -05:00
rattusandGitHub 1a37669cbb Fix lora Extraction in offload conditions (+ dynamic_vram mode) (#12479)
* lora_extract: Add a trange

If you bite off more than your GPU can chew, this kinda just hangs.
Give a rough indication of progress counting the weights in a trange.

* lora_extract: Support on-the-fly patching

Use the on-the-fly approach from the regular model saving logic for
lora extraction too. Switch off force_cast_weights accordingly.

This gets extraction working in dynamic vram while also supporting
extraction on GPU offloaded.
2026-02-15 20:28:51 -05:00
9611835ecf feat(api-nodes): add Bria RMBG nodes (#12465)
Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-02-15 17:22:30 -08:00
Alexander PiskunandGitHub 6e670fdc84 feat(api-nodes-Tencent): add ModelTo3DUV, 3DTextureEdit, 3DParts nodes (#12428)
* feat(api-nodes-Tencent): add ModelTo3DUV, 3DTextureEdit, 3DParts nodes

* add image output to TencentModelTo3DUV node

* commented out two nodes

* added rate_limit check to other hunyuan3d nodes
2026-02-15 05:33:18 -08:00
Jedrzej KosinskiandGitHub 5002b9e613 Node Replacement API (#12014) 2026-02-15 02:12:30 -08:00
Alexander PiskunandGitHub 6d9adf9174 chore(api-nodes): remove "gpt-4o" model (#12467) 2026-02-15 01:31:59 -08:00
comfyanonymousandGitHub 73ebbc1eff Remove unsafe pickle loading code that was used on pytorch older than 2.4 (#12473)
ComfyUI hasn't started on pytorch 2.4 since last month.
2026-02-14 22:53:52 -05:00
Christian ByrneandGitHub 7636ac692d Update frontend package to 1.38.14 (#12469) 2026-02-14 11:01:10 -08:00
krigetaandGitHub 25103970b1 Add working Qwen 2512 ControlNet (Fun ControlNet) support (#12359) 2026-02-13 22:23:52 -05:00
comfyanonymousandGitHub cfaf520b17 Add left padding to LTXAV text encoder. (#12456) 2026-02-13 21:56:54 -05:00
comfyanonymousandGitHub c3dbda505d Fix some custom nodes. (#12455) 2026-02-13 20:21:10 -05:00
comfyanonymousandGitHub f18e0e1901 Support generating attention masks for left padded text encoders. (#12454) 2026-02-13 20:15:23 -05:00
comfyanonymousandGitHub 13f588f3d0 Use torch RMSNorm for flux models and refactor hunyuan video code. (#12432) 2026-02-13 15:35:13 -05:00
rattusandGitHub d270a59f88 dynamic_vram: Training fixes (#12442) 2026-02-13 15:29:37 -05:00
comfyanonymousandGitHub 8130a2a2f4 Update command to install AMD stable linux pytorch. (#12437) 2026-02-12 23:29:12 -05:00
rattusandGitHub 729bf36846 llama: use a more efficient rope implementation (#12434)
Get rid of the cat and unary negation and inplace add-cmul the two
halves of the rope. Precompute -sin once at the start of the model
rather than every transformer block.

This is slightly faster on both GPU and CPU bound setups.
2026-02-12 19:56:42 -05:00
rattusandGitHub d397cff78c ModelPatcherDynamic: force load non leaf weights (#12433)
The current behaviour of the default ModelPatcher is to .to a model
only if its fully loaded, which is how random non-leaf weights get
loaded in non-LowVRAM conditions.

The however means they never get loaded in dynamic_vram. In the
dynamic_vram case, force load them to the GPU.
2026-02-12 19:51:50 -05:00
Alexander PiskunandGitHub 96aef614b8 fix(api-nodes): add separate retry budget for 429 rate limit responses (#12421) 2026-02-12 01:38:51 -08:00
comfyanonymousandGitHub eeb0a347a4 Add a tip for common error. (#12414) 2026-02-11 22:12:16 -05:00
askmyteapotandGitHub fb7459ea87 Update ace15.py to allow min_p sampling (#12373) 2026-02-11 20:28:48 -05:00
rattusandGitHub 348a963e05 model_patcher: guard against none model_dtype (#12410)
Handle the case where the _model_dtype exists but is none with the
intended fallback.
2026-02-11 14:54:02 -05:00
rattusandGitHub 07e622aafd ace15: Use dynamic_vram friendly trange (#12409)
Factor out the ksampler trange and use it in ACE LLM to prevent the
silent stall at 0 and rate distortion due to first-step model load.
2026-02-11 14:53:42 -05:00
rattusandGitHub f3bab91d84 dynamic_vram: Fix windows Aimdo crash + Fix LLM performance (#12408)
* model_management: lazy-cache aimdo_tensor

These tensors cosntructed from aimdo-allocations are CPU expensive to
make on the pytorch side. Add a cache version that will be valid with
signature match to fast path past whatever torch is doing.

* dynamic_vram: Minimize fast path CPU work

Move as much as possible inside the not resident if block and cache
the formed weight and bias rather than the flat intermediates. In
extreme layer weight rates this adds up.
2026-02-11 14:50:16 -05:00
1680e37a3d [API Nodes] enable Magnific Upscalers (#12179)
* feat(api-nodes): enable Magnific Upscalers

* update price badges

---------

Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-02-11 11:30:19 -08:00
45c7d336d2 Dispatch desktop auto-bump when a ComfyUI release is published (#12398)
* Dispatch desktop auto-bump on ComfyUI release publish

* Fix release webhook secret checks in step conditions

* Require desktop dispatch token in release webhook

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Luke Mino-Altherr <lminoaltherr@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-02-11 11:15:13 -08:00
Alexander PiskunandGitHub 8d4eba9506 fix(api-nodes): retry on connection errors during polling instead of aborting (#12393) 2026-02-11 10:51:49 -08:00
comfyanonymousandGitHub 03fd9bf2f4 Make built in lora training work on anima. (#12402) 2026-02-10 22:04:32 -05:00
Kohaku-BlueleafandGitHub bdf6a6a90e [Trainer] training with proper offloading (#12189)
* Fix bypass dtype/device moving

* Force offloading mode for training

* training context var

* offloading implementation in training node

* fix wrong input type

* Support bypass load lora model, correct adapter/offloading handling
2026-02-10 21:45:19 -05:00
906f22da43 Add a VideoSlice node (#12107)
* Base TrimVideo implementation

* Raise error if as_trimmed call fails

* Bigger max start_time, tooltips, and formatting

* Count packets unless codec has subframes

* Remove incorrect nested decode

* Add null check for audio streams

* Support non-strict duration

* Added strict_duration bool to node definition

* Empty commit for approval

* Fix duration

* Support 5.1 audio layout on save

---------

Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-02-10 14:42:21 -08:00
d54733af46 feat(jobs): add 3d to PREVIEWABLE_MEDIA_TYPES for first-class 3D output support (#12381)
Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-02-10 14:37:14 -08:00
rattusandGitHub 8ec9515331 ops: Fix vanilla-fp8 loaded lora quality (#12390)
This was missing the stochastic rounding required for fp8 downcast
to be consistent with model_patcher.patch_weight_to_device.

Missed in testing as I spend too much time with quantized tensors
and overlooked the simpler ones.
2026-02-10 13:38:28 -05:00
rattusandGitHub 93acb18b28 sd: delay VAE dtype archive until after override (#12388)
VAEs have host specific dtype logic that should override the dynamic
_model_dtype. Defer the archiving of model dtypes until after.
2026-02-10 13:37:46 -05:00
rattusandGitHub 91b56ca762 mp: dont deep-clone objects from model_options (#12382)
If there are non-trivial python objects nested in the model_options, this
causes all sorts of issues. Traverse lists and dicts so clones can safely
overide settings and BYO objects but stop there on the deepclone.
2026-02-10 13:37:17 -05:00
comfyanonymous ef1ad87e11 ComfyUI v0.13.0 2026-02-10 13:26:29 -05:00
ComfyUI WikiandGitHub 6b1af1fb3f chore: update workflow templates to v0.8.38 (#12394) 2026-02-10 13:24:56 -05:00
Alexander PiskunandGitHub 4e56f8e7ca feat(api-nodes-Kling): add new models (V3, O3) (#12389)
* feat(api-nodes-Kling): add new models (V3, O3)

* remove storyboard from VideoToVideo node

* added check for total duration of storyboards

* fixed other small things

* updated display name for nodes

* added "fake" seed
2026-02-10 09:34:54 -08:00
Alexander PiskunandGitHub 66f95819f4 fix(Moonvalley-API-Nodes): adjust "steps" parameter to not raise exception (#12370) 2026-02-09 21:58:27 -05:00
ComfyUI WikiandGitHub f61354a3e7 chore: update workflow templates to v0.8.37 (#12377) 2026-02-09 21:25:34 -05:00
comfyanonymousandGitHub 36d862cc33 Ace step prompts match now. (#12376) 2026-02-09 19:45:56 -05:00
bleppingandGitHub 94ca9d11ed Iimprovements to ACE-Steps 1.5 text encoding (part 2) (#12350) 2026-02-09 19:41:49 -05:00
rattusandGitHub bfc2e681d7 Dynamic VRAM fixes - Ace 1.5 performance + a VRAM leak (#12368)
* revert threaded model loader change

This change was only needed to get around the pytorch 2.7 mempool bugs,
and should have been reverted along with #12260. This fixes a different
memory leak where pytorch gets confused about cache emptying.

* load non comfy weights

* MPDynamic: Pre-generate the tensors for vbars

Apparently this is an expensive operation that slows down things.

* bump to aimdo 1.8

New features:
watermark limit feature
logging enhancements
-O2 build on linux
2026-02-09 16:16:08 -05:00
comfyanonymousandGitHub 85440bccb8 Make tonemap latent work on any dim latents. (#12363) 2026-02-08 21:16:40 -05:00
comfyanonymousandGitHub d1b42a220c Disable prompt weights for ltxv2. (#12354) 2026-02-07 19:16:28 -05:00
ComfyUI WikiandGitHub 49e2d8274a chore: update embedded docs to v0.4.1 (#12346) 2026-02-07 18:34:52 -05:00
chaObservandGitHub 7bf9892e93 Add search_aliases to sa-solver and seeds-2 node (#12327) 2026-02-07 17:38:51 -05:00
Jukka SeppänenandGitHub 5f863923a8 Fix LazyCache (#12344) 2026-02-07 11:25:30 -08:00
comfyanonymousandGitHub 677abaa156 Pad ace step 1.5 ref audio if not long enough. (#12341) 2026-02-07 00:02:11 -05:00
comfyanonymousandGitHub 39dbafff62 Some fixes to previous pr. (#12339) 2026-02-06 20:14:52 -05:00
tdrussellandGitHub 3f16fc3523 Support fp16 for Cosmos-Predict2 and Anima (#12249) 2026-02-06 20:12:15 -05:00
comfyanonymousandGitHub 15173d05a3 Fix bug with last pr (#12338) 2026-02-06 19:48:20 -05:00
asagi4andGitHub 39f00c8905 Fix return_word_ids=True with Anima tokenizer (#12328) 2026-02-06 19:38:04 -05:00
comfyanonymousandGitHub f7e01d0b46 Make ace step 1.5 base model work properly with default workflow. (#12337) 2026-02-06 19:14:56 -05:00
Jukka SeppänenandGitHub 6168cf6f52 EasyCache: Support LTX2 (#12231) 2026-02-06 00:43:09 -05:00
comfyanonymousandGitHub c561ae84d0 Fix issue when using disable_unet_model_creation (#12315) 2026-02-05 19:24:09 -05:00
comfyanonymousandGitHub 37800ae00f Fix some lowvram stuff with ace step 1.5 (#12312) 2026-02-05 19:15:04 -05:00
comfyanonymousandGitHub 1d5eae081b Make ace step 1.5 work without the llm. (#12311) 2026-02-05 16:43:45 -05:00
AustinMrozandGitHub 1f6b5670ff Add a Create List node (#12173) 2026-02-05 01:18:21 -05:00
Comfy Org PR BotandGitHub 5d13b8ba46 Bump comfyui-frontend-package to 1.38.13 (#12238) 2026-02-05 01:17:37 -05:00
comfyanonymous 10debe9f2e ComfyUI v0.12.3 2026-02-05 01:13:35 -05:00
comfyanonymousandGitHub efbfc783ae Add VAE tiled decode node for audio. (#12299) 2026-02-05 01:12:04 -05:00
bleppingandGitHub 1ba589470e Improvements to ACE-Steps 1.5 text encoding (#12283) 2026-02-05 00:17:37 -05:00
comfyanonymousandGitHub bfea74a317 Disable sage attention on ace step 1.5 (#12297) 2026-02-04 22:15:30 -05:00
comfyanonymousandGitHub 56acb72c9a Add llm sampling options and make reference audio work on ace step 1.5 (#12295) 2026-02-04 21:29:22 -05:00
comfyanonymousandGitHub 55ef40698e Try to fix ace text encoder slowness on some configs. (#12290) 2026-02-04 19:37:05 -05:00
comfyanonymousandGitHub 166babebd3 Fix ace step nan issue on some hardware/pytorch configs. (#12289) 2026-02-04 18:25:06 -05:00
Alexander PiskunandGitHub 3b18e7f655 add File3DAny output to Load3D node; extend SaveGLB to accept File3DAny as input (#12276)
* add File3DAny output to Load3D node; extend SaveGLB node to accept File3DAny as input

* fix(grammar): capitalize letter
2026-02-04 11:35:38 -08:00
rattusandGitHub cb1312fa4f mp: Fix checkpoint saving (#12268)
Fix regression in the recent model saving refactor. Pass the non unet
pieces down the layers so that checkpoints are complete.
2026-02-04 02:08:45 -05:00
rattusandGitHub 82376a6ad0 utils: safetensors: dont slice data on torch level (#12266)
Torch has alignment enforcement when viewing with data type changes
but only relative to itself. Do all tensor constructions straight
off the memory-view individually so pytorch doesnt see an alignment
problem.

The is needed for handling misaligned safetensors weights, which are
reasonably common in third party models.

This limits usage of this safetensors loader to GPU compute only
as CPUs kernnel are very likely to bus error. But it works for
dynamic_vram, where we really dont want to take a deep copy and we
always use GPU copy_ which disentangles the misalignment.
2026-02-04 01:48:47 -05:00
comfyanonymous f4cb0f390a ComfyUI v0.12.2 2026-02-04 00:08:59 -05:00
comfyanonymousandGitHub 4743c6f6a6 Fix crash with ace step 1.5 (#12264) 2026-02-04 00:03:21 -05:00
rattusandGitHub 1d2398e30c mm: Remove Aimdo exemption for empty_cache (#12260)
Its more important to get the torch caching allocator GC up and running
than supporting the pyt2.7 bug. Switch it on.

Defeature dynamic_vram + pyt2.7.
2026-02-03 21:39:19 -05:00
comfyanonymousandGitHub 2e0db5677c Support the 4B ace step 1.5 lm model. (#12257)
Can be used as an alternative to the 1.7B
2026-02-03 19:01:38 -05:00
comfyanonymous 96a0fc5e2e ComfyUI v0.12.1 2026-02-03 15:01:46 -05:00
comfyanonymousandGitHub 5c3b8b25bf Fix tiled vae for ace step 1.5 (#12253) 2026-02-03 14:40:45 -05:00
comfyanonymousandGitHub f59019c02b Support ace step 1.5 base model loras. (#12252) 2026-02-03 13:54:23 -05:00
Alexander PiskunandGitHub a36b18bfde feat(comfy_api): add basic 3D Model file types (#12129)
* feat(comfy_api): add basic 3D Model file types

* update Tripo nodes to use File3DGLB

* update Rodin3D nodes to use File3DGLB

* address PR review feedback:

- Rename File3D parameter 'path' to 'source'
- Convert File3D.data property to get_data()
- Make .glb extension check case-insensitive in nodes_rodin.py
- Restrict SaveGLB node to only accept File3DGLB

* Fixed a bug in the Meshy Rig and Animation nodes

* Fix backward compatability
2026-02-03 10:31:46 -08:00
comfyanonymousandGitHub 598431125a Fix mac issue. (#12250) 2026-02-03 12:19:39 -05:00
d39596902d llama: cast logits as a comfy-weight (#12248)
This is using a different layers weight with .to(). Change it to use
the ops caster if the original layer is a comfy weight so that it picks
up dynamic_vram and async_offload functionality in full.

Co-authored-by: Rattus <rattus128@gmail.com>
2026-02-03 11:31:36 -05:00
comfyanonymousandGitHub 7c619fdb83 Fix some issues with mac. (#12247) 2026-02-03 11:07:04 -05:00
comfyanonymousandGitHub 4e212503d5 Add progress bar to ace step. (#12242) 2026-02-03 04:09:30 -05:00
comfyanonymous 401ae7cc4e ComfyUI v0.12.0 2026-02-03 02:20:59 -05:00
ComfyUI WikiandGitHub 95b640e214 chore: update workflow templates to v0.8.31 (#12239) 2026-02-02 23:08:43 -08:00
comfyanonymousandGitHub ebf7b96cc8 Basic support for the ace step 1.5 model. (#12237) 2026-02-03 00:06:18 -05:00
870a566a7c [API Nodes] HitPaw API nodes (#12117)
* feat(api-nodes): add HitPaw API nodes

* remove face_soft_2x model as not working

---------

Co-authored-by: Robin Huang <robin.j.huang@gmail.com>
2026-02-02 19:17:59 -08:00
comfyanonymousandGitHub 35c2223930 Add back function. (#12234) 2026-02-02 19:52:07 -05:00
rattusandGitHub e0fdface48 Dynamic VRAM unloading fix (#12227)
* mp: fix full dynamic unloading

This was not unloading dynamic models when requesting a full unload via
the unpatch() code path.

This was ok, i your workflow was all dynamic models but fails with big
VRAM leaks if you need to fully unload something for a regular ModelPatcher

It also fices the "unload models" button.

* mm: load models outside of Aimdo Mempool

In dynamic_vram mode, escape the Aimdo mempool and load into the regular
mempool. Use a dummy thread to do it.
2026-02-02 17:35:20 -05:00
rattusandGitHub 4ef4729186 mm: Fix cast buffers with intel offloading (#12229)
Intel has offloading support but there were some nvidia calls in the
new cast buffer stuff.
2026-02-02 17:34:46 -05:00
comfyanonymousandGitHub a3ef9ff2e3 Enable embeddings for some qwen 3 models. (#12218) 2026-02-02 03:51:09 -05:00
comfyanonymousandGitHub b913a8a1a6 Fix issue with parameters on root model object. (#12216) 2026-02-01 20:12:52 -05:00
rattusandGitHub 5764a14b62 requirements: bump comfy-aimdo to 0.1.7 (#12211) 2026-02-01 20:10:15 -05:00
rattusandGitHub c72ecd20fe dynamic_vram: silence pytorch buffer warning (#12210)
This is log clutter and concerning to users. Its a false alarm.
2026-02-01 20:09:55 -05:00
rattusandGitHub 4f5269968e dynamic_vram: respect argument cast dtypes in non-comfy weights (#12209)
This function has a dtype argument that allows the caller to set the
dtype in the cast. TIL Some models override this on weight casts, which
means its the highest priority.

Priority scheme is: argument > model dtype > state dict dtype
2026-02-01 20:09:21 -05:00
rattusandGitHub 747c25d79d fix pinning with model defined dtype (#12208)
pinned memory was converted back to pinning the CPU side weight without
any changes. Fix the pinner to use the CPU weight and not the model defined
geometry. This will either save RAM or stop buffer overruns when the types
mismatch.

Fix the model defined weight caster to use the [ s.weight, s.bias ]
interpretation, as xfer_dest might be the flattened pin now. Fix the detection
of needing to cast to not be conditional on !pin.
2026-02-01 08:42:32 -08:00
comfyanonymousandGitHub faa859507e Fix some custom nodes breaking. (#12203) 2026-02-01 01:55:18 -05:00
Christian ByrneandGitHub 2c7b5c3647 fix: improve error message when node type is missing (#12194)
- Change error type from 'invalid_prompt' to 'missing_node_type' for frontend detection
- Add extra_info with node_id, class_type, and node_title (from _meta.title)
- Improve user-facing message: 'Node X not found. The custom node may not be installed.'
2026-02-01 01:13:48 -05:00
rattusandGitHub 2184d88787 Reduce RAM usage, fix VRAM OOMs, and fix Windows shared memory spilling with adaptive model loading (#11845) 2026-02-01 01:01:11 -05:00
comfyanonymousandGitHub 3fcd17a9e1 KV cache implementation for using llama models for text generation. (#12195) 2026-01-31 21:11:11 -05:00
Jedrzej KosinskiandGitHub 0ff28d56e6 Send is_input_list on v1 and v3 schema to frontend (#12188) 2026-01-31 20:05:11 -05:00
Jedrzej KosinskiandGitHub 75ce640ab9 Assets Part 2 - add more endpoints (#12125) 2026-01-31 02:22:05 -05:00
2bcc314997 feat(api-nodes): add Q3 models and support for Extend and MultiFrame Vidu endpoints (#12175)
Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-01-30 22:44:08 -08:00
comfyanonymousandGitHub ffd840397f Fix model not working with any res. (#12186) 2026-01-31 00:12:48 -05:00
comfyanonymousandGitHub c2d9279448 Update python patch version in dep workflow. (#12184) 2026-01-30 20:20:06 -05:00
ce9e423460 Add color type and Color to RGB Int node (#12145)
* add color type and color to rgb int node

* review fix for allowing output

---------

Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-01-30 15:01:33 -08:00
1f6cbafd16 feat(api-nodes): add RecraftCreateStyleNode node (#12055)
Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-01-30 14:04:43 -08:00
f67b0ae4a2 Remove NodeInfoV3-related code; we are almost 100% guaranteed to stick with NodeInfoV1 for the foreseable future (#12147)
Co-authored-by: guill <jacob.e.segal@gmail.com>
2026-01-30 10:21:48 -08:00
comfyanonymousandGitHub f1e5a3a818 Make empty hunyuan latent 1.0 work with the 1.5 model. (#12171) 2026-01-29 23:52:22 -05:00
d1fd8f8e64 fix: count non-dict items in outputs_count (#12166)
Move count increment before isinstance(item, dict) check so that
non-dict output items (like text strings from PreviewAny node)
are included in outputs_count.

This aligns OSS Python with Cloud's Go implementation which uses
len(itemsArray) to count ALL items regardless of type.

Amp-Thread-ID: https://ampcode.com/threads/T-019c0bb5-14e0-744f-8808-1e57653f3ae3

Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 17:10:08 -08:00
comfyanonymous c67a9def73 ComfyUI v0.11.1 2026-01-29 00:27:23 -05:00
comfyanonymousandGitHub 1bd5ea85a6 Add missing spacial downscale ratios. (#12146) 2026-01-28 20:52:51 -05:00
ComfyUI WikiandGitHub fc5b9486df chore: update workflow templates to v0.8.27 (#12141) 2026-01-28 12:48:02 -05:00
Dr.Lt.DataandGitHub c5a2f8c96e bump manager version to 4.1b1 (#12140) 2026-01-28 12:47:37 -05:00
Alexander PiskunandGitHub e4943c4ffa feat(api-nodes): add Grok Imagine nodes (#12136) 2026-01-28 12:46:57 -05:00
comfyanonymousandGitHub 3f12c530e3 Update Python 3.14 compatibility notes in README (#12127) 2026-01-27 19:58:48 -05:00
guillandGitHub 03b7122cb5 Add support for dev-only nodes. (#12106)
When a node is declared as dev-only, it doesn't show in the default UI
unless the dev mode is enabled in the settings. The intention is to
allow nodes related to unit testing to be included in ComfyUI
distributions without confusing the average user.
2026-01-27 13:03:29 -08:00
comfyanonymous 6ea1e11e00 ComfyUI version v0.11.0 2026-01-26 23:08:01 -05:00
ComfyUI WikiandGitHub db95446a66 chore: update workflow templates to v0.8.24 (#12103) 2026-01-26 22:47:33 -05:00
ComfyUI WikiandGitHub 257b291f50 Update workflow templates to v0.8.23 (#12102) 2026-01-26 21:39:39 -05:00
comfyanonymousandGitHub 4834b84f88 Update amd portable for rocm 7.2 (#12101)
* Update amd portable for rocm 7.2

* Update Python patch version in release workflow
2026-01-26 19:49:31 -05:00
rattusandGitHub 380bed9e34 wan-vae: Switch off feature cache for single frame (#12090)
The code throughout is None safe to just skip the feature cache saving
step if none. Set it none in single frame use so qwen doesn't burn VRAM
on the unused cache.
2026-01-26 19:40:19 -05:00
Jukka SeppänenandGitHub 62a15c221e Fix Noise_EmptyNoise when using nested latents (#12089) 2026-01-26 19:25:00 -05:00
06700e5278 [API Nodes] add Magnific nodes (#11986)
* feat(api-nodes): add Magnific nodes

* aggressive downscaling should not be performed

* disable upscaler nodes

---------

Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-01-26 14:10:09 -08:00
aaf8299291 chore(api-nodes): remove ByteDanceImageEditNode node (seededit) (#12069)
Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-01-26 13:58:33 -08:00
Tavi HalperinandGitHub e4ffdc7c15 IC-LoRA: support small grid (#12074) 2026-01-26 15:33:19 -05:00
comfyanonymousandGitHub fb34660e65 Fix mistral 3 tokenizer code failing on latest transformers version and other breakage. (#12095)
* Fix mistral 3 tokenizer code failing on latest transformers version.

* Add requests to the requirements
2026-01-26 11:39:00 -05:00
comfyanonymousandGitHub b781aa9427 Add name to LoraLoaderModelOnly. (#12078) 2026-01-25 21:01:55 -05:00
comfyanonymousandGitHub de5fa0046c Move nodes from previous PR into their own file. (#12066) 2026-01-24 23:02:32 -05:00
Kohaku-BlueleafandGitHub 38ad06dad7 [Weight-adapter/Trainer] Bypass forward mode in Weight adapter system (#11958)
* Add API of bypass forward module

* bypass implementation

* add bypass fwd into nodes list/trainer
2026-01-24 22:56:22 -05:00
comfyanonymousandGitHub 1638908ede Only enable fp16 on z image models that actually support it. (#12065) 2026-01-24 22:32:28 -05:00
2e620127ac add support for kwargs inputs to allow arbitrary inputs from frontend (#12063)
used to output selected combo index

Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-01-24 17:30:40 -08:00
be53b56171 [API Nodes] add TencentHunyuan3D nodes (#12026)
* feat(api-nodes): add TencentHunyuan3D nodes

* add "(Pro)" to display name

---------

Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-01-24 17:10:09 -08:00
comfyanonymousandGitHub 151be709f7 Make empty latent node work with other models. (#12062) 2026-01-24 19:23:20 -05:00
rattusandGitHub fa64cb89e4 speed up and reduce VRAM of QWEN VAE and WAN (less so) (#12036)
* ops: introduce autopad for conv3d

This works around pytorch missing ability to causal pad as part of the
kernel and avoids massive weight duplications for padding.

* wan-vae: rework causal padding

This currently uses F.pad which takes a full deep copy and is liable to
be the VRAM peak. Instead, kick spatial padding back to the op and
consolidate the temporal padding with the cat for the cache.

* wan-vae: implement zero pad fast path

The WAN VAE is also QWEN where it is used single-image. These
convolutions are however zero padded 3d convolutions, which means the
VAE is actually just 2D down the last element of the conv weight in
the temporal dimension. Fast path this, to avoid adding zeros that
then just evaporate in convoluton math but cost computation.
2026-01-23 19:56:14 -05:00
comfyanonymousandGitHub b4a44abe83 Make regular empty latent node work properly on flux 2 variants. (#12050) 2026-01-23 19:50:48 -05:00
ComfyUI WikiandGitHub 170f294cec Support ModelScope-Trainer/DiffSynth LoRA format for Flux.2 Klein models (#12042) 2026-01-23 15:27:49 -05:00
Jukka SeppänenandGitHub 4f587b915b LTX2: Refactor forward function for better VRAM efficiency and fix spatial inpainting (#12046)
* Disable timestep embed compression when inpainting

Spatial inpainting not compatible with the compression

* Reduce crossattn peak VRAM

* LTX2: Refactor forward function for better VRAM efficiency
2026-01-23 15:26:38 -05:00
Christian ByrneandGitHub 5e50a1c5f7 feat: Improve ResizeImageMaskNode UX with tooltips and search aliases (#12040)
- Add search_aliases for discoverability: resize, scale, dimensions, etc.
- Add node description for hover tooltip
- Add tooltips to all inputs explaining their behavior
- Reorder options: most common (scale dimensions) first, most technical (scale to multiple) last

Addresses user feedback that 'resize' search returned nothing useful and
options like 'match size' and 'scale to multiple' were not self-explanatory.
2026-01-22 22:04:27 -08:00
comfyanonymousandGitHub 337837ca99 Revert "feat: Improve ResizeImageMaskNode UX with tooltips and search aliases…" (#12038)
This reverts commit bc46bf1649.
2026-01-22 23:02:37 -05:00
Christian ByrneandGitHub bc46bf1649 feat: Improve ResizeImageMaskNode UX with tooltips and search aliases (#12013)
- Add search_aliases for discoverability: resize, scale, dimensions, etc.
- Add node description for hover tooltip
- Add tooltips to all inputs explaining their behavior
- Reorder options: most common (scale dimensions) first, most technical (scale to multiple) last

Addresses user feedback that 'resize' search returned nothing useful and
options like 'match size' and 'scale to multiple' were not self-explanatory.
2026-01-22 18:46:55 -08:00
Christian ByrneandGitHub 42e5a6e092 add search aliases to all nodes (#12035)
* feat: Add search_aliases field to node schema

Adds `search_aliases` field to improve node discoverability. Users can define alternative search terms for nodes (e.g., "text concat" → StringConcatenate).

Changes:
- Add `search_aliases: list[str]` to V3 Schema
- Add `SEARCH_ALIASES` support for V1 nodes
- Include field in `/object_info` response
- Add aliases to high-priority core nodes

V1 usage:
```python
class MyNode:
    SEARCH_ALIASES = ["alt name", "synonym"]
```

V3 usage:
```python
io.Schema(
    node_id="MyNode",
    search_aliases=["alt name", "synonym"],
    ...
)
```

## Related PRs
- Frontend: Comfy-Org/ComfyUI_frontend#XXXX (draft - merge after this)
- Docs: Comfy-Org/docs#XXXX (draft - merge after stable)

* Propagate search_aliases through V3 Schema.get_v1_info to NodeInfoV1

* feat: add SEARCH_ALIASES for core nodes (#12016)

Add search aliases to 22 core nodes in nodes.py to improve node discoverability:
- Checkpoint/model loaders: CheckpointLoader, DiffusersLoader
- Conditioning nodes: ConditioningAverage, ConditioningSetArea, ConditioningSetMask, ConditioningZeroOut
- Style nodes: StyleModelApply
- Image nodes: LoadImageMask, LoadImageOutput, ImageBatch, ImageInvert, ImagePadForOutpaint
- Latent nodes: LoadLatent, SaveLatent, LatentBlend, LatentComposite, LatentCrop, LatentFlip, LatentFromBatch, LatentUpscale, LatentUpscaleBy, RepeatLatentBatch

* feat: add SEARCH_ALIASES for image, mask, and string nodes (#12017)

Add search aliases to nodes in comfy_extras for better discoverability:
- nodes_mask.py: mask manipulation nodes
- nodes_images.py: image processing nodes
- nodes_post_processing.py: post-processing effect nodes
- nodes_string.py: string manipulation nodes
- nodes_compositing.py: compositing nodes
- nodes_morphology.py: morphological operation nodes
- nodes_latent.py: latent space nodes

Uses search_aliases parameter in io.Schema() for v3 nodes.

* feat: add SEARCH_ALIASES for audio and video nodes (#12018)

Add search aliases to audio and video nodes for better discoverability:
- nodes_audio.py: audio loading, saving, and processing nodes
- nodes_video.py: video loading and processing nodes
- nodes_wan.py: WAN model nodes

Uses search_aliases parameter in io.Schema() for v3 nodes.

* feat: add SEARCH_ALIASES for model and misc nodes (#12019)

Add search aliases to model-related and miscellaneous nodes:
- Model nodes: nodes_model_merging.py, nodes_model_advanced.py, nodes_lora_extract.py
- Sampler nodes: nodes_custom_sampler.py, nodes_align_your_steps.py
- Control nodes: nodes_controlnet.py, nodes_attention_multiply.py, nodes_hooks.py
- Training nodes: nodes_train.py, nodes_dataset.py
- Utility nodes: nodes_logic.py, nodes_canny.py, nodes_differential_diffusion.py
- Architecture-specific: nodes_sd3.py, nodes_pixart.py, nodes_lumina2.py, nodes_kandinsky5.py, nodes_hidream.py, nodes_fresca.py, nodes_hunyuan3d.py
- Media nodes: nodes_load_3d.py, nodes_webcam.py, nodes_preview_any.py, nodes_wanmove.py

Uses search_aliases parameter in io.Schema() for v3 nodes, SEARCH_ALIASES class attribute for legacy nodes.
2026-01-22 18:36:58 -08:00
786 changed files with 49528 additions and 57978 deletions
+8
View File
@@ -0,0 +1,8 @@
@echo off
..\python_embeded\python.exe .\update.py ..\HanzoStudio\
if exist update_new.py (
move /y update_new.py update.py
echo Running updater again since it got updated.
..\python_embeded\python.exe .\update.py ..\HanzoStudio\ --skip_self_update
)
if "%~1"=="" pause
+3 -3
View File
@@ -47,7 +47,7 @@ def pull(repo, remote_name='origin', branch='master'):
pygit2.option(pygit2.GIT_OPT_SET_OWNER_VALIDATION, 0)
repo_path = str(sys.argv[1])
repo = pygit2.Repository(repo_path)
ident = pygit2.Signature('comfyui', 'comfy@ui')
ident = pygit2.Signature('hanzo-studio', 'studio@hanzo.ai')
try:
print("stashing current changes") # noqa: T201
repo.stash(ident)
@@ -153,8 +153,8 @@ if not os.path.exists(req_path) or not files_equal(repo_req_path, req_path):
pass
stable_update_script = os.path.join(repo_path, ".ci/update_windows/update_comfyui_stable.bat")
stable_update_script_to = os.path.join(cur_path, "update_comfyui_stable.bat")
stable_update_script = os.path.join(repo_path, ".ci/update_windows/update_studio_stable.bat")
stable_update_script_to = os.path.join(cur_path, "update_studio_stable.bat")
try:
if not file_size(stable_update_script_to) > 10:
-8
View File
@@ -1,8 +0,0 @@
@echo off
..\python_embeded\python.exe .\update.py ..\ComfyUI\
if exist update_new.py (
move /y update_new.py update.py
echo Running updater again since it got updated.
..\python_embeded\python.exe .\update.py ..\ComfyUI\ --skip_self_update
)
if "%~1"=="" pause
@@ -1,8 +0,0 @@
@echo off
..\python_embeded\python.exe .\update.py ..\ComfyUI\ --stable
if exist update_new.py (
move /y update_new.py update.py
echo Running updater again since it got updated.
..\python_embeded\python.exe .\update.py ..\ComfyUI\ --skip_self_update --stable
)
if "%~1"=="" pause
+8
View File
@@ -0,0 +1,8 @@
@echo off
..\python_embeded\python.exe .\update.py ..\HanzoStudio\ --stable
if exist update_new.py (
move /y update_new.py update.py
echo Running updater again since it got updated.
..\python_embeded\python.exe .\update.py ..\HanzoStudio\ --skip_self_update --stable
)
if "%~1"=="" pause
@@ -7,22 +7,19 @@ If you have a AMD gpu:
run_amd_gpu.bat
If you have memory issues you can try disabling the smart memory management by running comfyui with:
If you have memory issues you can try disabling the smart memory management by running Hanzo Studio with:
run_amd_gpu_disable_smart_memory.bat
IF YOU GET A RED ERROR IN THE UI MAKE SURE YOU HAVE A MODEL/CHECKPOINT IN: ComfyUI\models\checkpoints
IF YOU GET A RED ERROR IN THE UI MAKE SURE YOU HAVE A MODEL/CHECKPOINT IN: HanzoStudio\models\checkpoints
You can download the stable diffusion XL one from: https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/blob/main/sd_xl_base_1.0_0.9vae.safetensors
RECOMMENDED WAY TO UPDATE:
To update the ComfyUI code: update\update_comfyui.bat
To update the Hanzo Studio code: update\update_studio.bat
TO SHARE MODELS BETWEEN COMFYUI AND ANOTHER UI:
In the ComfyUI directory you will find a file: extra_model_paths.yaml.example
TO SHARE MODELS BETWEEN HANZO STUDIO AND ANOTHER UI:
In the HanzoStudio directory you will find a file: extra_model_paths.yaml.example
Rename this file to: extra_model_paths.yaml and edit it with your favorite text editor.
+1 -1
View File
@@ -1,2 +1,2 @@
.\python_embeded\python.exe -s ComfyUI\main.py --windows-standalone-build
.\python_embeded\python.exe -s HanzoStudio\main.py --windows-standalone-build
pause
@@ -1,2 +1,2 @@
.\python_embeded\python.exe -s ComfyUI\main.py --windows-standalone-build --disable-smart-memory
.\python_embeded\python.exe -s HanzoStudio\main.py --windows-standalone-build --disable-smart-memory
pause
@@ -1,2 +1,2 @@
.\python_embeded\python.exe -s ComfyUI\main.py --windows-standalone-build --fast
.\python_embeded\python.exe -s HanzoStudio\main.py --windows-standalone-build --fast
pause
@@ -15,20 +15,20 @@ run_cpu.bat
IF YOU GET A RED ERROR IN THE UI MAKE SURE YOU HAVE A MODEL/CHECKPOINT IN: ComfyUI\models\checkpoints
IF YOU GET A RED ERROR IN THE UI MAKE SURE YOU HAVE A MODEL/CHECKPOINT IN: HanzoStudio\models\checkpoints
You can download the stable diffusion 1.5 one from: https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/blob/main/v1-5-pruned-emaonly-fp16.safetensors
You can download the stable diffusion 1.5 one from: https://huggingface.co/hanzoai/stable-diffusion-v1-5-archive/blob/main/v1-5-pruned-emaonly-fp16.safetensors
RECOMMENDED WAY TO UPDATE:
To update the ComfyUI code: update\update_comfyui.bat
To update the Hanzo Studio code: update\update_studio.bat
To update ComfyUI with the python dependencies, note that you should ONLY run this if you have issues with python dependencies.
update\update_comfyui_and_python_dependencies.bat
To update Hanzo Studio with the python dependencies, note that you should ONLY run this if you have issues with python dependencies.
update\update_studio_and_python_dependencies.bat
TO SHARE MODELS BETWEEN COMFYUI AND ANOTHER UI:
In the ComfyUI directory you will find a file: extra_model_paths.yaml.example
TO SHARE MODELS BETWEEN HANZO STUDIO AND ANOTHER UI:
In the HanzoStudio directory you will find a file: extra_model_paths.yaml.example
Rename this file to: extra_model_paths.yaml and edit it with your favorite text editor.
@@ -1,3 +1,3 @@
..\python_embeded\python.exe -s ..\ComfyUI\main.py --windows-standalone-build --disable-api-nodes
echo If you see this and ComfyUI did not start try updating your Nvidia Drivers to the latest. If you get a c10.dll error you need to install vc redist that you can find: https://aka.ms/vc14/vc_redist.x64.exe
..\python_embeded\python.exe -s ..\HanzoStudio\main.py --windows-standalone-build --disable-api-nodes
echo If you see this and Hanzo Studio did not start try updating your Nvidia Drivers to the latest. If you get a c10.dll error you need to install vc redist that you can find: https://aka.ms/vc14/vc_redist.x64.exe
pause
+1 -1
View File
@@ -1,2 +1,2 @@
.\python_embeded\python.exe -s ComfyUI\main.py --cpu --windows-standalone-build
.\python_embeded\python.exe -s HanzoStudio\main.py --cpu --windows-standalone-build
pause
@@ -1,3 +1,3 @@
.\python_embeded\python.exe -s ComfyUI\main.py --windows-standalone-build
echo If you see this and ComfyUI did not start try updating your Nvidia Drivers to the latest. If you get a c10.dll error you need to install vc redist that you can find: https://aka.ms/vc14/vc_redist.x64.exe
.\python_embeded\python.exe -s HanzoStudio\main.py --windows-standalone-build
echo If you see this and Hanzo Studio did not start try updating your Nvidia Drivers to the latest. If you get a c10.dll error you need to install vc redist that you can find: https://aka.ms/vc14/vc_redist.x64.exe
pause
@@ -1,3 +1,3 @@
.\python_embeded\python.exe -s ComfyUI\main.py --windows-standalone-build --fast fp16_accumulation
echo If you see this and ComfyUI did not start try updating your Nvidia Drivers to the latest. If you get a c10.dll error you need to install vc redist that you can find: https://aka.ms/vc14/vc_redist.x64.exe
.\python_embeded\python.exe -s HanzoStudio\main.py --windows-standalone-build --fast fp16_accumulation
echo If you see this and Hanzo Studio did not start try updating your Nvidia Drivers to the latest. If you get a c10.dll error you need to install vc redist that you can find: https://aka.ms/vc14/vc_redist.x64.exe
pause
+127
View File
@@ -0,0 +1,127 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
language: "en-US"
early_access: false
tone_instructions: "Only comment on issues introduced by this PR's changes. Do not flag pre-existing problems in moved, re-indented, or reformatted code."
reviews:
profile: "chill"
request_changes_workflow: false
high_level_summary: false
poem: false
review_status: false
review_details: false
commit_status: true
collapse_walkthrough: true
changed_files_summary: false
sequence_diagrams: false
estimate_code_review_effort: false
assess_linked_issues: false
related_issues: false
related_prs: false
suggested_labels: false
auto_apply_labels: false
suggested_reviewers: false
auto_assign_reviewers: false
in_progress_fortune: false
enable_prompt_for_ai_agents: true
path_filters:
- "!studio_api_nodes/apis/**"
- "!**/generated/*.pyi"
- "!.ci/**"
- "!script_examples/**"
- "!**/__pycache__/**"
- "!**/*.ipynb"
- "!**/*.png"
- "!**/*.bat"
path_instructions:
- path: "**"
instructions: |
IMPORTANT: Only comment on issues directly introduced by this PR's code changes.
Do NOT flag pre-existing issues in code that was merely moved, re-indented,
de-indented, or reformatted without logic changes. If code appears in the diff
only due to whitespace or structural reformatting (e.g., removing a `with:` block),
treat it as unchanged. Contributors should not feel obligated to address
pre-existing issues outside the scope of their contribution.
- path: "studio/**"
instructions: |
Core ML/diffusion engine. Focus on:
- Backward compatibility (breaking changes affect all custom nodes)
- Memory management and GPU resource handling
- Performance implications in hot paths
- Thread safety for concurrent execution
- path: "studio_api_nodes/**"
instructions: |
Third-party API integration nodes. Focus on:
- No hardcoded API keys or secrets
- Proper error handling for API failures (timeouts, rate limits, auth errors)
- Correct Pydantic model usage
- Security of user data passed to external APIs
- path: "studio_extras/**"
instructions: |
Community-contributed extra nodes. Focus on:
- Consistency with node patterns (INPUT_TYPES, RETURN_TYPES, FUNCTION, CATEGORY)
- No breaking changes to existing node interfaces
- path: "studio_execution/**"
instructions: |
Execution engine (graph execution, caching, jobs). Focus on:
- Caching correctness
- Concurrent execution safety
- Graph validation edge cases
- path: "nodes.py"
instructions: |
Core node definitions (2500+ lines). Focus on:
- Backward compatibility of NODE_CLASS_MAPPINGS
- Consistency of INPUT_TYPES return format
- path: "alembic_db/**"
instructions: |
Database migrations. Focus on:
- Migration safety and rollback support
- Data preservation during schema changes
auto_review:
enabled: true
auto_incremental_review: true
drafts: false
ignore_title_keywords:
- "WIP"
- "DO NOT REVIEW"
- "DO NOT MERGE"
finishing_touches:
docstrings:
enabled: false
unit_tests:
enabled: false
tools:
ruff:
enabled: false
pylint:
enabled: false
flake8:
enabled: false
gitleaks:
enabled: true
shellcheck:
enabled: false
markdownlint:
enabled: false
yamllint:
enabled: false
languagetool:
enabled: false
github-checks:
enabled: true
timeout_ms: 90000
ast-grep:
essential_rules: true
chat:
auto_reply: true
knowledge_base:
opt_out: false
learnings:
scope: "auto"
+33
View File
@@ -0,0 +1,33 @@
models/
output/
input/
custom_nodes/*
# ...but the pack manifest must reach the build context so the image can vendor
# the packs reproducibly (scripts/install_custom_nodes.sh clones them at build).
# Local clones stay excluded so the image builds them fresh from the manifest.
!custom_nodes/hanzo-packs.txt
user/
temp/
orgs/
.git/
.github/
__pycache__/
*.pyc
*.pyo
*.log
*.egg-info/
.venv/
venv/
.mypy_cache/
.pytest_cache/
.ruff_cache/
tests/
tests-unit/
# scripts/ ships WHOLE: the image build runs scripts/install_custom_nodes.sh and
# the studio pod runs scripts/library_manifest.py as the build-manifest sidecar
# (indexes every org's output/ into library.json). Excluding it silently no-ops
# the sidecar's `[ -f ]` guard, so nothing gets indexed — keep the tree intact.
notebooks/
*.swp
.env
.env.*
+7 -7
View File
@@ -1,5 +1,5 @@
name: Bug Report
description: "Something is broken inside of ComfyUI. (Do not use this if you're just having issues and need help, or if the issue relates to a custom node)"
description: "Something is broken inside of Hanzo Studio. (Do not use this if you're just having issues and need help, or if the issue relates to a custom node)"
labels: ["Potential Bug"]
body:
- type: markdown
@@ -7,23 +7,23 @@ body:
value: |
Before submitting a **Bug Report**, please ensure the following:
- **1:** You are running the latest version of ComfyUI.
- **2:** You have your ComfyUI logs and relevant workflow on hand and will post them in this bug report.
- **1:** You are running the latest version of Hanzo Studio.
- **2:** You have your Hanzo Studio logs and relevant workflow on hand and will post them in this bug report.
- **3:** You confirmed that the bug is not caused by a custom node. You can disable all custom nodes by passing
`--disable-all-custom-nodes` command line argument. If you have custom node try updating them to the latest version.
- **4:** This is an actual bug in ComfyUI, not just a support question. A bug is when you can specify exact
- **4:** This is an actual bug in Hanzo Studio, not just a support question. A bug is when you can specify exact
steps to replicate what went wrong and others will be able to repeat your steps and see the same issue happen.
## Very Important
Please make sure that you post ALL your ComfyUI logs in the bug report. A bug report without logs will likely be ignored.
Please make sure that you post ALL your Hanzo Studio logs in the bug report. A bug report without logs will likely be ignored.
- type: checkboxes
id: custom-nodes-test
attributes:
label: Custom Node Testing
description: Please confirm you have tried to reproduce the issue with all custom nodes disabled.
options:
- label: I have tried disabling custom nodes and the issue persists (see [how to disable custom nodes](https://docs.comfy.org/troubleshooting/custom-node-issues#step-1%3A-test-with-all-custom-nodes-disabled) if you need help)
- label: I have tried disabling custom nodes and the issue persists (see [how to disable custom nodes](https://docs.hanzo.ai/troubleshooting/custom-node-issues#step-1%3A-test-with-all-custom-nodes-disabled) if you need help)
required: false
- type: textarea
attributes:
@@ -40,7 +40,7 @@ body:
- type: textarea
attributes:
label: Steps to Reproduce
description: "Describe how to reproduce the issue. Please be sure to attach a workflow JSON or PNG, ideally one that doesn't require custom nodes to test. If the bug open happens when certain custom nodes are used, most likely that custom node is what has the bug rather than ComfyUI, in which case it should be reported to the node's author."
description: "Describe how to reproduce the issue. Please be sure to attach a workflow JSON or PNG, ideally one that doesn't require custom nodes to test. If the bug open happens when certain custom nodes are used, most likely that custom node is what has the bug rather than Hanzo Studio, in which case it should be reported to the node's author."
validations:
required: true
- type: textarea
+6 -9
View File
@@ -1,11 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: ComfyUI Frontend Issues
url: https://github.com/Comfy-Org/ComfyUI_frontend/issues
about: Issues related to the ComfyUI frontend (display issues, user interaction bugs), please go to the frontend repo to file the issue
- name: ComfyUI Matrix Space
url: https://app.element.io/#/room/%23comfyui_space%3Amatrix.org
about: The ComfyUI Matrix Space is available for support and general discussion related to ComfyUI (Matrix is like Discord but open source).
- name: Comfy Org Discord
url: https://discord.gg/comfyorg
about: The Comfy Org Discord is available for support and general discussion related to ComfyUI.
- name: Hanzo Studio Frontend Issues
url: https://github.com/hanzoai/studio/issues
about: Issues related to the Hanzo Studio frontend (display issues, user interaction bugs).
- name: Hanzo AI Discord
url: https://discord.gg/hanzoai
about: The Hanzo AI Discord is available for support and general discussion related to Hanzo Studio.
+4 -4
View File
@@ -1,5 +1,5 @@
name: Feature Request
description: "You have an idea for something new you would like to see added to ComfyUI's core."
description: "You have an idea for something new you would like to see added to Hanzo Studio's core."
labels: [ "Feature" ]
body:
- type: markdown
@@ -7,11 +7,11 @@ body:
value: |
Before submitting a **Feature Request**, please ensure the following:
**1:** You are running the latest version of ComfyUI.
**1:** You are running the latest version of Hanzo Studio.
**2:** You have looked to make sure there is not already a feature that does what you need, and there is not already a Feature Request listed for the same idea.
**3:** This is something that makes sense to add to ComfyUI Core, and wouldn't make more sense as a custom node.
**3:** This is something that makes sense to add to Hanzo Studio Core, and wouldn't make more sense as a custom node.
If unsure, ask on the [ComfyUI Matrix Space](https://app.element.io/#/room/%23comfyui_space%3Amatrix.org) or the [Comfy Org Discord](https://discord.gg/comfyorg) first.
If unsure, ask on the [Hanzo AI Discord](https://discord.gg/hanzoai) first.
- type: textarea
attributes:
label: Feature Idea
+3 -3
View File
@@ -7,17 +7,17 @@ body:
value: |
Before submitting a **User Report** issue, please ensure the following:
**1:** You are running the latest version of ComfyUI.
**1:** You are running the latest version of Hanzo Studio.
**2:** You have made an effort to find public answers to your question before asking here. In other words, you googled it first, and scrolled through recent help topics.
If unsure, ask on the [ComfyUI Matrix Space](https://app.element.io/#/room/%23comfyui_space%3Amatrix.org) or the [Comfy Org Discord](https://discord.gg/comfyorg) first.
If unsure, ask on the [Hanzo AI Discord](https://discord.gg/hanzoai) first.
- type: checkboxes
id: custom-nodes-test
attributes:
label: Custom Node Testing
description: Please confirm you have tried to reproduce the issue with all custom nodes disabled.
options:
- label: I have tried disabling custom nodes and the issue persists (see [how to disable custom nodes](https://docs.comfy.org/troubleshooting/custom-node-issues#step-1%3A-test-with-all-custom-nodes-disabled) if you need help)
- label: I have tried disabling custom nodes and the issue persists (see [how to disable custom nodes](https://docs.hanzo.ai/troubleshooting/custom-node-issues#step-1%3A-test-with-all-custom-nodes-disabled) if you need help)
required: false
- type: textarea
attributes:
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="studio">
<rect width="1280" height="640" fill="#0A0A0A"/>
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">studio</text>
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">Hanzo Studio — Visual AI Engine</text>
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+2 -2
View File
@@ -4,7 +4,7 @@ on:
pull_request_target:
types: [opened, reopened, synchronize, ready_for_review]
paths:
- 'comfy_api_nodes/**' # only run if these files changed
- 'studio_api_nodes/**' # only run if these files changed
permissions:
contents: read
@@ -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
+22
View File
@@ -0,0 +1,22 @@
# Canonical CI/CD — imports the shared hanzoai/ci reusable workflow, which
# reads hanzo.yml (images + test + deploy + kms). No per-repo build logic,
# no GitHub-hosted runners (defaults to the Hanzo cloud arc pool).
name: CI/CD
on:
push:
branches: [main]
tags: ['v*']
pull_request:
workflow_dispatch:
jobs:
cicd:
uses: hanzoai/ci/.github/workflows/build.yml@v1
# Target the Hanzo arc scale set by its installation name. ARC scale sets
# are matched by name, not by the [self-hosted,linux,amd64] label triple
# that ci@v1 defaults to — without this override the job never gets a
# runner and sits queued.
with:
runner: '["hanzo-build-linux-amd64"]'
secrets: inherit
+5 -5
View File
@@ -1,5 +1,5 @@
# This is the GitHub Workflow that drives full-GPU-enabled tests of pull requests to ComfyUI, when the 'Run-CI-Test' label is added
# Results are reported as checkmarks on the commits, as well as onto https://ci.comfy.org/
# This is the GitHub Workflow that drives full-GPU-enabled tests of pull requests to Hanzo Studio, when the 'Run-CI-Test' label is added
# Results are reported as checkmarks on the commits, as well as onto https://ci.hanzo.ai/
name: Pull Request CI Workflow Runs
on:
pull_request_target:
@@ -34,11 +34,11 @@ jobs:
python_version: ${{ matrix.python_version }}
torch_version: ${{ matrix.torch_version }}
google_credentials: ${{ secrets.GCS_SERVICE_ACCOUNT_JSON }}
comfyui_flags: ${{ matrix.flags }}
studio_flags: ${{ matrix.flags }}
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:
@@ -49,5 +49,5 @@ jobs:
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '(Automated Bot Message) CI Tests are running, you can view the results at https://ci.comfy.org/?branch=${{ github.event.pull_request.number }}%2Fmerge'
body: '(Automated Bot Message) CI Tests are running, you can view the results at https://ci.hanzo.ai/?branch=${{ github.event.pull_request.number }}%2Fmerge'
})
+3 -3
View File
@@ -20,7 +20,7 @@ jobs:
git_tag: ${{ inputs.git_tag }}
cache_tag: "cu130"
python_minor: "13"
python_patch: "9"
python_patch: "11"
rel_name: "nvidia"
rel_extra_name: ""
test_release: true
@@ -65,11 +65,11 @@ jobs:
contents: "write"
packages: "write"
pull-requests: "read"
name: "Release AMD ROCm 7.1.1"
name: "Release AMD ROCm 7.2"
uses: ./.github/workflows/stable-release.yml
with:
git_tag: ${{ inputs.git_tag }}
cache_tag: "rocm711"
cache_tag: "rocm72"
python_minor: "12"
python_patch: "10"
rel_name: "amd"
+37 -1
View File
@@ -6,7 +6,9 @@ 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:
- name: Send release webhook
env:
@@ -106,3 +108,37 @@ jobs:
--fail --silent --show-error
echo "✅ Release webhook sent successfully"
- name: Send repository dispatch to desktop
env:
DISPATCH_TOKEN: ${{ env.DESKTOP_REPO_DISPATCH_TOKEN }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
RELEASE_URL: ${{ github.event.release.html_url }}
run: |
set -euo pipefail
if [ -z "${DISPATCH_TOKEN:-}" ]; then
echo "::error::DESKTOP_REPO_DISPATCH_TOKEN is required but not set."
exit 1
fi
PAYLOAD="$(jq -n \
--arg release_tag "$RELEASE_TAG" \
--arg release_url "$RELEASE_URL" \
'{
event_type: "studio_release_published",
client_payload: {
release_tag: $release_tag,
release_url: $release_url
}
}')"
curl -fsSL \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${DISPATCH_TOKEN}" \
https://api.github.com/repos/hanzoai/studio-desktop/dispatches \
-d "$PAYLOAD"
echo "✅ Dispatched Hanzo Studio release ${RELEASE_TAG} to hanzoai/studio-desktop"
+13 -8
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
@@ -16,15 +16,18 @@ jobs:
with:
python-version: 3.x
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Install Ruff
run: pip install ruff
run: uv pip install --system ruff
- name: Run Ruff
run: ruff check .
pylint:
name: Run Pylint
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
steps:
- name: Checkout repository
@@ -35,14 +38,16 @@ jobs:
with:
python-version: '3.12'
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Install requirements
run: |
python -m pip install --upgrade pip
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
pip install -r requirements.txt
uv pip install --system torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
uv pip install --system -r requirements.txt
- name: Install Pylint
run: pip install pylint
run: uv pip install --system pylint
- name: Run Pylint
run: pylint comfy_api_nodes
run: pylint studio_api_nodes
+23 -23
View File
@@ -76,7 +76,7 @@ on:
default: true
jobs:
package_comfy_windows:
package_studio_windows:
permissions:
contents: "write"
packages: "write"
@@ -93,12 +93,12 @@ jobs:
with:
path: |
${{ inputs.cache_tag }}_python_deps.tar
update_comfyui_and_python_dependencies.bat
update_studio_and_python_dependencies.bat
key: ${{ runner.os }}-build-${{ inputs.cache_tag }}-${{ inputs.python_minor }}
- shell: bash
run: |
mv ${{ inputs.cache_tag }}_python_deps.tar ../
mv update_comfyui_and_python_dependencies.bat ../
mv update_studio_and_python_dependencies.bat ../
cd ..
tar xf ${{ inputs.cache_tag }}_python_deps.tar
pwd
@@ -107,7 +107,7 @@ jobs:
- shell: bash
run: |
cd ..
cp -r ComfyUI ComfyUI_copy
cp -r HanzoStudio HanzoStudio_copy
curl https://www.python.org/ftp/python/3.${{ inputs.python_minor }}.${{ inputs.python_patch }}/python-3.${{ inputs.python_minor }}.${{ inputs.python_patch }}-embed-amd64.zip -o python_embeded.zip
unzip python_embeded.zip -d python_embeded
cd python_embeded
@@ -117,11 +117,11 @@ jobs:
./python.exe get-pip.py
./python.exe -s -m pip install ../${{ inputs.cache_tag }}_python_deps/*
grep comfy ../ComfyUI/requirements.txt > ./requirements_comfyui.txt
./python.exe -s -m pip install -r requirements_comfyui.txt
rm requirements_comfyui.txt
grep -E "comfyui-frontend-package|comfyui-workflow-templates|comfyui-embedded-docs|comfy-kitchen|comfy-aimdo" ../HanzoStudio/requirements.txt > ./requirements_studio.txt
./python.exe -s -m pip install -r requirements_studio.txt
rm requirements_studio.txt
sed -i '1i../ComfyUI' ./python3${{ inputs.python_minor }}._pth
sed -i '1i../HanzoStudio' ./python3${{ inputs.python_minor }}._pth
if test -f ./Lib/site-packages/torch/lib/dnnl.lib; then
rm ./Lib/site-packages/torch/lib/dnnl.lib #I don't think this is actually used and I need the space
@@ -131,40 +131,40 @@ jobs:
cd ..
git clone --depth 1 https://github.com/comfyanonymous/taesd
cp taesd/*.safetensors ./ComfyUI_copy/models/vae_approx/
git clone --depth 1 https://github.com/hanzoai/taesd
cp taesd/*.safetensors ./HanzoStudio_copy/models/vae_approx/
mkdir ComfyUI_windows_portable
mv python_embeded ComfyUI_windows_portable
mv ComfyUI_copy ComfyUI_windows_portable/ComfyUI
mkdir HanzoStudio_windows_portable
mv python_embeded HanzoStudio_windows_portable
mv HanzoStudio_copy HanzoStudio_windows_portable/HanzoStudio
cd ComfyUI_windows_portable
cd HanzoStudio_windows_portable
mkdir update
cp -r ComfyUI/.ci/update_windows/* ./update/
cp -r ComfyUI/.ci/windows_${{ inputs.rel_name }}_base_files/* ./
cp ../update_comfyui_and_python_dependencies.bat ./update/
cp -r HanzoStudio/.ci/update_windows/* ./update/
cp -r HanzoStudio/.ci/windows_${{ inputs.rel_name }}_base_files/* ./
cp ../update_studio_and_python_dependencies.bat ./update/
cd ..
"C:\Program Files\7-Zip\7z.exe" a -t7z -m0=lzma2 -mx=9 -mfb=128 -md=768m -ms=on -mf=BCJ2 ComfyUI_windows_portable.7z ComfyUI_windows_portable
mv ComfyUI_windows_portable.7z ComfyUI/ComfyUI_windows_portable_${{ inputs.rel_name }}${{ inputs.rel_extra_name }}.7z
"C:\Program Files\7-Zip\7z.exe" a -t7z -m0=lzma2 -mx=9 -mfb=128 -md=768m -ms=on -mf=BCJ2 HanzoStudio_windows_portable.7z HanzoStudio_windows_portable
mv HanzoStudio_windows_portable.7z HanzoStudio/HanzoStudio_windows_portable_${{ inputs.rel_name }}${{ inputs.rel_extra_name }}.7z
- shell: bash
if: ${{ inputs.test_release }}
run: |
cd ..
cd ComfyUI_windows_portable
python_embeded/python.exe -s ComfyUI/main.py --quick-test-for-ci --cpu
cd HanzoStudio_windows_portable
python_embeded/python.exe -s HanzoStudio/main.py --quick-test-for-ci --cpu
python_embeded/python.exe -s ./update/update.py ComfyUI/
python_embeded/python.exe -s ./update/update.py HanzoStudio/
ls
- name: Upload binaries to release
uses: softprops/action-gh-release@v2
with:
files: ComfyUI_windows_portable_${{ inputs.rel_name }}${{ inputs.rel_extra_name }}.7z
files: HanzoStudio_windows_portable_${{ inputs.rel_name }}${{ inputs.rel_extra_name }}.7z
tag_name: ${{ inputs.git_tag }}
draft: true
overwrite_files: true
+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:
+4 -3
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:
@@ -25,7 +25,8 @@ jobs:
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
uv pip install --system -r requirements.txt
+6 -6
View File
@@ -1,6 +1,6 @@
# This is the GitHub Workflow that drives automatic full-GPU-enabled tests of all new commits to the master branch of ComfyUI
# Results are reported as checkmarks on the commits, as well as onto https://ci.comfy.org/
name: Full Comfy CI Workflow Runs
# This is the GitHub Workflow that drives automatic full-GPU-enabled tests of all new commits to the main branch of Hanzo Studio
# Results are reported as checkmarks on the commits, as well as onto https://ci.hanzo.ai/
name: Full Hanzo Studio CI Workflow Runs
on:
push:
branches:
@@ -46,7 +46,7 @@ jobs:
python_version: ${{ matrix.python_version }}
torch_version: ${{ matrix.torch_version }}
google_credentials: ${{ secrets.GCS_SERVICE_ACCOUNT_JSON }}
comfyui_flags: ${{ matrix.flags }}
studio_flags: ${{ matrix.flags }}
# test-win-nightly:
# strategy:
@@ -69,7 +69,7 @@ jobs:
# python_version: ${{ matrix.python_version }}
# torch_version: ${{ matrix.torch_version }}
# google_credentials: ${{ secrets.GCS_SERVICE_ACCOUNT_JSON }}
# comfyui_flags: ${{ matrix.flags }}
# studio_flags: ${{ matrix.flags }}
test-unix-nightly:
strategy:
@@ -96,4 +96,4 @@ jobs:
python_version: ${{ matrix.python_version }}
torch_version: ${{ matrix.torch_version }}
google_credentials: ${{ secrets.GCS_SERVICE_ACCOUNT_JSON }}
comfyui_flags: ${{ matrix.flags }}
studio_flags: ${{ matrix.flags }}
+5 -4
View File
@@ -19,12 +19,13 @@ jobs:
uses: actions/setup-python@v4
with:
python-version: '3.12'
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Install requirements
run: |
python -m pip install --upgrade pip
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
pip install -r requirements.txt
pip install -r tests-unit/requirements.txt
uv pip install --system torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
uv pip install --system -r requirements.txt
uv pip install --system -r tests-unit/requirements.txt
- name: Run Execution Tests
run: |
python -m pytest tests/execution -v --skip-timing-checks
+15 -14
View File
@@ -8,28 +8,29 @@ on:
jobs:
test:
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
steps:
- name: Checkout ComfyUI
- name: Checkout Hanzo Studio
uses: actions/checkout@v4
with:
repository: "Comfy-Org/ComfyUI"
path: "ComfyUI"
repository: "hanzoai/studio"
path: "studio"
- uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Install requirements
run: |
python -m pip install --upgrade pip
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
pip install -r requirements.txt
pip install wait-for-it
working-directory: ComfyUI
- name: Start ComfyUI server
uv pip install --system torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
uv pip install --system -r requirements.txt
uv pip install --system wait-for-it
working-directory: studio
- 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
working-directory: ComfyUI
wait-for-it --service 127.0.0.1:8188 -t 120
working-directory: studio
- name: Check for unhandled exceptions in server log
run: |
grep -v "Found comfy_kitchen backend triton: {'available': False, 'disabled': True, 'unavailable_reason': \"ImportError: No module named 'triton'\", 'capabilities': \[\]}" console_output.log | grep -v "Found comfy_kitchen backend triton: {'available': False, 'disabled': False, 'unavailable_reason': \"ImportError: No module named 'triton'\", 'capabilities': \[\]}" > console_output_filtered.log
@@ -38,10 +39,10 @@ jobs:
echo "Unhandled exception/error found in server log."
exit 1
fi
working-directory: ComfyUI
working-directory: studio
- uses: actions/upload-artifact@v4
if: always()
with:
name: console-output
path: ComfyUI/console_output.log
path: studio/console_output.log
retention-days: 30
+19 -19
View File
@@ -1,4 +1,4 @@
name: Generate Pydantic Stubs from api.comfy.org
name: Generate Pydantic Stubs from api.hanzo.ai
on:
schedule:
@@ -7,50 +7,50 @@ on:
jobs:
generate-models:
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install 'datamodel-code-generator[http]'
npm install @redocly/cli
- name: Download OpenAPI spec
run: |
curl -o openapi.yaml https://api.comfy.org/openapi
curl -o openapi.yaml https://api.hanzo.ai/openapi
- name: Filter OpenAPI spec with Redocly
run: |
npx @redocly/cli bundle openapi.yaml --output filtered-openapi.yaml --config comfy_api_nodes/redocly.yaml --remove-unused-components
npx @redocly/cli bundle openapi.yaml --output filtered-openapi.yaml --config studio_api_nodes/redocly.yaml --remove-unused-components
- name: Generate API models
run: |
datamodel-codegen --use-subclass-enum --input filtered-openapi.yaml --output comfy_api_nodes/apis --output-model-type pydantic_v2.BaseModel
datamodel-codegen --use-subclass-enum --input filtered-openapi.yaml --output studio_api_nodes/apis --output-model-type pydantic_v2.BaseModel
- name: Check for changes
id: git-check
run: |
git diff --exit-code comfy_api_nodes/apis || echo "changes=true" >> $GITHUB_OUTPUT
git diff --exit-code studio_api_nodes/apis || echo "changes=true" >> $GITHUB_OUTPUT
- name: Create Pull Request
if: steps.git-check.outputs.changes == 'true'
uses: peter-evans/create-pull-request@v5
with:
commit-message: 'chore: update API models from OpenAPI spec'
title: 'Update API models from api.comfy.org'
title: 'Update API models from api.hanzo.ai'
body: |
This PR updates the API models based on the latest api.comfy.org OpenAPI specification.
Generated automatically by the a Github workflow.
This PR updates the API models based on the latest api.hanzo.ai OpenAPI specification.
Generated automatically by a Github workflow.
branch: update-api-stubs
delete-branch: true
base: master
base: main
+10 -10
View File
@@ -6,13 +6,13 @@ on:
workflow_dispatch:
inputs:
version:
description: 'ComfyUI version (e.g., v0.7.0)'
description: 'Hanzo Studio version (e.g., v0.7.0)'
required: true
type: string
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:
@@ -26,34 +26,34 @@ jobs:
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Checkout comfyui-ci-container
- name: Checkout studio-ci-container
uses: actions/checkout@v4
with:
repository: comfy-org/comfyui-ci-container
repository: hanzoai/studio-ci-container
token: ${{ secrets.CI_CONTAINER_PAT }}
- name: Check current version
id: current
run: |
CURRENT=$(grep -oP 'ARG COMFYUI_VERSION=\K.*' Dockerfile || echo "unknown")
CURRENT=$(grep -oP 'ARG STUDIO_VERSION=\K.*' Dockerfile || echo "unknown")
echo "current_version=$CURRENT" >> $GITHUB_OUTPUT
- name: Update Dockerfile
run: |
VERSION="${{ steps.version.outputs.version }}"
sed -i "s/^ARG COMFYUI_VERSION=.*/ARG COMFYUI_VERSION=${VERSION}/" Dockerfile
sed -i "s/^ARG STUDIO_VERSION=.*/ARG STUDIO_VERSION=${VERSION}/" Dockerfile
- name: Create Pull Request
id: create-pr
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.CI_CONTAINER_PAT }}
branch: automation/comfyui-${{ steps.version.outputs.version }}
title: "chore: bump ComfyUI to ${{ steps.version.outputs.version }}"
branch: automation/studio-${{ steps.version.outputs.version }}
title: "chore: bump Hanzo Studio to ${{ steps.version.outputs.version }}"
body: |
Updates ComfyUI version from `${{ steps.current.outputs.current_version }}` to `${{ steps.version.outputs.version }}`
Updates Hanzo Studio version from `${{ steps.current.outputs.current_version }}` to `${{ steps.version.outputs.version }}`
**Triggered by:** ${{ github.event_name == 'release' && format('[Release {0}]({1})', github.event.release.tag_name, github.event.release.html_url) || 'Manual workflow dispatch' }}
labels: automation
commit-message: "chore: bump ComfyUI to ${{ steps.version.outputs.version }}"
commit-message: "chore: bump Hanzo Studio to ${{ steps.version.outputs.version }}"
+7 -7
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:
@@ -30,9 +30,9 @@ jobs:
run: |
python -m pip install --upgrade pip
- name: Update comfyui_version.py
- name: Update studio_version.py
run: |
# Read version from pyproject.toml and update comfyui_version.py
# Read version from pyproject.toml and update studio_version.py
python -c '
import tomllib
@@ -41,8 +41,8 @@ jobs:
config = tomllib.load(f)
version = config["project"]["version"]
# Write version to comfyui_version.py
with open("comfyui_version.py", "w") as f:
# Write version to studio_version.py
with open("studio_version.py", "w") as f:
f.write("# This file is automatically generated by the build process when version is\n")
f.write("# updated in pyproject.toml.\n")
f.write(f"__version__ = \"{version}\"\n")
@@ -54,6 +54,6 @@ jobs:
git config --local user.email "github-actions@github.com"
git fetch origin ${{ github.head_ref }}
git checkout -B ${{ github.head_ref }} origin/${{ github.head_ref }}
git add comfyui_version.py
git diff --quiet && git diff --staged --quiet || git commit -m "chore: Update comfyui_version.py to match pyproject.toml"
git add studio_version.py
git diff --quiet && git diff --staged --quiet || git commit -m "chore: Update studio_version.py to match pyproject.toml"
git push origin HEAD:${{ github.head_ref }}
@@ -29,7 +29,7 @@ on:
description: 'python patch version'
required: true
type: string
default: "9"
default: "11"
# push:
# branches:
# - master
@@ -46,18 +46,18 @@ jobs:
- shell: bash
run: |
echo "@echo off
call update_comfyui.bat nopause
call update_studio.bat nopause
echo -
echo This will try to update pytorch and all python dependencies.
echo -
echo If you just want to update normally, close this and run update_comfyui.bat instead.
echo If you just want to update normally, close this and run update_studio.bat instead.
echo -
pause
..\python_embeded\python.exe -s -m pip install --upgrade torch torchvision torchaudio ${{ inputs.xformers }} --extra-index-url https://download.pytorch.org/whl/cu${{ inputs.cu }} -r ../ComfyUI/requirements.txt pygit2
pause" > update_comfyui_and_python_dependencies.bat
..\python_embeded\python.exe -s -m pip install --upgrade torch torchvision torchaudio ${{ inputs.xformers }} --extra-index-url https://download.pytorch.org/whl/cu${{ inputs.cu }} -r ../HanzoStudio/requirements.txt pygit2
pause" > update_studio_and_python_dependencies.bat
grep -v comfyui requirements.txt > requirements_nocomfyui.txt
python -m pip wheel --no-cache-dir torch torchvision torchaudio ${{ inputs.xformers }} ${{ inputs.extra_dependencies }} --extra-index-url https://download.pytorch.org/whl/cu${{ inputs.cu }} -r requirements_nocomfyui.txt pygit2 -w ./temp_wheel_dir
grep -v studio requirements.txt > requirements_nostudio.txt
python -m pip wheel --no-cache-dir torch torchvision torchaudio ${{ inputs.xformers }} ${{ inputs.extra_dependencies }} --extra-index-url https://download.pytorch.org/whl/cu${{ inputs.cu }} -r requirements_nostudio.txt pygit2 -w ./temp_wheel_dir
python -m pip install --no-cache-dir ./temp_wheel_dir/*
echo installed basic
ls -lah temp_wheel_dir
@@ -68,5 +68,5 @@ jobs:
with:
path: |
cu${{ inputs.cu }}_python_deps.tar
update_comfyui_and_python_dependencies.bat
update_studio_and_python_dependencies.bat
key: ${{ runner.os }}-build-cu${{ inputs.cu }}-${{ inputs.python_minor }}
@@ -38,18 +38,18 @@ jobs:
- shell: bash
run: |
echo "@echo off
call update_comfyui.bat nopause
call update_studio.bat nopause
echo -
echo This will try to update pytorch and all python dependencies.
echo -
echo If you just want to update normally, close this and run update_comfyui.bat instead.
echo If you just want to update normally, close this and run update_studio.bat instead.
echo -
pause
..\python_embeded\python.exe -s -m pip install --upgrade ${{ inputs.torch_dependencies }} -r ../ComfyUI/requirements.txt pygit2
pause" > update_comfyui_and_python_dependencies.bat
..\python_embeded\python.exe -s -m pip install --upgrade ${{ inputs.torch_dependencies }} -r ../HanzoStudio/requirements.txt pygit2
pause" > update_studio_and_python_dependencies.bat
grep -v comfyui requirements.txt > requirements_nocomfyui.txt
python -m pip wheel --no-cache-dir ${{ inputs.torch_dependencies }} -r requirements_nocomfyui.txt pygit2 -w ./temp_wheel_dir
grep -v studio requirements.txt > requirements_nostudio.txt
python -m pip wheel --no-cache-dir ${{ inputs.torch_dependencies }} -r requirements_nostudio.txt pygit2 -w ./temp_wheel_dir
python -m pip install --no-cache-dir ./temp_wheel_dir/*
echo installed basic
ls -lah temp_wheel_dir
@@ -60,5 +60,5 @@ jobs:
with:
path: |
${{ inputs.cache_tag }}_python_deps.tar
update_comfyui_and_python_dependencies.bat
update_studio_and_python_dependencies.bat
key: ${{ runner.os }}-build-${{ inputs.cache_tag }}-${{ inputs.python_minor }}
@@ -42,45 +42,45 @@ jobs:
- shell: bash
run: |
cd ..
cp -r ComfyUI ComfyUI_copy
cp -r HanzoStudio HanzoStudio_copy
curl https://www.python.org/ftp/python/3.${{ inputs.python_minor }}.${{ inputs.python_patch }}/python-3.${{ inputs.python_minor }}.${{ inputs.python_patch }}-embed-amd64.zip -o python_embeded.zip
unzip python_embeded.zip -d python_embeded
cd python_embeded
echo 'import site' >> ./python3${{ inputs.python_minor }}._pth
curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py
./python.exe get-pip.py
python -m pip wheel torch torchvision torchaudio --pre --extra-index-url https://download.pytorch.org/whl/nightly/cu${{ inputs.cu }} -r ../ComfyUI/requirements.txt pygit2 -w ../temp_wheel_dir
python -m pip wheel torch torchvision torchaudio --pre --extra-index-url https://download.pytorch.org/whl/nightly/cu${{ inputs.cu }} -r ../HanzoStudio/requirements.txt pygit2 -w ../temp_wheel_dir
ls ../temp_wheel_dir
./python.exe -s -m pip install --pre ../temp_wheel_dir/*
sed -i '1i../ComfyUI' ./python3${{ inputs.python_minor }}._pth
sed -i '1i../HanzoStudio' ./python3${{ inputs.python_minor }}._pth
rm ./Lib/site-packages/torch/lib/dnnl.lib #I don't think this is actually used and I need the space
cd ..
git clone --depth 1 https://github.com/comfyanonymous/taesd
cp taesd/*.safetensors ./ComfyUI_copy/models/vae_approx/
git clone --depth 1 https://github.com/hanzoai/taesd
cp taesd/*.safetensors ./HanzoStudio_copy/models/vae_approx/
mkdir ComfyUI_windows_portable_nightly_pytorch
mv python_embeded ComfyUI_windows_portable_nightly_pytorch
mv ComfyUI_copy ComfyUI_windows_portable_nightly_pytorch/ComfyUI
mkdir HanzoStudio_windows_portable_nightly_pytorch
mv python_embeded HanzoStudio_windows_portable_nightly_pytorch
mv HanzoStudio_copy HanzoStudio_windows_portable_nightly_pytorch/HanzoStudio
cd ComfyUI_windows_portable_nightly_pytorch
cd HanzoStudio_windows_portable_nightly_pytorch
mkdir update
cp -r ComfyUI/.ci/update_windows/* ./update/
cp -r ComfyUI/.ci/windows_nvidia_base_files/* ./
cp -r ComfyUI/.ci/windows_nightly_base_files/* ./
cp -r HanzoStudio/.ci/update_windows/* ./update/
cp -r HanzoStudio/.ci/windows_nvidia_base_files/* ./
cp -r HanzoStudio/.ci/windows_nightly_base_files/* ./
echo "call update_comfyui.bat nopause
..\python_embeded\python.exe -s -m pip install --upgrade --pre torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/nightly/cu${{ inputs.cu }} -r ../ComfyUI/requirements.txt pygit2
pause" > ./update/update_comfyui_and_python_dependencies.bat
echo "call update_studio.bat nopause
..\python_embeded\python.exe -s -m pip install --upgrade --pre torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/nightly/cu${{ inputs.cu }} -r ../HanzoStudio/requirements.txt pygit2
pause" > ./update/update_studio_and_python_dependencies.bat
cd ..
"C:\Program Files\7-Zip\7z.exe" a -t7z -m0=lzma2 -mx=9 -mfb=128 -md=512m -ms=on -mf=BCJ2 ComfyUI_windows_portable_nightly_pytorch.7z ComfyUI_windows_portable_nightly_pytorch
mv ComfyUI_windows_portable_nightly_pytorch.7z ComfyUI/ComfyUI_windows_portable_nvidia_or_cpu_nightly_pytorch.7z
"C:\Program Files\7-Zip\7z.exe" a -t7z -m0=lzma2 -mx=9 -mfb=128 -md=512m -ms=on -mf=BCJ2 HanzoStudio_windows_portable_nightly_pytorch.7z HanzoStudio_windows_portable_nightly_pytorch
mv HanzoStudio_windows_portable_nightly_pytorch.7z HanzoStudio/HanzoStudio_windows_portable_nvidia_or_cpu_nightly_pytorch.7z
cd ComfyUI_windows_portable_nightly_pytorch
python_embeded/python.exe -s ComfyUI/main.py --quick-test-for-ci --cpu
cd HanzoStudio_windows_portable_nightly_pytorch
python_embeded/python.exe -s HanzoStudio/main.py --quick-test-for-ci --cpu
ls
@@ -88,6 +88,6 @@ jobs:
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ComfyUI_windows_portable_nvidia_or_cpu_nightly_pytorch.7z
file: HanzoStudio_windows_portable_nvidia_or_cpu_nightly_pytorch.7z
tag: "latest"
overwrite: true
+20 -20
View File
@@ -25,7 +25,7 @@ on:
# - master
jobs:
package_comfyui:
package_studio:
permissions:
contents: "write"
packages: "write"
@@ -37,12 +37,12 @@ jobs:
with:
path: |
cu${{ inputs.cu }}_python_deps.tar
update_comfyui_and_python_dependencies.bat
update_studio_and_python_dependencies.bat
key: ${{ runner.os }}-build-cu${{ inputs.cu }}-${{ inputs.python_minor }}
- shell: bash
run: |
mv cu${{ inputs.cu }}_python_deps.tar ../
mv update_comfyui_and_python_dependencies.bat ../
mv update_studio_and_python_dependencies.bat ../
cd ..
tar xf cu${{ inputs.cu }}_python_deps.tar
pwd
@@ -55,7 +55,7 @@ jobs:
- shell: bash
run: |
cd ..
cp -r ComfyUI ComfyUI_copy
cp -r HanzoStudio HanzoStudio_copy
curl https://www.python.org/ftp/python/3.${{ inputs.python_minor }}.${{ inputs.python_patch }}/python-3.${{ inputs.python_minor }}.${{ inputs.python_patch }}-embed-amd64.zip -o python_embeded.zip
unzip python_embeded.zip -d python_embeded
cd python_embeded
@@ -63,36 +63,36 @@ jobs:
curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py
./python.exe get-pip.py
./python.exe -s -m pip install ../cu${{ inputs.cu }}_python_deps/*
sed -i '1i../ComfyUI' ./python3${{ inputs.python_minor }}._pth
sed -i '1i../HanzoStudio' ./python3${{ inputs.python_minor }}._pth
rm ./Lib/site-packages/torch/lib/dnnl.lib #I don't think this is actually used and I need the space
rm ./Lib/site-packages/torch/lib/libprotoc.lib
rm ./Lib/site-packages/torch/lib/libprotobuf.lib
cd ..
git clone --depth 1 https://github.com/comfyanonymous/taesd
cp taesd/*.safetensors ./ComfyUI_copy/models/vae_approx/
git clone --depth 1 https://github.com/hanzoai/taesd
cp taesd/*.safetensors ./HanzoStudio_copy/models/vae_approx/
mkdir ComfyUI_windows_portable
mv python_embeded ComfyUI_windows_portable
mv ComfyUI_copy ComfyUI_windows_portable/ComfyUI
mkdir HanzoStudio_windows_portable
mv python_embeded HanzoStudio_windows_portable
mv HanzoStudio_copy HanzoStudio_windows_portable/HanzoStudio
cd ComfyUI_windows_portable
cd HanzoStudio_windows_portable
mkdir update
cp -r ComfyUI/.ci/update_windows/* ./update/
cp -r ComfyUI/.ci/windows_nvidia_base_files/* ./
cp ../update_comfyui_and_python_dependencies.bat ./update/
cp -r HanzoStudio/.ci/update_windows/* ./update/
cp -r HanzoStudio/.ci/windows_nvidia_base_files/* ./
cp ../update_studio_and_python_dependencies.bat ./update/
cd ..
"C:\Program Files\7-Zip\7z.exe" a -t7z -m0=lzma2 -mx=9 -mfb=128 -md=768m -ms=on -mf=BCJ2 ComfyUI_windows_portable.7z ComfyUI_windows_portable
mv ComfyUI_windows_portable.7z ComfyUI/new_ComfyUI_windows_portable_nvidia_cu${{ inputs.cu }}_or_cpu.7z
"C:\Program Files\7-Zip\7z.exe" a -t7z -m0=lzma2 -mx=9 -mfb=128 -md=768m -ms=on -mf=BCJ2 HanzoStudio_windows_portable.7z HanzoStudio_windows_portable
mv HanzoStudio_windows_portable.7z HanzoStudio/new_HanzoStudio_windows_portable_nvidia_cu${{ inputs.cu }}_or_cpu.7z
cd ComfyUI_windows_portable
python_embeded/python.exe -s ComfyUI/main.py --quick-test-for-ci --cpu
cd HanzoStudio_windows_portable
python_embeded/python.exe -s HanzoStudio/main.py --quick-test-for-ci --cpu
python_embeded/python.exe -s ./update/update.py ComfyUI/
python_embeded/python.exe -s ./update/update.py HanzoStudio/
ls
@@ -100,7 +100,7 @@ jobs:
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: new_ComfyUI_windows_portable_nvidia_cu${{ inputs.cu }}_or_cpu.7z
file: new_HanzoStudio_windows_portable_nvidia_cu${{ inputs.cu }}_or_cpu.7z
tag: "latest"
overwrite: true
+7
View File
@@ -0,0 +1,7 @@
name: Workflow Sanity
on:
pull_request:
paths: ['.github/workflows/**']
jobs:
sanity:
uses: hanzoai/.github/.github/workflows/workflow-sanity.yml@main
+8 -3
View File
@@ -5,13 +5,17 @@ __pycache__/
!/input/example.png
/models/
/temp/
/custom_nodes/
!custom_nodes/example_node.py.example
# Ignore vendored pack contents but keep the directory listable so the tracked
# files below can be re-included (a file under a fully-excluded dir cannot be).
/custom_nodes/*
!/custom_nodes/example_node.py.example
# Pinned pack manifest is tracked; the pack sources it clones are not (vendored at build).
!/custom_nodes/hanzo-packs.txt
extra_model_paths.yaml
/.vs
.vscode/
.idea/
venv/
venv*/
.venv/
/web/extensions/*
!/web/extensions/logging.js.example
@@ -24,3 +28,4 @@ web_custom_versions/
openapi.yaml
filtered-openapi.yaml
uv.lock
/custom_nodes_quarantine/
Symlink
+1
View File
@@ -0,0 +1 @@
LLM.md
Symlink
+1
View File
@@ -0,0 +1 @@
LLM.md
+1 -1
View File
@@ -1,2 +1,2 @@
# Admins
* @comfyanonymous @kosinkadink @guill
* @hanzoai/studio
+8 -8
View File
@@ -1,12 +1,12 @@
# Contributing to ComfyUI
# Contributing to Hanzo Studio
Welcome, and thank you for your interest in contributing to ComfyUI!
Welcome, and thank you for your interest in contributing to Hanzo Studio!
There are several ways in which you can contribute, beyond writing code. The goal of this document is to provide a high-level overview of how you can get involved.
## Asking Questions
Have a question? Instead of opening an issue, please ask on [Discord](https://comfy.org/discord) or [Matrix](https://app.element.io/#/room/%23comfyui_space%3Amatrix.org) channels. Our team and the community will help you.
Have a question? Instead of opening an issue, please ask on [Discord](https://discord.gg/hanzoai) first. Our team and the community will help you.
## Providing Feedback
@@ -16,24 +16,24 @@ See the `#bug-report`, `#feature-request` and `#feedback` channels on Discord.
## Reporting Issues
Have you identified a reproducible problem in ComfyUI? Do you have a feature request? We want to hear about it! Here's how you can report your issue as effectively as possible.
Have you identified a reproducible problem in Hanzo Studio? Do you have a feature request? We want to hear about it! Here's how you can report your issue as effectively as possible.
### Look For an Existing Issue
Before you create a new issue, please do a search in [open issues](https://github.com/comfyanonymous/ComfyUI/issues) to see if the issue or feature request has already been filed.
Before you create a new issue, please do a search in [open issues](https://github.com/hanzoai/studio/issues) to see if the issue or feature request has already been filed.
If you find your issue already exists, make relevant comments and add your [reaction](https://github.com/blog/2119-add-reactions-to-pull-requests-issues-and-comments). Use a reaction in place of a "+1" comment:
* 👍 - upvote
* 👎 - downvote
* upvote
* downvote
If you cannot find an existing issue that describes your bug or feature, create a new issue. We have an issue template in place to organize new issues.
### Creating Pull Requests
* Please refer to the article on [creating pull requests](https://github.com/comfyanonymous/ComfyUI/wiki/How-to-Contribute-Code) and contributing to this project.
* Please refer to the article on [creating pull requests](https://github.com/hanzoai/studio/wiki/How-to-Contribute-Code) and contributing to this project.
## Thank You
+92
View File
@@ -0,0 +1,92 @@
# ── studio-chat SPA (web/) ── the /v1/agent chat UI served at /studio. Built here
# so the image is self-contained; the small Vite bundle lands at /app/web/dist.
FROM node:20-slim AS web
WORKDIR /web
COPY web/package.json web/package-lock.json* ./
RUN npm ci 2>/dev/null || npm install
COPY web/ ./
RUN npm run build
FROM python:3.11-slim AS base
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
build-essential \
libgl1 \
libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install PyTorch CPU first (large layer, cached separately)
RUN pip install --no-cache-dir \
torch torchvision torchaudio \
--index-url https://download.pytorch.org/whl/cpu
# Install remaining dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Apply Hanzo Studio branding to frontend package
COPY branding/ /tmp/branding/
RUN chmod +x /tmp/branding/apply-branding.sh && /tmp/branding/apply-branding.sh && rm -rf /tmp/branding
# Copy application code
COPY . .
# The studio-chat SPA built above → served at /studio by middleware/studio_home.py.
COPY --from=web /web/dist ./web/dist
# Vendor the Hanzo Studio custom-node packs (segmentation, controlnet, ipadapter,
# upscaling, video, utils) at their pinned commits and install their deps with the
# torch/numpy/transformers stack locked. See custom_nodes/hanzo-packs.txt.
RUN chmod +x scripts/install_custom_nodes.sh && scripts/install_custom_nodes.sh
# Create directories for runtime volumes
RUN mkdir -p models output input custom_nodes user
# /app/orgs is the tenant-data PVC; /app/models is ephemeral overlay. Register
# .models on the PVC as the default model store (main.py auto-loads this file)
# so models survive pod restarts and downloads land durable.
RUN printf '%s\n' \
'hanzo:' \
' base_path: /app/orgs/.models' \
' is_default: true' \
' checkpoints: checkpoints' \
' configs: configs' \
' loras: loras' \
' vae: vae' \
' text_encoders: text_encoders' \
' diffusion_models: diffusion_models' \
' clip_vision: clip_vision' \
' style_models: style_models' \
' embeddings: embeddings' \
' diffusers: diffusers' \
' vae_approx: vae_approx' \
' controlnet: controlnet' \
' gligen: gligen' \
' upscale_models: upscale_models' \
' latent_upscale_models: latent_upscale_models' \
' hypernetworks: hypernetworks' \
' photomaker: photomaker' \
' classifiers: classifiers' \
' model_patches: model_patches' \
' audio_encoders: audio_encoders' \
> extra_model_paths.yaml
EXPOSE 8188
# Default: listen on all interfaces, CPU mode
# Environment variables:
# STUDIO_ENABLE_IAM_AUTH=1 - Enable IAM auth via hanzo.id
# STUDIO_IAM_URL=https://hanzo.id
# STUDIO_NO_LOCALHOST_BYPASS=1 - Require auth even for localhost
# STUDIO_MULTI_TENANT=1 - Per-org storage isolation
# STUDIO_ORG_ID=default-org - Default org ID
# STUDIO_ENABLE_BILLING=1 - Commerce billing integration
# STUDIO_BILLING_CHECK_BALANCE=1 - Check balance before prompts
# STUDIO_COMMERCE_URL=http://commerce.hanzo.svc:8001
# STUDIO_COMMERCE_TOKEN=... - Commerce API token
# STUDIO_ENABLE_METRICS=1 - /metrics endpoint for VictoriaMetrics
# STUDIO_RATE_LIMIT_RPM=60 - Prompts per minute per org
CMD ["python", "main.py", "--listen", "0.0.0.0", "--port", "8188", "--cpu"]
+191
View File
@@ -0,0 +1,191 @@
# Hanzo Studio
## Overview
**The most powerful and modular visual AI engine and application.**
## Tech Stack
- **Language**: Python
## Build & Run
```bash
uv sync
uv run pytest
```
## Structure
```
studio/
CODEOWNERS
CONTRIBUTING.md
Dockerfile
LICENSE
QUANTIZATION.md
README.md
alembic.ini
alembic_db/
api_server/
app/
blueprints/
branding/
comfyui.log
comfyui.prev.log
comfyui.prev2.log
```
## Key Files
- `README.md` -- Project documentation
- `pyproject.toml` -- Python project config
- `Dockerfile` -- Container build (applies `branding/` then runs `main.py`)
- `hanzo.yml` + `.github/workflows/cicd.yml` -- canonical CI/CD (hanzoai/ci); builds `ghcr.io/hanzoai/studio`, rolls the `studio` operator Service CR at `studio.hanzo.ai`.
## Custom-node packs ("hanzo packages")
The popular ComfyUI packs are vendored as first-class hanzo packages, pinned and
reproducible. One and only one way to add/bump them:
- **Manifest**: `custom_nodes/hanzo-packs.txt` — `<dir> <git_url> <commit_sha>
<spdx>` lines (immutable SHAs). This is the tracked source of truth; the cloned
pack sources are gitignored (vendored at build).
- **Deps**: `custom_nodes-requirements.txt` — hand-resolved, only what's not
already in `requirements.txt`. Installed with the studio torch(cu130)/NumPy 2.x/
Transformers 5.x stack **locked** so packs can never downgrade it.
- **Installer**: `scripts/install_custom_nodes.sh` clones each pack at its pin and
installs deps; the lock is derived from the live env (correct for both the CPU
image and the GB10 cu130 venv). `ultralytics` is installed `--no-deps` (else it
pulls non-headless opencv). Local (GB10): `STUDIO_PY=.venv/bin/python
STUDIO_PIP="uv pip install --python .venv/bin/python" scripts/install_custom_nodes.sh`.
- **Image**: `Dockerfile` runs the installer after `COPY . .`; `.dockerignore`
re-includes only the manifest + installer (`custom_nodes/*` / `scripts/*` form).
So `git push origin main` → CI builds `ghcr.io/hanzoai/studio` with the packs →
studio.hanzo.ai gets them on the next image build.
**Compat shim — why packs work here at all.** This fork renamed ComfyUI's core
packages (`comfy`→`studio`, `comfy_extras`→`studio_extras`, `comfy/comfy_types`→
`studio/node_types`, and the `Comfy*`→`Studio*` API classes; see
`scripts/rename_modules.py`). `studio_compat/` is one meta-path finder that lazily
aliases every `comfy*` import to its `studio*` counterpart and mirrors
`ComfyExtension`/`io.ComfyNode`→`Studio*` — so upstream packs run **unmodified**,
no forks/renames. It is the phase-4 redirect `rename_modules.py` deferred, done
with the 3.12 `find_spec` API. Activated by `import studio_compat` at the top of
`main.py`. `nodes.py` also honors the upstream `comfy_entrypoint` V3 name.
Restart the local worker only when idle: `scripts/studio-service-swap.sh` polls
`/queue` and restarts on the first empty window. Bad packs are isolated by the
loader (studio still boots); genuinely-incompatible ones go to
`custom_nodes_quarantine/` (e.g. was-node-suite: hard `numba` dep vs NumPy 2.5).
## Multi-tenant + IAM (cloud: studio.hanzo.ai)
Standalone app; `console.hanzo.ai` link-outs land here already authenticated via
shared Hanzo IAM (standard OIDC code flow). No embedding.
- **Auth** (`middleware/iam_auth_middleware.py`): local **JWKS** JWT validation
against `${STUDIO_IAM_URL}/v1/iam/.well-known/jwks` — signature + `exp` +
`iss` + `aud` (client_id `hanzo-studio`). Org from the `owner` claim
(`organization` fallback) → `request["iam_user"]["org_id"]`. Anonymous mode:
off by default locally (loopback bypass); enable in cloud with
`STUDIO_ENABLE_IAM_AUTH=true` (the one auth switch — this is the
"STUDIO_AUTH=off default" behavior). Browser flow: unauth HTML → IAM
`authorize` (code+PKCE) → `/callback` exchanges the code, sets an httpOnly
session cookie (no frontend changes). `verify_jwt(...)` is a pure, unit-tested
function.
- **Tenancy** (`folder_paths.py`, `app/user_manager.py`): with
`STUDIO_MULTI_TENANT=true`, userdata is `user/orgs/{org}/user/{user}/…` and
outputs are `output/orgs/{org}/…`. `get_public_user_directory(user, org)` is
org-rooted (fixed the bug where org userdata always resolved to `None`).
Execution outputs are scoped via `folder_paths.set_execution_org()`, bound by
the prompt worker from the queue item's `org_id`.
- **GPU federation** (`middleware/{worker_client,prompt_router,compute_config,
visor_client}.py`): `/v1/workers/register` (heartbeat), `/v1/workers` (list),
`/v1/worker/execute` (in-cluster push fast path). Worker↔coordinator trust via
`X-Worker-Token` (`STUDIO_WORKER_TOKEN`, KMS-sourced).
- **Durable render queue** (`middleware/tasks_queue.py`, `docs/federation.md`):
`SqlitePromptQueue` — crash-durable render queue in a single SQLite file
(stdlib `sqlite3`, WAL, **zero external processes**). `STUDIO_QUEUE_DB=<path>`
→ `server.py` swaps `PromptQueue` for it (precedence **STUDIO_QUEUE_DB >
STUDIO_PERSIST_QUEUE > memory**; default local behavior unchanged). The
`PromptQueue` seam maps to a durable job state machine: `put→INSERT (pending,
idempotent on prompt_id)`, `get→claim next pending under BEGIN IMMEDIATE
(exactly-once, leased)`, `task_done→done | retry/failed`. Crash-recovery: a
lease + reap returns an abandoned claim to `pending` (on every get(), a
heartbeat thread, and on boot) so another process re-runs it — idempotent
(SaveImage suffixes increment). Multiple Studio processes on the same DB file
compete safely. Tested: `middleware_test/tasks_queue_test.py` (incl.
crash-recovery across instances). Future (federation.md §6): a remote queue
backend served by Hanzo Tasks + a Go/unified-binary (HIP-0106) migration.
- **Engine selector** (`middleware/engine_selector.py`): `/v1/engines` lists
execution targets for the org (`local` + registered compute_config workers) and
`PUT /v1/engines/default` sets a per-org default (stored on
`ComputeProfile.default_engine`). `prompt_router.route_prompt` routes to the
chosen worker; `local` = unchanged. Leased cloud machines (Visor
`GET /v1/machines`) are a future engine class (interface stubbed, not built).
Frontend picker is a TODO. Tested: `middleware_test/engine_selector_test.py`.
- **Chat Copilot** (`middleware/copilot.py` + `branding/hanzo-copilot.{js,css}`):
a sidebar chat that reads and MUTATES the workflow graph in natural language.
Backend `POST /v1/copilot/chat` proxies one OpenAI tool-calling round to a
chat endpoint (`STUDIO_COPILOT_URL`; default = local engine
`127.0.0.1:1234` if its port is open, else `api.hanzo.ai`; `STUDIO_COPILOT_MODEL`
default `zen`; token from `STUDIO_COPILOT_TOKEN`→`STUDIO_COMMERCE_TOKEN`→request
bearer). It returns `{reply, ops}`; `add_node` ops are validated against
`nodes.NODE_CLASS_MAPPINGS` server-side so the model can't invent node types.
The **frontend** applies ops against `app.graph` (LiteGraph) — op schema:
`add_node`(id?/pos?) · `set_widget` · `connect`(slots by name or index) ·
`set_prompt` · `move_node` · `delete_node` · `layout` (columnar tidy) ·
`queue`. Every op is guarded (unknown type/slot = skip + note, never throw).
Injected into the prebuilt frontend by `branding/patch_copilot.py` (copies the
bundle into static/, CSS `<link>` in `<head>`, deferred `<script>` before
`</body>`; idempotent, wired as step 9 of `apply-branding.sh`) — survives
frontend upgrades. `window.HanzoCopilot.applyOps` is exposed for tests. Tab
registered via `app.extensionManager.registerSidebarTab` (id `hanzo-copilot`).
Tested: `middleware_test/copilot_test.py` (op-validation + mocked tool loop) and
`branding/copilot_smoke.py` (headless: applier mutates a mock graph + panel
renders). Live end-to-end needs one server restart to register the route.
- **Identity user menu + session API** (`middleware/session.py` +
`branding/hanzo-identity.{js,css}`): the top-right user menu, driven entirely
by the IAM-validated token. Backend (thin, `/v1` house style, registered in
`add_routes` so it works auth-on and auth-off): `GET /v1/session` →
`{user,email,org,orgs[],project,projects[],console_url,iam_url,authenticated}`
read from `request["iam_user"]`; `POST /v1/session/org` sets the active org
(validated against membership → `studio_active_org` cookie); `POST
/v1/session/project` sets the workflow-workspace project scope; `GET|POST
/logout` clears the session + selection cookies and bounces to the IAM
end-session → `/login`. **Org switching is real, one hook**: the auth
middleware applies `session.resolve_org(...)` to a COPY of the validated user
(never the cached dict) so `iam_user["org_id"]` — the id every tenancy/output
path already reads — reflects the switch; membership comes from
`_orgs_from_claims` (`owner` + `organizations`/`orgs`/`groups`, list or CSV or
`{name}` dicts, home first, deduped). Frontend is a SPA-agnostic fixed pill +
dropdown (avatar/initials, user, org switcher, project switcher, Console
link → `console.hanzo.ai`, Logout); 401 → "Sign in with Hanzo" → `/login`.
Injected by `branding/patch_identity.py` (step 10 of `apply-branding.sh`,
same seam as the copilot). `window.HanzoIdentity` is exposed for tests.
Tested: `middleware_test/session_test.py` (resolve/orgs/routes/logout, real
aiohttp app) + `scratchpad/studio-iam/test_identity.py` (headless: pill,
dropdown, org switch re-scope, signed-out). The `/v1/session` routes need one
server restart to register (static + de-Comfy load without restart).
- **De-Comfy display strings** (`branding/patch_destring.py`, step 11 —
runs LAST): the systematic English brand-word sweep the curated phrase list in
`patch_frontend.py` structurally can't reach. Rule 1: whole-word `ComfyUI` →
`Hanzo Studio` only where it reads as a display word (preceded by
open-quote/space/`>`/`(`, followed by close-quote/space/sentence-punct) — that
boundary skips every code use in the bundle (`ComfyUIStarted`, `.ComfyUI`,
`=ComfyUI`, the `/^(ComfyUI-.../` regex, dotted i18n keys `.category.ComfyUI`)
and hyphenated product names (`ComfyUI-Manager`). Rule 2: curated bare-`Comfy`
**values** as exact `key:` value`` pairs so the Settings dialog **keys**
(`Comfy`, `Comfy-Desktop`, `Comfy.ColorPalette`) are preserved while the
displayed section header becomes **Studio** / **Studio Desktop** (this is the
"Settings must not say Comfy" fix) + `Comfy Cloud` → `Hanzo Cloud`. Scope:
English only — files above `LOCALE_RATIO` (0.15%) non-ASCII are skipped (all 33
locale bundles are ≥0.31%; English bundles ~0.02%) and each match needs a
pure-ASCII window, so the non-English `main-*/nodeDefs-*/commands-*` chunks are
left as-is (noted, out of scope). Idempotent. Verified: served English display
`ComfyUI` 63→0; a few example filesystem paths (`Documents/ComfyUI`) and the
external comfy.org service labels (`Comfy API Key`, `Comfy Node Registry`,
behind disabled API-nodes) are intentionally left. patch_frontend.py keeps its
orthogonal concern (URLs/domains + `Comfy-Org` → `Hanzo AI`).
## Tests
`tests-unit/{folder_paths_test,middleware_test,app_test,prompt_server_test}` —
run `uv run pytest tests-unit/folder_paths_test tests-unit/middleware_test
tests-unit/app_test tests-unit/prompt_server_test -q`. The auth/tenancy work is
covered by `middleware_test/iam_auth_test.py`,
`middleware_test/session_test.py` and
`folder_paths_test/org_scoping_test.py`.
+42
View File
@@ -0,0 +1,42 @@
Hanzo Studio
============
Hanzo Studio is a fork of ComfyUI (https://github.com/comfyanonymous/ComfyUI),
Copyright (c) ComfyUI contributors, licensed under the GNU General Public
License v3.0 (GPL-3.0).
Modifications Copyright (c) 2026 Hanzo Industries Inc, also released under the
GNU General Public License v3.0 (GPL-3.0). See the LICENSE file for the full
license text.
As required by the GPL-3.0, this fork remains under the same license as the
upstream project. The original ComfyUI copyright notices are retained.
Modifications made in this fork
-------------------------------
- Branding: user-visible name changed from "ComfyUI" to "Hanzo Studio",
including the served page <title>, favicon, in-app logos, and display
strings. The prebuilt frontend package (comfyui-frontend-package) is patched
at install time by branding/apply-branding.sh; source assets live in
branding/. Backend Python packages are namespaced under studio_* and the
server reports itself as "Hanzo Studio".
- 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/):
product-shot, garment-edit, and caption-dataset workflow templates for a
designer's day-to-day use.
- Deployment/integration middleware for Hanzo infrastructure (IAM auth via
hanzo.id, commerce/billing, metrics), wired through environment variables and
the Dockerfile.
Third-party components retain their own upstream copyrights and licenses.
+7 -7
View File
@@ -1,4 +1,4 @@
# The Comfy guide to Quantization
# The Hanzo Studio guide to Quantization
## How does quantization work?
@@ -27,7 +27,7 @@ tensor_dq ~ tensor
Given that additional information (scaling factor) is needed to "interpret" the quantized values, we describe those as derived datatypes.
## Quantization in Comfy
## Quantization in Hanzo Studio
```
QuantizedTensor (torch.Tensor subclass)
@@ -39,7 +39,7 @@ MixedPrecisionOps + Metadata Detection
### Representation
To represent these derived datatypes, ComfyUI uses a subclass of torch.Tensor to implements these using the `QuantizedTensor` class found in `comfy/quant_ops.py`
To represent these derived datatypes, Hanzo Studio uses a subclass of torch.Tensor to implements these using the `QuantizedTensor` class found in `studio/quant_ops.py`
A `Layout` class defines how a specific quantization format behaves:
- Required parameters
@@ -47,7 +47,7 @@ A `Layout` class defines how a specific quantization format behaves:
- De-Quantize method
```python
from comfy.quant_ops import QuantizedLayout
from studio.quant_ops import QuantizedLayout
class MyLayout(QuantizedLayout):
@classmethod
@@ -67,7 +67,7 @@ The first is a **generic registry** that handles operations common to all quanti
The second registry is layout-specific and allows to implement fast-paths like nn.Linear.
```python
from comfy.quant_ops import register_layout_op
from studio.quant_ops import register_layout_op
@register_layout_op(torch.ops.aten.linear.default, MyLayout)
def my_linear(func, args, kwargs):
@@ -80,7 +80,7 @@ For any unsupported operation, QuantizedTensor will fallback to call `dequantize
### Mixed Precision
The `MixedPrecisionOps` class (lines 542-648 in `comfy/ops.py`) enables per-layer quantization decisions, allowing different layers in a model to use different precisions. This is activated when a model config contains a `layer_quant_config` dictionary that specifies which layers should be quantized and how.
The `MixedPrecisionOps` class (lines 542-648 in `studio/ops.py`) enables per-layer quantization decisions, allowing different layers in a model to use different precisions. This is activated when a model config contains a `layer_quant_config` dictionary that specifies which layers should be quantized and how.
**Architecture:**
@@ -125,7 +125,7 @@ We define 4 possible scaling parameters that should cover most recipes in the ne
|--------|---------------|--------------|----------------|-----------------|-------------|
| float8_e4m3fn | float32 | float32 (scalar) | - | - | float32 (scalar) |
You can find the defined formats in `comfy/quant_ops.py` (QUANT_ALGOS).
You can find the defined formats in `studio/quant_ops.py` (QUANT_ALGOS).
### Quantization Metadata
+74 -196
View File
@@ -1,92 +1,74 @@
<p align="center"><img src=".github/hero.svg" alt="studio" width="880"></p>
<div align="center">
# ComfyUI
# Hanzo Studio
**The most powerful and modular visual AI engine and application.**
[![Website][website-shield]][website-url]
[![Dynamic JSON Badge][discord-shield]][discord-url]
[![Twitter][twitter-shield]][twitter-url]
[![Matrix][matrix-shield]][matrix-url]
<br>
[![][github-release-shield]][github-release-link]
[![][github-release-date-shield]][github-release-link]
[![][github-downloads-shield]][github-downloads-link]
[![][github-downloads-latest-shield]][github-downloads-link]
[matrix-shield]: https://img.shields.io/badge/Matrix-000000?style=flat&logo=matrix&logoColor=white
[matrix-url]: https://app.element.io/#/room/%23comfyui_space%3Amatrix.org
[website-shield]: https://img.shields.io/badge/ComfyOrg-4285F4?style=flat
[website-url]: https://www.comfy.org/
[website-shield]: https://img.shields.io/badge/HanzoAI-4285F4?style=flat
[website-url]: https://www.hanzo.ai/
<!-- Workaround to display total user from https://github.com/badges/shields/issues/4500#issuecomment-2060079995 -->
[discord-shield]: https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Finvites%2Fcomfyorg%3Fwith_counts%3Dtrue&query=%24.approximate_member_count&logo=discord&logoColor=white&label=Discord&color=green&suffix=%20total
[discord-url]: https://www.comfy.org/discord
[twitter-shield]: https://img.shields.io/twitter/follow/ComfyUI
[twitter-url]: https://x.com/ComfyUI
[discord-shield]: https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Finvites%2Fhanzoai%3Fwith_counts%3Dtrue&query=%24.approximate_member_count&logo=discord&logoColor=white&label=Discord&color=green&suffix=%20total
[discord-url]: https://discord.gg/hanzoai
[twitter-shield]: https://img.shields.io/twitter/follow/hanaboroai
[twitter-url]: https://x.com/hanzoai
[github-release-shield]: https://img.shields.io/github/v/release/comfyanonymous/ComfyUI?style=flat&sort=semver
[github-release-link]: https://github.com/comfyanonymous/ComfyUI/releases
[github-release-date-shield]: https://img.shields.io/github/release-date/comfyanonymous/ComfyUI?style=flat
[github-downloads-shield]: https://img.shields.io/github/downloads/comfyanonymous/ComfyUI/total?style=flat
[github-downloads-latest-shield]: https://img.shields.io/github/downloads/comfyanonymous/ComfyUI/latest/total?style=flat&label=downloads%40latest
[github-downloads-link]: https://github.com/comfyanonymous/ComfyUI/releases
[github-release-shield]: https://img.shields.io/github/v/release/hanzoai/studio?style=flat&sort=semver
[github-release-link]: https://github.com/hanzoai/studio/releases
[github-release-date-shield]: https://img.shields.io/github/release-date/hanzoai/studio?style=flat
[github-downloads-shield]: https://img.shields.io/github/downloads/hanzoai/studio/total?style=flat
[github-downloads-latest-shield]: https://img.shields.io/github/downloads/hanzoai/studio/latest/total?style=flat&label=downloads%40latest
[github-downloads-link]: https://github.com/hanzoai/studio/releases
![ComfyUI Screenshot](https://github.com/user-attachments/assets/7ccaf2c1-9b72-41ae-9a89-5688c94b7abe)
![Hanzo Studio Screenshot](https://github.com/user-attachments/assets/7ccaf2c1-9b72-41ae-9a89-5688c94b7abe)
</div>
ComfyUI lets you design and execute advanced stable diffusion pipelines using a graph/nodes/flowchart based interface. Available on Windows, Linux, and macOS.
Hanzo Studio lets you design and execute advanced stable diffusion pipelines using a graph/nodes/flowchart based interface. Available on Windows, Linux, and macOS.
## Upstream & License
Hanzo Studio is a fork of [**ComfyUI**](https://github.com/comfyanonymous/ComfyUI) by comfyanonymous and the ComfyUI contributors — full credit to the upstream project for the engine this builds on. ComfyUI is licensed under the **GNU General Public License v3.0 (GPL-3.0)**, and this fork stays under the same license. All modifications in this fork are likewise GPL-3.0.
See [`LICENSE`](LICENSE) for the full license text and [`NOTICE`](NOTICE) for the fork's attribution and a summary of what it changes (branding, Hanzo Engine integration nodes, and fashion workflow templates).
## Get Started
#### [Desktop Application](https://www.comfy.org/download)
- The easiest way to get started.
- Available on Windows & macOS.
#### [Windows Portable Package](#installing)
- Get the latest commits and completely portable.
- Available on Windows.
#### [Manual Install](#manual-install-windows-linux)
Supports all operating systems and GPU types (NVIDIA, AMD, Intel, Apple Silicon, Ascend).
## [Examples](https://comfyanonymous.github.io/ComfyUI_examples/)
See what ComfyUI can do with the [example workflows](https://comfyanonymous.github.io/ComfyUI_examples/).
#### Docker (Recommended for servers)
```bash
docker pull ghcr.io/hanzoai/studio:latest
docker run -p 8188:8188 ghcr.io/hanzoai/studio:latest --listen --cpu
```
## Features
- Nodes/graph/flowchart interface to experiment and create complex Stable Diffusion workflows without needing to code anything.
- Image Models
- SD1.x, SD2.x ([unCLIP](https://comfyanonymous.github.io/ComfyUI_examples/unclip/))
- [SDXL](https://comfyanonymous.github.io/ComfyUI_examples/sdxl/), [SDXL Turbo](https://comfyanonymous.github.io/ComfyUI_examples/sdturbo/)
- [Stable Cascade](https://comfyanonymous.github.io/ComfyUI_examples/stable_cascade/)
- [SD3 and SD3.5](https://comfyanonymous.github.io/ComfyUI_examples/sd3/)
- Pixart Alpha and Sigma
- [AuraFlow](https://comfyanonymous.github.io/ComfyUI_examples/aura_flow/)
- [HunyuanDiT](https://comfyanonymous.github.io/ComfyUI_examples/hunyuan_dit/)
- [Flux](https://comfyanonymous.github.io/ComfyUI_examples/flux/)
- [Lumina Image 2.0](https://comfyanonymous.github.io/ComfyUI_examples/lumina2/)
- [HiDream](https://comfyanonymous.github.io/ComfyUI_examples/hidream/)
- [Qwen Image](https://comfyanonymous.github.io/ComfyUI_examples/qwen_image/)
- [Hunyuan Image 2.1](https://comfyanonymous.github.io/ComfyUI_examples/hunyuan_image/)
- [Flux 2](https://comfyanonymous.github.io/ComfyUI_examples/flux2/)
- [Z Image](https://comfyanonymous.github.io/ComfyUI_examples/z_image/)
- SD1.x, SD2.x, SDXL, SDXL Turbo
- Stable Cascade, SD3 and SD3.5
- Pixart Alpha and Sigma, AuraFlow, HunyuanDiT
- Flux, Flux 2, Lumina Image 2.0, HiDream
- Qwen Image, Hunyuan Image 2.1, Z Image
- Image Editing Models
- [Omnigen 2](https://comfyanonymous.github.io/ComfyUI_examples/omnigen/)
- [Flux Kontext](https://comfyanonymous.github.io/ComfyUI_examples/flux/#flux-kontext-image-editing-model)
- [HiDream E1.1](https://comfyanonymous.github.io/ComfyUI_examples/hidream/#hidream-e11)
- [Qwen Image Edit](https://comfyanonymous.github.io/ComfyUI_examples/qwen_image/#edit-model)
- Omnigen 2, Flux Kontext, HiDream E1.1, Qwen Image Edit
- Video Models
- [Stable Video Diffusion](https://comfyanonymous.github.io/ComfyUI_examples/video/)
- [Mochi](https://comfyanonymous.github.io/ComfyUI_examples/mochi/)
- [LTX-Video](https://comfyanonymous.github.io/ComfyUI_examples/ltxv/)
- [Hunyuan Video](https://comfyanonymous.github.io/ComfyUI_examples/hunyuan_video/)
- [Wan 2.1](https://comfyanonymous.github.io/ComfyUI_examples/wan/)
- [Wan 2.2](https://comfyanonymous.github.io/ComfyUI_examples/wan22/)
- [Hunyuan Video 1.5](https://docs.comfy.org/tutorials/video/hunyuan/hunyuan-video-1-5)
- Stable Video Diffusion, Mochi, LTX-Video
- Hunyuan Video, Wan 2.1, Wan 2.2, Hunyuan Video 1.5
- Audio Models
- [Stable Audio](https://comfyanonymous.github.io/ComfyUI_examples/audio/)
- [ACE Step](https://comfyanonymous.github.io/ComfyUI_examples/audio/)
- Stable Audio, ACE Step
- 3D Models
- [Hunyuan3D 2.0](https://docs.comfy.org/tutorials/3d/hunyuan3D-2)
- Hunyuan3D 2.0
- Asynchronous Queue system
- Many optimizations: Only re-executes the parts of the workflow that changes between executions.
- Smart memory management: can automatically run large models on GPUs with as low as 1GB vram with smart offloading.
@@ -94,45 +76,23 @@ See what ComfyUI can do with the [example workflows](https://comfyanonymous.gith
- Can load ckpt and safetensors: All in one checkpoints or standalone diffusion models, VAEs and CLIP models.
- Safe loading of ckpt, pt, pth, etc.. files.
- Embeddings/Textual inversion
- [Loras (regular, locon and loha)](https://comfyanonymous.github.io/ComfyUI_examples/lora/)
- [Hypernetworks](https://comfyanonymous.github.io/ComfyUI_examples/hypernetworks/)
- Loras (regular, locon and loha)
- Hypernetworks
- Loading full workflows (with seeds) from generated PNG, WebP and FLAC files.
- Saving/Loading workflows as Json files.
- Nodes interface can be used to create complex workflows like one for [Hires fix](https://comfyanonymous.github.io/ComfyUI_examples/2_pass_txt2img/) or much more advanced ones.
- [Area Composition](https://comfyanonymous.github.io/ComfyUI_examples/area_composition/)
- [Inpainting](https://comfyanonymous.github.io/ComfyUI_examples/inpaint/) with both regular and inpainting models.
- [ControlNet and T2I-Adapter](https://comfyanonymous.github.io/ComfyUI_examples/controlnet/)
- [Upscale Models (ESRGAN, ESRGAN variants, SwinIR, Swin2SR, etc...)](https://comfyanonymous.github.io/ComfyUI_examples/upscale_models/)
- [GLIGEN](https://comfyanonymous.github.io/ComfyUI_examples/gligen/)
- [Model Merging](https://comfyanonymous.github.io/ComfyUI_examples/model_merging/)
- [LCM models and Loras](https://comfyanonymous.github.io/ComfyUI_examples/lcm/)
- Latent previews with [TAESD](#how-to-show-high-quality-previews)
- Nodes interface can be used to create complex workflows like one for Hires fix or much more advanced ones.
- Area Composition
- Inpainting with both regular and inpainting models.
- ControlNet and T2I-Adapter
- Upscale Models (ESRGAN, ESRGAN variants, SwinIR, Swin2SR, etc...)
- GLIGEN
- Model Merging
- LCM models and Loras
- Latent previews with TAESD
- Works fully offline: core will never download anything unless you want to.
- Optional API nodes to use paid models from external providers through the online [Comfy API](https://docs.comfy.org/tutorials/api-nodes/overview) disable with: `--disable-api-nodes`
- Optional API nodes to use paid models from external providers through the online API. Disable with: `--disable-api-nodes`
- [Config file](extra_model_paths.yaml.example) to set the search paths for models.
Workflow examples can be found on the [Examples page](https://comfyanonymous.github.io/ComfyUI_examples/)
## Release Process
ComfyUI follows a weekly release cycle targeting Monday but this regularly changes because of model releases or large changes to the codebase. There are three interconnected repositories:
1. **[ComfyUI Core](https://github.com/comfyanonymous/ComfyUI)**
- Releases a new stable version (e.g., v0.7.0) roughly every week.
- Starting from v0.4.0 patch versions will be used for fixes backported onto the current stable release.
- Minor versions will be used for releases off the master branch.
- Patch versions may still be used for releases on the master branch in cases where a backport would not make sense.
- Commits outside of the stable release tags may be very unstable and break many custom nodes.
- Serves as the foundation for the desktop release
2. **[ComfyUI Desktop](https://github.com/Comfy-Org/desktop)**
- Builds a new release using the latest stable core version
3. **[ComfyUI Frontend](https://github.com/Comfy-Org/ComfyUI_frontend)**
- Weekly frontend updates are merged into the core repository
- Features are frozen for the upcoming core release
- Development continues for the next release cycle
## Shortcuts
| Keybind | Explanation |
@@ -173,42 +133,9 @@ ComfyUI follows a weekly release cycle targeting Monday but this regularly chang
# Installing
## Windows Portable
There is a portable standalone build for Windows that should work for running on Nvidia GPUs or for running on your CPU only on the [releases page](https://github.com/comfyanonymous/ComfyUI/releases).
### [Direct link to download](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_nvidia.7z)
Simply download, extract with [7-Zip](https://7-zip.org) or with the windows explorer on recent windows versions and run. For smaller models you normally only need to put the checkpoints (the huge ckpt/safetensors files) in: ComfyUI\models\checkpoints but many of the larger models have multiple files. Make sure to follow the instructions to know which subfolder to put them in ComfyUI\models\
If you have trouble extracting it, right click the file -> properties -> unblock
The portable above currently comes with python 3.13 and pytorch cuda 13.0. Update your Nvidia drivers if it doesn't start.
#### Alternative Downloads:
[Experimental portable for AMD GPUs](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_amd.7z)
[Portable with pytorch cuda 12.8 and python 3.12](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_nvidia_cu128.7z).
[Portable with pytorch cuda 12.6 and python 3.12](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_nvidia_cu126.7z) (Supports Nvidia 10 series and older GPUs).
#### How do I share models between another UI and ComfyUI?
See the [Config file](extra_model_paths.yaml.example) to set the search paths for models. In the standalone windows build you can find this file in the ComfyUI directory. Rename this file to extra_model_paths.yaml and edit it with your favorite text editor.
## [comfy-cli](https://docs.comfy.org/comfy-cli/getting-started)
You can install and start ComfyUI using comfy-cli:
```bash
pip install comfy-cli
comfy install
```
## Manual Install (Windows, Linux)
Python 3.14 works but you may encounter issues with the torch compile node. The free threaded variant is still missing some dependencies.
Python 3.14 works but some custom nodes may have issues. The free threaded variant works but some dependencies will enable the GIL so it's not fully supported.
Python 3.13 is very well supported. If you have trouble with some custom node dependencies on 3.13 you can try 3.12
@@ -227,11 +154,11 @@ Put your VAE in: models/vae
AMD users can install rocm and pytorch with pip if you don't have it already installed, this is the command to install the stable version:
```pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.4```
```pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm7.1```
This is the command to install the nightly with ROCm 7.1 which might have some performance improvements:
This is the command to install the nightly with ROCm 7.2 which might have some performance improvements:
```pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/rocm7.1```
```pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/rocm7.2```
### AMD GPUs (Experimental: Windows and Linux), RDNA 3, 3.5 and 4 only.
@@ -282,24 +209,24 @@ And install it again with the command above.
### Dependencies
Install the dependencies by opening your terminal inside the ComfyUI folder and:
Install the dependencies by opening your terminal inside the Hanzo Studio folder and:
```pip install -r requirements.txt```
After this you should have everything installed and can proceed to running ComfyUI.
After this you should have everything installed and can proceed to running Hanzo Studio.
### Others:
#### Apple Mac silicon
You can install ComfyUI in Apple Mac silicon (M1 or M2) with any recent macOS version.
You can install Hanzo Studio in Apple Mac silicon (M1 or M2) with any recent macOS version.
1. Install pytorch nightly. For instructions, read the [Accelerated PyTorch training on Mac](https://developer.apple.com/metal/pytorch/) Apple Developer guide (make sure to install the latest pytorch nightly).
1. Follow the [ComfyUI manual installation](#manual-install-windows-linux) instructions for Windows and Linux.
1. Install the ComfyUI [dependencies](#dependencies). If you have another Stable Diffusion UI [you might be able to reuse the dependencies](#i-already-have-another-ui-for-stable-diffusion-installed-do-i-really-have-to-install-all-of-these-dependencies).
1. Launch ComfyUI by running `python main.py`
1. Follow the [Hanzo Studio manual installation](#manual-install-windows-linux) instructions for Windows and Linux.
1. Install the Hanzo Studio [dependencies](#dependencies). If you have another Stable Diffusion UI you might be able to reuse the dependencies.
1. Launch Hanzo Studio by running `python main.py`
> **Note**: Remember to add your models, VAE, LoRAs etc. to the corresponding Comfy folders, as discussed in [ComfyUI manual installation](#manual-install-windows-linux).
> **Note**: Remember to add your models, VAE, LoRAs etc. to the corresponding folders, as discussed in [Hanzo Studio manual installation](#manual-install-windows-linux).
#### Ascend NPUs
@@ -308,7 +235,7 @@ For models compatible with Ascend Extension for PyTorch (torch_npu). To get star
1. Begin by installing the recommended or newer kernel version for Linux as specified in the Installation page of torch-npu, if necessary.
2. Proceed with the installation of Ascend Basekit, which includes the driver, firmware, and CANN, following the instructions provided for your specific platform.
3. Next, install the necessary packages for torch-npu by adhering to the platform-specific instructions on the [Installation](https://ascend.github.io/docs/sources/pytorch/install.html#pytorch) page.
4. Finally, adhere to the [ComfyUI manual installation](#manual-install-windows-linux) guide for Linux. Once all components are installed, you can run ComfyUI as described earlier.
4. Finally, adhere to the [Hanzo Studio manual installation](#manual-install-windows-linux) guide for Linux. Once all components are installed, you can run Hanzo Studio as described earlier.
#### Cambricon MLUs
@@ -316,19 +243,19 @@ For models compatible with Cambricon Extension for PyTorch (torch_mlu). Here's a
1. Install the Cambricon CNToolkit by adhering to the platform-specific instructions on the [Installation](https://www.cambricon.com/docs/sdk_1.15.0/cntoolkit_3.7.2/cntoolkit_install_3.7.2/index.html)
2. Next, install the PyTorch(torch_mlu) following the instructions on the [Installation](https://www.cambricon.com/docs/sdk_1.15.0/cambricon_pytorch_1.17.0/user_guide_1.9/index.html)
3. Launch ComfyUI by running `python main.py`
3. Launch Hanzo Studio by running `python main.py`
#### Iluvatar Corex
For models compatible with Iluvatar Extension for PyTorch. Here's a step-by-step guide tailored to your platform and installation method:
1. Install the Iluvatar Corex Toolkit by adhering to the platform-specific instructions on the [Installation](https://support.iluvatar.com/#/DocumentCentre?id=1&nameCenter=2&productId=520117912052801536)
2. Launch ComfyUI by running `python main.py`
2. Launch Hanzo Studio by running `python main.py`
## [ComfyUI-Manager](https://github.com/Comfy-Org/ComfyUI-Manager/tree/manager-v4)
## Manager
**ComfyUI-Manager** is an extension that allows you to easily install, update, and manage custom nodes for ComfyUI.
The **Manager** extension allows you to easily install, update, and manage custom nodes for Hanzo Studio.
### Setup
@@ -337,7 +264,7 @@ For models compatible with Iluvatar Extension for PyTorch. Here's a step-by-step
pip install -r manager_requirements.txt
```
2. Enable the manager with the `--enable-manager` flag when running ComfyUI:
2. Enable the manager with the `--enable-manager` flag when running Hanzo Studio:
```bash
python main.py --enable-manager
```
@@ -346,7 +273,7 @@ For models compatible with Iluvatar Extension for PyTorch. Here's a step-by-step
| Flag | Description |
|------|-------------|
| `--enable-manager` | Enable ComfyUI-Manager |
| `--enable-manager` | Enable the Manager |
| `--enable-manager-legacy-ui` | Use the legacy manager UI instead of the new UI (requires `--enable-manager`) |
| `--disable-manager-ui` | Disable the manager UI and endpoints while keeping background features like security checks and scheduled installation completion (requires `--enable-manager`) |
@@ -365,7 +292,7 @@ For AMD 7600 and maybe other RDNA3 cards: ```HSA_OVERRIDE_GFX_VERSION=11.0.0 pyt
### AMD ROCm Tips
You can enable experimental memory efficient attention on recent pytorch in ComfyUI on some AMD GPUs using this command, it should already be enabled by default on RDNA3. If this improves speed for you on latest pytorch on your GPU please report it so that I can enable it by default.
You can enable experimental memory efficient attention on recent pytorch on some AMD GPUs using this command, it should already be enabled by default on RDNA3. If this improves speed for you on latest pytorch on your GPU please report it so that we can enable it by default.
```TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL=1 python main.py --use-pytorch-cross-attention```
@@ -379,9 +306,9 @@ Only parts of the graph that change from each execution to the next will be exec
Dragging a generated png on the webpage or loading one will give you the full workflow including seeds that were used to create it.
You can use () to change emphasis of a word or phrase like: (good code:1.2) or (bad code:0.8). The default emphasis for () is 1.1. To use () characters in your actual prompt escape them like \\( or \\).
You can use () to change emphasis of a word or phrase like: (good code:1.2) or (bad code:0.8). The default emphasis for () is 1.1. To use () characters in your actual prompt escape them like \( or \).
You can use {day|night}, for wildcard/dynamic prompts. With this syntax "{wild|card|test}" will be randomly replaced by either "wild", "card" or "test" by the frontend every time you queue the prompt. To use {} characters in your actual prompt escape them like: \\{ or \\}.
You can use {day|night}, for wildcard/dynamic prompts. With this syntax "{wild|card|test}" will be randomly replaced by either "wild", "card" or "test" by the frontend every time you queue the prompt. To use {} characters in your actual prompt escape them like: \{ or \}.
Dynamic prompts also support C-style comments, like `// comment` or `/* comment */`.
@@ -394,7 +321,7 @@ To use a textual inversion concepts/embeddings in a text prompt put them in the
Use ```--preview-method auto``` to enable previews.
The default installation includes a fast latent preview method that's low-resolution. To enable higher-quality previews with [TAESD](https://github.com/madebyollin/taesd), download the [taesd_decoder.pth, taesdxl_decoder.pth, taesd3_decoder.pth and taef1_decoder.pth](https://github.com/madebyollin/taesd/) and place them in the `models/vae_approx` folder. Once they're installed, restart ComfyUI and launch it with `--preview-method taesd` to enable high-quality previews.
The default installation includes a fast latent preview method that's low-resolution. To enable higher-quality previews with [TAESD](https://github.com/madebyollin/taesd), download the [taesd_decoder.pth, taesdxl_decoder.pth, taesd3_decoder.pth and taef1_decoder.pth](https://github.com/madebyollin/taesd/) and place them in the `models/vae_approx` folder. Once they're installed, restart Hanzo Studio and launch it with `--preview-method taesd` to enable high-quality previews.
## How to use TLS/SSL?
Generate a self-signed certificate (not appropriate for shared/production use) and key by running the command: `openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 3650 -nodes -subj "/C=XX/ST=StateName/L=CityName/O=CompanyName/OU=CompanySectionName/CN=CommonNameOrHostname"`
@@ -406,55 +333,6 @@ Use `--tls-keyfile key.pem --tls-certfile cert.pem` to enable TLS/SSL, the app w
## Support and dev channel
[Discord](https://comfy.org/discord): Try the #help or #feedback channels.
[Discord](https://discord.gg/hanzoai): Try the #help or #feedback channels.
[Matrix space: #comfyui_space:matrix.org](https://app.element.io/#/room/%23comfyui_space%3Amatrix.org) (it's like discord but open source).
See also: [https://www.comfy.org/](https://www.comfy.org/)
## Frontend Development
As of August 15, 2024, we have transitioned to a new frontend, which is now hosted in a separate repository: [ComfyUI Frontend](https://github.com/Comfy-Org/ComfyUI_frontend). This repository now hosts the compiled JS (from TS/Vue) under the `web/` directory.
### Reporting Issues and Requesting Features
For any bugs, issues, or feature requests related to the frontend, please use the [ComfyUI Frontend repository](https://github.com/Comfy-Org/ComfyUI_frontend). This will help us manage and address frontend-specific concerns more efficiently.
### Using the Latest Frontend
The new frontend is now the default for ComfyUI. However, please note:
1. The frontend in the main ComfyUI repository is updated fortnightly.
2. Daily releases are available in the separate frontend repository.
To use the most up-to-date frontend version:
1. For the latest daily release, launch ComfyUI with this command line argument:
```
--front-end-version Comfy-Org/ComfyUI_frontend@latest
```
2. For a specific version, replace `latest` with the desired version number:
```
--front-end-version Comfy-Org/ComfyUI_frontend@1.2.2
```
This approach allows you to easily switch between the stable fortnightly release and the cutting-edge daily updates, or even specific versions for testing purposes.
### Accessing the Legacy Frontend
If you need to use the legacy frontend for any reason, you can access it using the following command line argument:
```
--front-end-version Comfy-Org/ComfyUI_legacy_frontend@latest
```
This will use a snapshot of the legacy frontend preserved in the [ComfyUI Legacy Frontend repository](https://github.com/Comfy-Org/ComfyUI_legacy_frontend).
# QA
### Which GPU should I buy for this?
[See this page for some recommendations](https://github.com/comfyanonymous/ComfyUI/wiki/Which-GPU-should-I-buy-for-ComfyUI)
See also: [https://www.hanzo.ai/](https://www.hanzo.ai/)
+1 -1
View File
@@ -63,7 +63,7 @@ version_path_separator = os
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = sqlite:///user/comfyui.db
sqlalchemy.url = sqlite:///user/hanzo_studio.db
[post_write_hooks]
+2 -2
View File
@@ -1,3 +1,3 @@
# ComfyUI Internal Routes
# Hanzo Studio Internal Routes
All routes under the `/internal` path are designated for **internal use by ComfyUI only**. These routes are not intended for use by external applications may change at any time without notice.
All routes under the `/internal` path are designated for **internal use by Hanzo Studio only**. These routes are not intended for use by external applications may change at any time without notice.
@@ -8,7 +8,7 @@ import os
class InternalRoutes:
'''
The top level web router for internal routes: /internal/*
The endpoints here should NOT be depended upon. It is for ComfyUI frontend use only.
The endpoints here should NOT be depended upon. It is for Hanzo Studio frontend use only.
Check README.md for more information.
'''
+2 -2
View File
@@ -12,7 +12,7 @@ class AppSettings():
try:
file = self.user_manager.get_request_user_filepath(
request,
"comfy.settings.json"
"studio.settings.json"
)
except KeyError as e:
logging.error("User settings not found.")
@@ -29,7 +29,7 @@ class AppSettings():
def save_settings(self, request, settings):
file = self.user_manager.get_request_user_filepath(
request, "comfy.settings.json")
request, "studio.settings.json")
with open(file, "w") as f:
f.write(json.dumps(settings, indent=4))
+413 -1
View File
@@ -1,5 +1,8 @@
import logging
import uuid
import urllib.parse
import os
import contextlib
from aiohttp import web
from pydantic import ValidationError
@@ -8,6 +11,9 @@ import app.assets.manager as manager
from app import user_manager
from app.assets.api import schemas_in
from app.assets.helpers import get_query_dict
from app.assets.scanner import seed_assets
import folder_paths
ROUTES = web.RouteTableDef()
USER_MANAGER: user_manager.UserManager | None = None
@@ -15,6 +21,9 @@ USER_MANAGER: user_manager.UserManager | None = None
# UUID regex (canonical hyphenated form, case-insensitive)
UUID_RE = r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
# Note to any custom node developers reading this code:
# The assets system is not yet fully implemented, do not rely on the code in /app/assets remaining the same.
def register_assets_system(app: web.Application, user_manager_instance: user_manager.UserManager) -> None:
global USER_MANAGER
USER_MANAGER = user_manager_instance
@@ -28,6 +37,18 @@ def _validation_error_response(code: str, ve: ValidationError) -> web.Response:
return _error_response(400, code, "Validation failed.", {"errors": ve.json()})
@ROUTES.head("/api/assets/hash/{hash}")
async def head_asset_by_hash(request: web.Request) -> web.Response:
hash_str = request.match_info.get("hash", "").strip().lower()
if not hash_str or ":" not in hash_str:
return _error_response(400, "INVALID_HASH", "hash must be like 'blake3:<hex>'")
algo, digest = hash_str.split(":", 1)
if algo != "blake3" or not digest or any(c for c in digest if c not in "0123456789abcdef"):
return _error_response(400, "INVALID_HASH", "hash must be like 'blake3:<hex>'")
exists = manager.asset_exists(asset_hash=hash_str)
return web.Response(status=200 if exists else 404)
@ROUTES.get("/api/assets")
async def list_assets(request: web.Request) -> web.Response:
"""
@@ -50,7 +71,7 @@ async def list_assets(request: web.Request) -> web.Response:
order=q.order,
owner_id=USER_MANAGER.get_request_user_id(request),
)
return web.json_response(payload.model_dump(mode="json"))
return web.json_response(payload.model_dump(mode="json", exclude_none=True))
@ROUTES.get(f"/api/assets/{{id:{UUID_RE}}}")
@@ -76,6 +97,314 @@ async def get_asset(request: web.Request) -> web.Response:
return web.json_response(result.model_dump(mode="json"), status=200)
@ROUTES.get(f"/api/assets/{{id:{UUID_RE}}}/content")
async def download_asset_content(request: web.Request) -> web.Response:
# question: do we need disposition? could we just stick with one of these?
disposition = request.query.get("disposition", "attachment").lower().strip()
if disposition not in {"inline", "attachment"}:
disposition = "attachment"
try:
abs_path, content_type, filename = manager.resolve_asset_content_for_download(
asset_info_id=str(uuid.UUID(request.match_info["id"])),
owner_id=USER_MANAGER.get_request_user_id(request),
)
except ValueError as ve:
return _error_response(404, "ASSET_NOT_FOUND", str(ve))
except NotImplementedError as nie:
return _error_response(501, "BACKEND_UNSUPPORTED", str(nie))
except FileNotFoundError:
return _error_response(404, "FILE_NOT_FOUND", "Underlying file not found on disk.")
quoted = (filename or "").replace("\r", "").replace("\n", "").replace('"', "'")
cd = f'{disposition}; filename="{quoted}"; filename*=UTF-8\'\'{urllib.parse.quote(filename)}'
file_size = os.path.getsize(abs_path)
logging.info(
"download_asset_content: path=%s, size=%d bytes (%.2f MB), content_type=%s, filename=%s",
abs_path,
file_size,
file_size / (1024 * 1024),
content_type,
filename,
)
async def file_sender():
chunk_size = 64 * 1024
with open(abs_path, "rb") as f:
while True:
chunk = f.read(chunk_size)
if not chunk:
break
yield chunk
return web.Response(
body=file_sender(),
content_type=content_type,
headers={
"Content-Disposition": cd,
"Content-Length": str(file_size),
},
)
@ROUTES.post("/api/assets/from-hash")
async def create_asset_from_hash(request: web.Request) -> web.Response:
try:
payload = await request.json()
body = schemas_in.CreateFromHashBody.model_validate(payload)
except ValidationError as ve:
return _validation_error_response("INVALID_BODY", ve)
except Exception:
return _error_response(400, "INVALID_JSON", "Request body must be valid JSON.")
result = manager.create_asset_from_hash(
hash_str=body.hash,
name=body.name,
tags=body.tags,
user_metadata=body.user_metadata,
owner_id=USER_MANAGER.get_request_user_id(request),
)
if result is None:
return _error_response(404, "ASSET_NOT_FOUND", f"Asset content {body.hash} does not exist")
return web.json_response(result.model_dump(mode="json"), status=201)
@ROUTES.post("/api/assets")
async def upload_asset(request: web.Request) -> web.Response:
"""Multipart/form-data endpoint for Asset uploads."""
if not (request.content_type or "").lower().startswith("multipart/"):
return _error_response(415, "UNSUPPORTED_MEDIA_TYPE", "Use multipart/form-data for uploads.")
reader = await request.multipart()
file_present = False
file_client_name: str | None = None
tags_raw: list[str] = []
provided_name: str | None = None
user_metadata_raw: str | None = None
provided_hash: str | None = None
provided_hash_exists: bool | None = None
file_written = 0
tmp_path: str | None = None
while True:
field = await reader.next()
if field is None:
break
fname = getattr(field, "name", "") or ""
if fname == "hash":
try:
s = ((await field.text()) or "").strip().lower()
except Exception:
return _error_response(400, "INVALID_HASH", "hash must be like 'blake3:<hex>'")
if s:
if ":" not in s:
return _error_response(400, "INVALID_HASH", "hash must be like 'blake3:<hex>'")
algo, digest = s.split(":", 1)
if algo != "blake3" or not digest or any(c for c in digest if c not in "0123456789abcdef"):
return _error_response(400, "INVALID_HASH", "hash must be like 'blake3:<hex>'")
provided_hash = f"{algo}:{digest}"
try:
provided_hash_exists = manager.asset_exists(asset_hash=provided_hash)
except Exception:
provided_hash_exists = None # do not fail the whole request here
elif fname == "file":
file_present = True
file_client_name = (field.filename or "").strip()
if provided_hash and provided_hash_exists is True:
# If client supplied a hash that we know exists, drain but do not write to disk
try:
while True:
chunk = await field.read_chunk(8 * 1024 * 1024)
if not chunk:
break
file_written += len(chunk)
except Exception:
return _error_response(500, "UPLOAD_IO_ERROR", "Failed to receive uploaded file.")
continue # Do not create temp file; we will create AssetInfo from the existing content
# Otherwise, store to temp for hashing/ingest
uploads_root = os.path.join(folder_paths.get_temp_directory(), "uploads")
unique_dir = os.path.join(uploads_root, uuid.uuid4().hex)
os.makedirs(unique_dir, exist_ok=True)
tmp_path = os.path.join(unique_dir, ".upload.part")
try:
with open(tmp_path, "wb") as f:
while True:
chunk = await field.read_chunk(8 * 1024 * 1024)
if not chunk:
break
f.write(chunk)
file_written += len(chunk)
except Exception:
try:
if os.path.exists(tmp_path or ""):
os.remove(tmp_path)
finally:
return _error_response(500, "UPLOAD_IO_ERROR", "Failed to receive and store uploaded file.")
elif fname == "tags":
tags_raw.append((await field.text()) or "")
elif fname == "name":
provided_name = (await field.text()) or None
elif fname == "user_metadata":
user_metadata_raw = (await field.text()) or None
# If client did not send file, and we are not doing a from-hash fast path -> error
if not file_present and not (provided_hash and provided_hash_exists):
return _error_response(400, "MISSING_FILE", "Form must include a 'file' part or a known 'hash'.")
if file_present and file_written == 0 and not (provided_hash and provided_hash_exists):
# Empty upload is only acceptable if we are fast-pathing from existing hash
try:
if tmp_path and os.path.exists(tmp_path):
os.remove(tmp_path)
finally:
return _error_response(400, "EMPTY_UPLOAD", "Uploaded file is empty.")
try:
spec = schemas_in.UploadAssetSpec.model_validate({
"tags": tags_raw,
"name": provided_name,
"user_metadata": user_metadata_raw,
"hash": provided_hash,
})
except ValidationError as ve:
try:
if tmp_path and os.path.exists(tmp_path):
os.remove(tmp_path)
finally:
return _validation_error_response("INVALID_BODY", ve)
# Validate models category against configured folders (consistent with previous behavior)
if spec.tags and spec.tags[0] == "models":
if len(spec.tags) < 2 or spec.tags[1] not in folder_paths.folder_names_and_paths:
if tmp_path and os.path.exists(tmp_path):
os.remove(tmp_path)
return _error_response(
400, "INVALID_BODY", f"unknown models category '{spec.tags[1] if len(spec.tags) >= 2 else ''}'"
)
owner_id = USER_MANAGER.get_request_user_id(request)
# Fast path: if a valid provided hash exists, create AssetInfo without writing anything
if spec.hash and provided_hash_exists is True:
try:
result = manager.create_asset_from_hash(
hash_str=spec.hash,
name=spec.name or (spec.hash.split(":", 1)[1]),
tags=spec.tags,
user_metadata=spec.user_metadata or {},
owner_id=owner_id,
)
except Exception:
logging.exception("create_asset_from_hash failed for hash=%s, owner_id=%s", spec.hash, owner_id)
return _error_response(500, "INTERNAL", "Unexpected server error.")
if result is None:
return _error_response(404, "ASSET_NOT_FOUND", f"Asset content {spec.hash} does not exist")
# Drain temp if we accidentally saved (e.g., hash field came after file)
if tmp_path and os.path.exists(tmp_path):
with contextlib.suppress(Exception):
os.remove(tmp_path)
status = 200 if (not result.created_new) else 201
return web.json_response(result.model_dump(mode="json"), status=status)
# Otherwise, we must have a temp file path to ingest
if not tmp_path or not os.path.exists(tmp_path):
# The only case we reach here without a temp file is: client sent a hash that does not exist and no file
return _error_response(404, "ASSET_NOT_FOUND", "Provided hash not found and no file uploaded.")
try:
created = manager.upload_asset_from_temp_path(
spec,
temp_path=tmp_path,
client_filename=file_client_name,
owner_id=owner_id,
expected_asset_hash=spec.hash,
)
status = 201 if created.created_new else 200
return web.json_response(created.model_dump(mode="json"), status=status)
except ValueError as e:
if tmp_path and os.path.exists(tmp_path):
os.remove(tmp_path)
msg = str(e)
if "HASH_MISMATCH" in msg or msg.strip().upper() == "HASH_MISMATCH":
return _error_response(
400,
"HASH_MISMATCH",
"Uploaded file hash does not match provided hash.",
)
return _error_response(400, "BAD_REQUEST", "Invalid inputs.")
except Exception:
if tmp_path and os.path.exists(tmp_path):
os.remove(tmp_path)
logging.exception("upload_asset_from_temp_path failed for tmp_path=%s, owner_id=%s", tmp_path, owner_id)
return _error_response(500, "INTERNAL", "Unexpected server error.")
@ROUTES.put(f"/api/assets/{{id:{UUID_RE}}}")
async def update_asset(request: web.Request) -> web.Response:
asset_info_id = str(uuid.UUID(request.match_info["id"]))
try:
body = schemas_in.UpdateAssetBody.model_validate(await request.json())
except ValidationError as ve:
return _validation_error_response("INVALID_BODY", ve)
except Exception:
return _error_response(400, "INVALID_JSON", "Request body must be valid JSON.")
try:
result = manager.update_asset(
asset_info_id=asset_info_id,
name=body.name,
user_metadata=body.user_metadata,
owner_id=USER_MANAGER.get_request_user_id(request),
)
except (ValueError, PermissionError) as ve:
return _error_response(404, "ASSET_NOT_FOUND", str(ve), {"id": asset_info_id})
except Exception:
logging.exception(
"update_asset failed for asset_info_id=%s, owner_id=%s",
asset_info_id,
USER_MANAGER.get_request_user_id(request),
)
return _error_response(500, "INTERNAL", "Unexpected server error.")
return web.json_response(result.model_dump(mode="json"), status=200)
@ROUTES.delete(f"/api/assets/{{id:{UUID_RE}}}")
async def delete_asset(request: web.Request) -> web.Response:
asset_info_id = str(uuid.UUID(request.match_info["id"]))
delete_content = request.query.get("delete_content")
delete_content = True if delete_content is None else delete_content.lower() not in {"0", "false", "no"}
try:
deleted = manager.delete_asset_reference(
asset_info_id=asset_info_id,
owner_id=USER_MANAGER.get_request_user_id(request),
delete_content_if_orphan=delete_content,
)
except Exception:
logging.exception(
"delete_asset_reference failed for asset_info_id=%s, owner_id=%s",
asset_info_id,
USER_MANAGER.get_request_user_id(request),
)
return _error_response(500, "INTERNAL", "Unexpected server error.")
if not deleted:
return _error_response(404, "ASSET_NOT_FOUND", f"AssetInfo {asset_info_id} not found.")
return web.Response(status=204)
@ROUTES.get("/api/tags")
async def get_tags(request: web.Request) -> web.Response:
"""
@@ -100,3 +429,86 @@ async def get_tags(request: web.Request) -> web.Response:
owner_id=USER_MANAGER.get_request_user_id(request),
)
return web.json_response(result.model_dump(mode="json"))
@ROUTES.post(f"/api/assets/{{id:{UUID_RE}}}/tags")
async def add_asset_tags(request: web.Request) -> web.Response:
asset_info_id = str(uuid.UUID(request.match_info["id"]))
try:
payload = await request.json()
data = schemas_in.TagsAdd.model_validate(payload)
except ValidationError as ve:
return _error_response(400, "INVALID_BODY", "Invalid JSON body for tags add.", {"errors": ve.errors()})
except Exception:
return _error_response(400, "INVALID_JSON", "Request body must be valid JSON.")
try:
result = manager.add_tags_to_asset(
asset_info_id=asset_info_id,
tags=data.tags,
origin="manual",
owner_id=USER_MANAGER.get_request_user_id(request),
)
except (ValueError, PermissionError) as ve:
return _error_response(404, "ASSET_NOT_FOUND", str(ve), {"id": asset_info_id})
except Exception:
logging.exception(
"add_tags_to_asset failed for asset_info_id=%s, owner_id=%s",
asset_info_id,
USER_MANAGER.get_request_user_id(request),
)
return _error_response(500, "INTERNAL", "Unexpected server error.")
return web.json_response(result.model_dump(mode="json"), status=200)
@ROUTES.delete(f"/api/assets/{{id:{UUID_RE}}}/tags")
async def delete_asset_tags(request: web.Request) -> web.Response:
asset_info_id = str(uuid.UUID(request.match_info["id"]))
try:
payload = await request.json()
data = schemas_in.TagsRemove.model_validate(payload)
except ValidationError as ve:
return _error_response(400, "INVALID_BODY", "Invalid JSON body for tags remove.", {"errors": ve.errors()})
except Exception:
return _error_response(400, "INVALID_JSON", "Request body must be valid JSON.")
try:
result = manager.remove_tags_from_asset(
asset_info_id=asset_info_id,
tags=data.tags,
owner_id=USER_MANAGER.get_request_user_id(request),
)
except ValueError as ve:
return _error_response(404, "ASSET_NOT_FOUND", str(ve), {"id": asset_info_id})
except Exception:
logging.exception(
"remove_tags_from_asset failed for asset_info_id=%s, owner_id=%s",
asset_info_id,
USER_MANAGER.get_request_user_id(request),
)
return _error_response(500, "INTERNAL", "Unexpected server error.")
return web.json_response(result.model_dump(mode="json"), status=200)
@ROUTES.post("/api/assets/seed")
async def seed_assets_endpoint(request: web.Request) -> web.Response:
"""Trigger asset seeding for specified roots (models, input, output)."""
try:
payload = await request.json()
roots = payload.get("roots", ["models", "input", "output"])
except Exception:
roots = ["models", "input", "output"]
valid_roots = [r for r in roots if r in ("models", "input", "output")]
if not valid_roots:
return _error_response(400, "INVALID_BODY", "No valid roots specified")
try:
seed_assets(tuple(valid_roots))
except Exception:
logging.exception("seed_assets failed for roots=%s", valid_roots)
return _error_response(500, "INTERNAL", "Seed operation failed")
return web.json_response({"seeded": valid_roots}, status=200)
+183 -13
View File
@@ -1,5 +1,4 @@
import json
import uuid
from typing import Any, Literal
from pydantic import (
@@ -8,9 +7,9 @@ from pydantic import (
Field,
conint,
field_validator,
model_validator,
)
class ListAssetsQuery(BaseModel):
include_tags: list[str] = Field(default_factory=list)
exclude_tags: list[str] = Field(default_factory=list)
@@ -57,6 +56,57 @@ class ListAssetsQuery(BaseModel):
return None
class UpdateAssetBody(BaseModel):
name: str | None = None
user_metadata: dict[str, Any] | None = None
@model_validator(mode="after")
def _at_least_one(self):
if self.name is None and self.user_metadata is None:
raise ValueError("Provide at least one of: name, user_metadata.")
return self
class CreateFromHashBody(BaseModel):
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
hash: str
name: str
tags: list[str] = Field(default_factory=list)
user_metadata: dict[str, Any] = Field(default_factory=dict)
@field_validator("hash")
@classmethod
def _require_blake3(cls, v):
s = (v or "").strip().lower()
if ":" not in s:
raise ValueError("hash must be 'blake3:<hex>'")
algo, digest = s.split(":", 1)
if algo != "blake3":
raise ValueError("only canonical 'blake3:<hex>' is accepted here")
if not digest or any(c for c in digest if c not in "0123456789abcdef"):
raise ValueError("hash digest must be lowercase hex")
return s
@field_validator("tags", mode="before")
@classmethod
def _tags_norm(cls, v):
if v is None:
return []
if isinstance(v, list):
out = [str(t).strip().lower() for t in v if str(t).strip()]
seen = set()
dedup = []
for t in out:
if t not in seen:
seen.add(t)
dedup.append(t)
return dedup
if isinstance(v, str):
return [t.strip().lower() for t in v.split(",") if t.strip()]
return []
class TagsListQuery(BaseModel):
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
@@ -75,20 +125,140 @@ class TagsListQuery(BaseModel):
return v.lower() or None
class SetPreviewBody(BaseModel):
"""Set or clear the preview for an AssetInfo. Provide an Asset.id or null."""
preview_id: str | None = None
class TagsAdd(BaseModel):
model_config = ConfigDict(extra="ignore")
tags: list[str] = Field(..., min_length=1)
@field_validator("preview_id", mode="before")
@field_validator("tags")
@classmethod
def _norm_uuid(cls, v):
def normalize_tags(cls, v: list[str]) -> list[str]:
out = []
for t in v:
if not isinstance(t, str):
raise TypeError("tags must be strings")
tnorm = t.strip().lower()
if tnorm:
out.append(tnorm)
seen = set()
deduplicated = []
for x in out:
if x not in seen:
seen.add(x)
deduplicated.append(x)
return deduplicated
class TagsRemove(TagsAdd):
pass
class UploadAssetSpec(BaseModel):
"""Upload Asset operation.
- tags: ordered; first is root ('models'|'input'|'output');
if root == 'models', second must be a valid category from folder_paths.folder_names_and_paths
- name: display name
- user_metadata: arbitrary JSON object (optional)
- hash: optional canonical 'blake3:<hex>' provided by the client for validation / fast-path
Files created via this endpoint are stored on disk using the **content hash** as the filename stem
and the original extension is preserved when available.
"""
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
tags: list[str] = Field(..., min_length=1)
name: str | None = Field(default=None, max_length=512, description="Display Name")
user_metadata: dict[str, Any] = Field(default_factory=dict)
hash: str | None = Field(default=None)
@field_validator("hash", mode="before")
@classmethod
def _parse_hash(cls, v):
if v is None:
return None
s = str(v).strip()
s = str(v).strip().lower()
if not s:
return None
try:
uuid.UUID(s)
except Exception:
raise ValueError("preview_id must be a UUID")
return s
if ":" not in s:
raise ValueError("hash must be 'blake3:<hex>'")
algo, digest = s.split(":", 1)
if algo != "blake3":
raise ValueError("only canonical 'blake3:<hex>' is accepted here")
if not digest or any(c for c in digest if c not in "0123456789abcdef"):
raise ValueError("hash digest must be lowercase hex")
return f"{algo}:{digest}"
@field_validator("tags", mode="before")
@classmethod
def _parse_tags(cls, v):
"""
Accepts a list of strings (possibly multiple form fields),
where each string can be:
- JSON array (e.g., '["models","loras","foo"]')
- comma-separated ('models, loras, foo')
- single token ('models')
Returns a normalized, deduplicated, ordered list.
"""
items: list[str] = []
if v is None:
return []
if isinstance(v, str):
v = [v]
if isinstance(v, list):
for item in v:
if item is None:
continue
s = str(item).strip()
if not s:
continue
if s.startswith("["):
try:
arr = json.loads(s)
if isinstance(arr, list):
items.extend(str(x) for x in arr)
continue
except Exception:
pass # fallback to CSV parse below
items.extend([p for p in s.split(",") if p.strip()])
else:
return []
# normalize + dedupe
norm = []
seen = set()
for t in items:
tnorm = str(t).strip().lower()
if tnorm and tnorm not in seen:
seen.add(tnorm)
norm.append(tnorm)
return norm
@field_validator("user_metadata", mode="before")
@classmethod
def _parse_metadata_json(cls, v):
if v is None or isinstance(v, dict):
return v or {}
if isinstance(v, str):
s = v.strip()
if not s:
return {}
try:
parsed = json.loads(s)
except Exception as e:
raise ValueError(f"user_metadata must be JSON: {e}") from e
if not isinstance(parsed, dict):
raise ValueError("user_metadata must be a JSON object")
return parsed
return {}
@model_validator(mode="after")
def _validate_order(self):
if not self.tags:
raise ValueError("tags must be provided and non-empty")
root = self.tags[0]
if root not in {"models", "input", "output"}:
raise ValueError("first tag must be one of: models, input, output")
if root == "models":
if len(self.tags) < 2:
raise ValueError("models uploads require a category tag as the second tag")
return self
+33
View File
@@ -29,6 +29,21 @@ class AssetsList(BaseModel):
has_more: bool
class AssetUpdated(BaseModel):
id: str
name: str
asset_hash: str | None = None
tags: list[str] = Field(default_factory=list)
user_metadata: dict[str, Any] = Field(default_factory=dict)
updated_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
@field_serializer("updated_at")
def _ser_updated(self, v: datetime | None, _info):
return v.isoformat() if v else None
class AssetDetail(BaseModel):
id: str
name: str
@@ -48,6 +63,10 @@ class AssetDetail(BaseModel):
return v.isoformat() if v else None
class AssetCreated(AssetDetail):
created_new: bool
class TagUsage(BaseModel):
name: str
count: int
@@ -58,3 +77,17 @@ class TagsList(BaseModel):
tags: list[TagUsage] = Field(default_factory=list)
total: int
has_more: bool
class TagsAdd(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
added: list[str] = Field(default_factory=list)
already_present: list[str] = Field(default_factory=list)
total_tags: list[str] = Field(default_factory=list)
class TagsRemove(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
removed: list[str] = Field(default_factory=list)
not_present: list[str] = Field(default_factory=list)
total_tags: list[str] = Field(default_factory=list)
+714 -5
View File
@@ -1,9 +1,17 @@
import os
import logging
import sqlalchemy as sa
from collections import defaultdict
from sqlalchemy import select, exists, func
from datetime import datetime
from typing import Iterable, Any
from sqlalchemy import select, delete, exists, func
from sqlalchemy.dialects import sqlite
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, contains_eager, noload
from app.assets.database.models import Asset, AssetInfo, AssetInfoMeta, AssetInfoTag, Tag
from app.assets.helpers import escape_like_prefix, normalize_tags
from app.assets.database.models import Asset, AssetInfo, AssetCacheState, AssetInfoMeta, AssetInfoTag, Tag
from app.assets.helpers import (
compute_relative_filename, escape_like_prefix, normalize_tags, project_kv, utcnow
)
from typing import Sequence
@@ -15,6 +23,22 @@ def visible_owner_clause(owner_id: str) -> sa.sql.ClauseElement:
return AssetInfo.owner_id.in_(["", owner_id])
def pick_best_live_path(states: Sequence[AssetCacheState]) -> str:
"""
Return the best on-disk path among cache states:
1) Prefer a path that exists with needs_verify == False (already verified).
2) Otherwise, pick the first path that exists.
3) Otherwise return empty string.
"""
alive = [s for s in states if getattr(s, "file_path", None) and os.path.isfile(s.file_path)]
if not alive:
return ""
for s in alive:
if not getattr(s, "needs_verify", False):
return s.file_path
return alive[0].file_path
def apply_tag_filters(
stmt: sa.sql.Select,
include_tags: Sequence[str] | None = None,
@@ -42,6 +66,7 @@ def apply_tag_filters(
)
return stmt
def apply_metadata_filter(
stmt: sa.sql.Select,
metadata_filter: dict | None = None,
@@ -94,7 +119,11 @@ def apply_metadata_filter(
return stmt
def asset_exists_by_hash(session: Session, asset_hash: str) -> bool:
def asset_exists_by_hash(
session: Session,
*,
asset_hash: str,
) -> bool:
"""
Check if an asset with a given hash exists in database.
"""
@@ -105,9 +134,39 @@ def asset_exists_by_hash(session: Session, asset_hash: str) -> bool:
).first()
return row is not None
def get_asset_info_by_id(session: Session, asset_info_id: str) -> AssetInfo | None:
def asset_info_exists_for_asset_id(
session: Session,
*,
asset_id: str,
) -> bool:
q = (
select(sa.literal(True))
.select_from(AssetInfo)
.where(AssetInfo.asset_id == asset_id)
.limit(1)
)
return (session.execute(q)).first() is not None
def get_asset_by_hash(
session: Session,
*,
asset_hash: str,
) -> Asset | None:
return (
session.execute(select(Asset).where(Asset.hash == asset_hash).limit(1))
).scalars().first()
def get_asset_info_by_id(
session: Session,
*,
asset_info_id: str,
) -> AssetInfo | None:
return session.get(AssetInfo, asset_info_id)
def list_asset_infos_page(
session: Session,
owner_id: str = "",
@@ -171,12 +230,14 @@ def list_asset_infos_page(
select(AssetInfoTag.asset_info_id, Tag.name)
.join(Tag, Tag.name == AssetInfoTag.tag_name)
.where(AssetInfoTag.asset_info_id.in_(id_list))
.order_by(AssetInfoTag.added_at)
)
for aid, tag_name in rows.all():
tag_map[aid].append(tag_name)
return infos, tag_map, total
def fetch_asset_info_asset_and_tags(
session: Session,
asset_info_id: str,
@@ -208,6 +269,494 @@ def fetch_asset_info_asset_and_tags(
tags.append(tag_name)
return first_info, first_asset, tags
def fetch_asset_info_and_asset(
session: Session,
*,
asset_info_id: str,
owner_id: str = "",
) -> tuple[AssetInfo, Asset] | None:
stmt = (
select(AssetInfo, Asset)
.join(Asset, Asset.id == AssetInfo.asset_id)
.where(
AssetInfo.id == asset_info_id,
visible_owner_clause(owner_id),
)
.limit(1)
.options(noload(AssetInfo.tags))
)
row = session.execute(stmt)
pair = row.first()
if not pair:
return None
return pair[0], pair[1]
def list_cache_states_by_asset_id(
session: Session, *, asset_id: str
) -> Sequence[AssetCacheState]:
return (
session.execute(
select(AssetCacheState)
.where(AssetCacheState.asset_id == asset_id)
.order_by(AssetCacheState.id.asc())
)
).scalars().all()
def touch_asset_info_by_id(
session: Session,
*,
asset_info_id: str,
ts: datetime | None = None,
only_if_newer: bool = True,
) -> None:
ts = ts or utcnow()
stmt = sa.update(AssetInfo).where(AssetInfo.id == asset_info_id)
if only_if_newer:
stmt = stmt.where(
sa.or_(AssetInfo.last_access_time.is_(None), AssetInfo.last_access_time < ts)
)
session.execute(stmt.values(last_access_time=ts))
def create_asset_info_for_existing_asset(
session: Session,
*,
asset_hash: str,
name: str,
user_metadata: dict | None = None,
tags: Sequence[str] | None = None,
tag_origin: str = "manual",
owner_id: str = "",
) -> AssetInfo:
"""Create or return an existing AssetInfo for an Asset identified by asset_hash."""
now = utcnow()
asset = get_asset_by_hash(session, asset_hash=asset_hash)
if not asset:
raise ValueError(f"Unknown asset hash {asset_hash}")
info = AssetInfo(
owner_id=owner_id,
name=name,
asset_id=asset.id,
preview_id=None,
created_at=now,
updated_at=now,
last_access_time=now,
)
try:
with session.begin_nested():
session.add(info)
session.flush()
except IntegrityError:
existing = (
session.execute(
select(AssetInfo)
.options(noload(AssetInfo.tags))
.where(
AssetInfo.asset_id == asset.id,
AssetInfo.name == name,
AssetInfo.owner_id == owner_id,
)
.limit(1)
)
).unique().scalars().first()
if not existing:
raise RuntimeError("AssetInfo upsert failed to find existing row after conflict.")
return existing
# metadata["filename"] hack
new_meta = dict(user_metadata or {})
computed_filename = None
try:
p = pick_best_live_path(list_cache_states_by_asset_id(session, asset_id=asset.id))
if p:
computed_filename = compute_relative_filename(p)
except Exception:
computed_filename = None
if computed_filename:
new_meta["filename"] = computed_filename
if new_meta:
replace_asset_info_metadata_projection(
session,
asset_info_id=info.id,
user_metadata=new_meta,
)
if tags is not None:
set_asset_info_tags(
session,
asset_info_id=info.id,
tags=tags,
origin=tag_origin,
)
return info
def set_asset_info_tags(
session: Session,
*,
asset_info_id: str,
tags: Sequence[str],
origin: str = "manual",
) -> dict:
desired = normalize_tags(tags)
current = set(
tag_name for (tag_name,) in (
session.execute(select(AssetInfoTag.tag_name).where(AssetInfoTag.asset_info_id == asset_info_id))
).all()
)
to_add = [t for t in desired if t not in current]
to_remove = [t for t in current if t not in desired]
if to_add:
ensure_tags_exist(session, to_add, tag_type="user")
session.add_all([
AssetInfoTag(asset_info_id=asset_info_id, tag_name=t, origin=origin, added_at=utcnow())
for t in to_add
])
session.flush()
if to_remove:
session.execute(
delete(AssetInfoTag)
.where(AssetInfoTag.asset_info_id == asset_info_id, AssetInfoTag.tag_name.in_(to_remove))
)
session.flush()
return {"added": to_add, "removed": to_remove, "total": desired}
def replace_asset_info_metadata_projection(
session: Session,
*,
asset_info_id: str,
user_metadata: dict | None = None,
) -> None:
info = session.get(AssetInfo, asset_info_id)
if not info:
raise ValueError(f"AssetInfo {asset_info_id} not found")
info.user_metadata = user_metadata or {}
info.updated_at = utcnow()
session.flush()
session.execute(delete(AssetInfoMeta).where(AssetInfoMeta.asset_info_id == asset_info_id))
session.flush()
if not user_metadata:
return
rows: list[AssetInfoMeta] = []
for k, v in user_metadata.items():
for r in project_kv(k, v):
rows.append(
AssetInfoMeta(
asset_info_id=asset_info_id,
key=r["key"],
ordinal=int(r["ordinal"]),
val_str=r.get("val_str"),
val_num=r.get("val_num"),
val_bool=r.get("val_bool"),
val_json=r.get("val_json"),
)
)
if rows:
session.add_all(rows)
session.flush()
def ingest_fs_asset(
session: Session,
*,
asset_hash: str,
abs_path: str,
size_bytes: int,
mtime_ns: int,
mime_type: str | None = None,
info_name: str | None = None,
owner_id: str = "",
preview_id: str | None = None,
user_metadata: dict | None = None,
tags: Sequence[str] = (),
tag_origin: str = "manual",
require_existing_tags: bool = False,
) -> dict:
"""
Idempotently upsert:
- Asset by content hash (create if missing)
- AssetCacheState(file_path) pointing to asset_id
- Optionally AssetInfo + tag links and metadata projection
Returns flags and ids.
"""
locator = os.path.abspath(abs_path)
now = utcnow()
if preview_id:
if not session.get(Asset, preview_id):
preview_id = None
out: dict[str, Any] = {
"asset_created": False,
"asset_updated": False,
"state_created": False,
"state_updated": False,
"asset_info_id": None,
}
# 1) Asset by hash
asset = (
session.execute(select(Asset).where(Asset.hash == asset_hash).limit(1))
).scalars().first()
if not asset:
vals = {
"hash": asset_hash,
"size_bytes": int(size_bytes),
"mime_type": mime_type,
"created_at": now,
}
res = session.execute(
sqlite.insert(Asset)
.values(**vals)
.on_conflict_do_nothing(index_elements=[Asset.hash])
)
if int(res.rowcount or 0) > 0:
out["asset_created"] = True
asset = (
session.execute(
select(Asset).where(Asset.hash == asset_hash).limit(1)
)
).scalars().first()
if not asset:
raise RuntimeError("Asset row not found after upsert.")
else:
changed = False
if asset.size_bytes != int(size_bytes) and int(size_bytes) > 0:
asset.size_bytes = int(size_bytes)
changed = True
if mime_type and asset.mime_type != mime_type:
asset.mime_type = mime_type
changed = True
if changed:
out["asset_updated"] = True
# 2) AssetCacheState upsert by file_path (unique)
vals = {
"asset_id": asset.id,
"file_path": locator,
"mtime_ns": int(mtime_ns),
}
ins = (
sqlite.insert(AssetCacheState)
.values(**vals)
.on_conflict_do_nothing(index_elements=[AssetCacheState.file_path])
)
res = session.execute(ins)
if int(res.rowcount or 0) > 0:
out["state_created"] = True
else:
upd = (
sa.update(AssetCacheState)
.where(AssetCacheState.file_path == locator)
.where(
sa.or_(
AssetCacheState.asset_id != asset.id,
AssetCacheState.mtime_ns.is_(None),
AssetCacheState.mtime_ns != int(mtime_ns),
)
)
.values(asset_id=asset.id, mtime_ns=int(mtime_ns))
)
res2 = session.execute(upd)
if int(res2.rowcount or 0) > 0:
out["state_updated"] = True
# 3) Optional AssetInfo + tags + metadata
if info_name:
try:
with session.begin_nested():
info = AssetInfo(
owner_id=owner_id,
name=info_name,
asset_id=asset.id,
preview_id=preview_id,
created_at=now,
updated_at=now,
last_access_time=now,
)
session.add(info)
session.flush()
out["asset_info_id"] = info.id
except IntegrityError:
pass
existing_info = (
session.execute(
select(AssetInfo)
.where(
AssetInfo.asset_id == asset.id,
AssetInfo.name == info_name,
(AssetInfo.owner_id == owner_id),
)
.limit(1)
)
).unique().scalar_one_or_none()
if not existing_info:
raise RuntimeError("Failed to update or insert AssetInfo.")
if preview_id and existing_info.preview_id != preview_id:
existing_info.preview_id = preview_id
existing_info.updated_at = now
if existing_info.last_access_time < now:
existing_info.last_access_time = now
session.flush()
out["asset_info_id"] = existing_info.id
norm = [t.strip().lower() for t in (tags or []) if (t or "").strip()]
if norm and out["asset_info_id"] is not None:
if not require_existing_tags:
ensure_tags_exist(session, norm, tag_type="user")
existing_tag_names = set(
name for (name,) in (session.execute(select(Tag.name).where(Tag.name.in_(norm)))).all()
)
missing = [t for t in norm if t not in existing_tag_names]
if missing and require_existing_tags:
raise ValueError(f"Unknown tags: {missing}")
existing_links = set(
tag_name
for (tag_name,) in (
session.execute(
select(AssetInfoTag.tag_name).where(AssetInfoTag.asset_info_id == out["asset_info_id"])
)
).all()
)
to_add = [t for t in norm if t in existing_tag_names and t not in existing_links]
if to_add:
session.add_all(
[
AssetInfoTag(
asset_info_id=out["asset_info_id"],
tag_name=t,
origin=tag_origin,
added_at=now,
)
for t in to_add
]
)
session.flush()
# metadata["filename"] hack
if out["asset_info_id"] is not None:
primary_path = pick_best_live_path(list_cache_states_by_asset_id(session, asset_id=asset.id))
computed_filename = compute_relative_filename(primary_path) if primary_path else None
current_meta = existing_info.user_metadata or {}
new_meta = dict(current_meta)
if user_metadata is not None:
for k, v in user_metadata.items():
new_meta[k] = v
if computed_filename:
new_meta["filename"] = computed_filename
if new_meta != current_meta:
replace_asset_info_metadata_projection(
session,
asset_info_id=out["asset_info_id"],
user_metadata=new_meta,
)
try:
remove_missing_tag_for_asset_id(session, asset_id=asset.id)
except Exception:
logging.exception("Failed to clear 'missing' tag for asset %s", asset.id)
return out
def update_asset_info_full(
session: Session,
*,
asset_info_id: str,
name: str | None = None,
tags: Sequence[str] | None = None,
user_metadata: dict | None = None,
tag_origin: str = "manual",
asset_info_row: Any = None,
) -> AssetInfo:
if not asset_info_row:
info = session.get(AssetInfo, asset_info_id)
if not info:
raise ValueError(f"AssetInfo {asset_info_id} not found")
else:
info = asset_info_row
touched = False
if name is not None and name != info.name:
info.name = name
touched = True
computed_filename = None
try:
p = pick_best_live_path(list_cache_states_by_asset_id(session, asset_id=info.asset_id))
if p:
computed_filename = compute_relative_filename(p)
except Exception:
computed_filename = None
if user_metadata is not None:
new_meta = dict(user_metadata)
if computed_filename:
new_meta["filename"] = computed_filename
replace_asset_info_metadata_projection(
session, asset_info_id=asset_info_id, user_metadata=new_meta
)
touched = True
else:
if computed_filename:
current_meta = info.user_metadata or {}
if current_meta.get("filename") != computed_filename:
new_meta = dict(current_meta)
new_meta["filename"] = computed_filename
replace_asset_info_metadata_projection(
session, asset_info_id=asset_info_id, user_metadata=new_meta
)
touched = True
if tags is not None:
set_asset_info_tags(
session,
asset_info_id=asset_info_id,
tags=tags,
origin=tag_origin,
)
touched = True
if touched and user_metadata is None:
info.updated_at = utcnow()
session.flush()
return info
def delete_asset_info_by_id(
session: Session,
*,
asset_info_id: str,
owner_id: str,
) -> bool:
stmt = sa.delete(AssetInfo).where(
AssetInfo.id == asset_info_id,
visible_owner_clause(owner_id),
)
return int((session.execute(stmt)).rowcount or 0) > 0
def list_tags_with_usage(
session: Session,
prefix: str | None = None,
@@ -265,3 +814,163 @@ def list_tags_with_usage(
rows_norm = [(name, ttype, int(count or 0)) for (name, ttype, count) in rows]
return rows_norm, int(total or 0)
def ensure_tags_exist(session: Session, names: Iterable[str], tag_type: str = "user") -> None:
wanted = normalize_tags(list(names))
if not wanted:
return
rows = [{"name": n, "tag_type": tag_type} for n in list(dict.fromkeys(wanted))]
ins = (
sqlite.insert(Tag)
.values(rows)
.on_conflict_do_nothing(index_elements=[Tag.name])
)
session.execute(ins)
def get_asset_tags(session: Session, *, asset_info_id: str) -> list[str]:
return [
tag_name for (tag_name,) in (
session.execute(
select(AssetInfoTag.tag_name).where(AssetInfoTag.asset_info_id == asset_info_id)
)
).all()
]
def add_tags_to_asset_info(
session: Session,
*,
asset_info_id: str,
tags: Sequence[str],
origin: str = "manual",
create_if_missing: bool = True,
asset_info_row: Any = None,
) -> dict:
if not asset_info_row:
info = session.get(AssetInfo, asset_info_id)
if not info:
raise ValueError(f"AssetInfo {asset_info_id} not found")
norm = normalize_tags(tags)
if not norm:
total = get_asset_tags(session, asset_info_id=asset_info_id)
return {"added": [], "already_present": [], "total_tags": total}
if create_if_missing:
ensure_tags_exist(session, norm, tag_type="user")
current = {
tag_name
for (tag_name,) in (
session.execute(
sa.select(AssetInfoTag.tag_name).where(AssetInfoTag.asset_info_id == asset_info_id)
)
).all()
}
want = set(norm)
to_add = sorted(want - current)
if to_add:
with session.begin_nested() as nested:
try:
session.add_all(
[
AssetInfoTag(
asset_info_id=asset_info_id,
tag_name=t,
origin=origin,
added_at=utcnow(),
)
for t in to_add
]
)
session.flush()
except IntegrityError:
nested.rollback()
after = set(get_asset_tags(session, asset_info_id=asset_info_id))
return {
"added": sorted(((after - current) & want)),
"already_present": sorted(want & current),
"total_tags": sorted(after),
}
def remove_tags_from_asset_info(
session: Session,
*,
asset_info_id: str,
tags: Sequence[str],
) -> dict:
info = session.get(AssetInfo, asset_info_id)
if not info:
raise ValueError(f"AssetInfo {asset_info_id} not found")
norm = normalize_tags(tags)
if not norm:
total = get_asset_tags(session, asset_info_id=asset_info_id)
return {"removed": [], "not_present": [], "total_tags": total}
existing = {
tag_name
for (tag_name,) in (
session.execute(
sa.select(AssetInfoTag.tag_name).where(AssetInfoTag.asset_info_id == asset_info_id)
)
).all()
}
to_remove = sorted(set(t for t in norm if t in existing))
not_present = sorted(set(t for t in norm if t not in existing))
if to_remove:
session.execute(
delete(AssetInfoTag)
.where(
AssetInfoTag.asset_info_id == asset_info_id,
AssetInfoTag.tag_name.in_(to_remove),
)
)
session.flush()
total = get_asset_tags(session, asset_info_id=asset_info_id)
return {"removed": to_remove, "not_present": not_present, "total_tags": total}
def remove_missing_tag_for_asset_id(
session: Session,
*,
asset_id: str,
) -> None:
session.execute(
sa.delete(AssetInfoTag).where(
AssetInfoTag.asset_info_id.in_(sa.select(AssetInfo.id).where(AssetInfo.asset_id == asset_id)),
AssetInfoTag.tag_name == "missing",
)
)
def set_asset_info_preview(
session: Session,
*,
asset_info_id: str,
preview_asset_id: str | None = None,
) -> None:
"""Set or clear preview_id and bump updated_at. Raises on unknown IDs."""
info = session.get(AssetInfo, asset_info_id)
if not info:
raise ValueError(f"AssetInfo {asset_info_id} not found")
if preview_asset_id is None:
info.preview_id = None
else:
# validate preview asset exists
if not session.get(Asset, preview_asset_id):
raise ValueError(f"Preview Asset {preview_asset_id} not found")
info.preview_id = preview_asset_id
info.updated_at = utcnow()
session.flush()
+103 -8
View File
@@ -1,5 +1,6 @@
import contextlib
import os
from decimal import Decimal
from aiohttp import web
from datetime import datetime, timezone
from pathlib import Path
@@ -36,7 +37,7 @@ def list_tree(base_dir: str) -> list[str]:
def prefixes_for_root(root: RootType) -> list[str]:
if root == "models":
bases: list[str] = []
for _bucket, paths in get_comfy_models_folders():
for _bucket, paths in get_studio_models_folders():
bases.extend(paths)
return [os.path.abspath(p) for p in bases]
if root == "input":
@@ -73,20 +74,54 @@ def utcnow() -> datetime:
"""Naive UTC timestamp (no tzinfo). We always treat DB datetimes as UTC."""
return datetime.now(timezone.utc).replace(tzinfo=None)
def get_comfy_models_folders() -> list[tuple[str, list[str]]]:
def get_studio_models_folders() -> list[tuple[str, list[str]]]:
"""Build a list of (folder_name, base_paths[]) categories that are configured for model locations.
We trust `folder_paths.folder_names_and_paths` and include a category if
*any* of its base paths lies under the Comfy `models_dir`.
*any* of its base paths lies under the Studio `models_dir`.
"""
targets: list[tuple[str, list[str]]] = []
models_root = os.path.abspath(folder_paths.models_dir)
for name, values in folder_paths.folder_names_and_paths.items():
paths, _exts = values[0], values[1] # NOTE: this prevents nodepacks that hackily edit folder_... from breaking ComfyUI
paths, _exts = values[0], values[1] # NOTE: this prevents nodepacks that hackily edit folder_... from breaking Hanzo Studio
if any(os.path.abspath(p).startswith(models_root + os.sep) for p in paths):
targets.append((name, paths))
return targets
def resolve_destination_from_tags(tags: list[str]) -> tuple[str, list[str]]:
"""Validates and maps tags -> (base_dir, subdirs_for_fs)"""
root = tags[0]
if root == "models":
if len(tags) < 2:
raise ValueError("at least two tags required for model asset")
try:
bases = folder_paths.folder_names_and_paths[tags[1]][0]
except KeyError:
raise ValueError(f"unknown model category '{tags[1]}'")
if not bases:
raise ValueError(f"no base path configured for category '{tags[1]}'")
base_dir = os.path.abspath(bases[0])
raw_subdirs = tags[2:]
else:
base_dir = os.path.abspath(
folder_paths.get_input_directory() if root == "input" else folder_paths.get_output_directory()
)
raw_subdirs = tags[1:]
for i in raw_subdirs:
if i in (".", ".."):
raise ValueError("invalid path component in tags")
return base_dir, raw_subdirs if raw_subdirs else []
def ensure_within_base(candidate: str, base: str) -> None:
cand_abs = os.path.abspath(candidate)
base_abs = os.path.abspath(base)
try:
if os.path.commonpath([cand_abs, base_abs]) != base_abs:
raise ValueError("destination escapes base directory")
except Exception:
raise ValueError("invalid destination path")
def compute_relative_filename(file_path: str) -> str | None:
"""
Return the model's path relative to the last well-known folder (the model category),
@@ -113,12 +148,11 @@ def compute_relative_filename(file_path: str) -> str | None:
return "/".join(inside)
return "/".join(parts) # input/output: keep all parts
def get_relative_to_root_category_path_of_asset(file_path: str) -> tuple[Literal["input", "output", "models"], str]:
"""Given an absolute or relative file path, determine which root category the path belongs to:
- 'input' if the file resides under `folder_paths.get_input_directory()`
- 'output' if the file resides under `folder_paths.get_output_directory()`
- 'models' if the file resides under any base path of categories returned by `get_comfy_models_folders()`
- 'models' if the file resides under any base path of categories returned by `get_studio_models_folders()`
Returns:
(root_category, relative_path_inside_that_root)
@@ -151,7 +185,7 @@ def get_relative_to_root_category_path_of_asset(file_path: str) -> tuple[Literal
# 3) models (check deepest matching base to avoid ambiguity)
best: tuple[int, str, str] | None = None # (base_len, bucket, rel_inside_bucket)
for bucket, bases in get_comfy_models_folders():
for bucket, bases in get_studio_models_folders():
for b in bases:
base_abs = os.path.abspath(b)
if not _is_within(fp_abs, base_abs):
@@ -198,7 +232,7 @@ def normalize_tags(tags: list[str] | None) -> list[str]:
def collect_models_files() -> list[str]:
out: list[str] = []
for folder_name, bases in get_comfy_models_folders():
for folder_name, bases in get_studio_models_folders():
rel_files = folder_paths.get_filename_list(folder_name) or []
for rel_path in rel_files:
abs_path = folder_paths.get_full_path(folder_name, rel_path)
@@ -215,3 +249,64 @@ def collect_models_files() -> list[str]:
if allowed:
out.append(abs_path)
return out
def is_scalar(v):
if v is None:
return True
if isinstance(v, bool):
return True
if isinstance(v, (int, float, Decimal, str)):
return True
return False
def project_kv(key: str, value):
"""
Turn a metadata key/value into typed projection rows.
Returns list[dict] with keys:
key, ordinal, and one of val_str / val_num / val_bool / val_json (others None)
"""
rows: list[dict] = []
def _null_row(ordinal: int) -> dict:
return {
"key": key, "ordinal": ordinal,
"val_str": None, "val_num": None, "val_bool": None, "val_json": None
}
if value is None:
rows.append(_null_row(0))
return rows
if is_scalar(value):
if isinstance(value, bool):
rows.append({"key": key, "ordinal": 0, "val_bool": bool(value)})
elif isinstance(value, (int, float, Decimal)):
num = value if isinstance(value, Decimal) else Decimal(str(value))
rows.append({"key": key, "ordinal": 0, "val_num": num})
elif isinstance(value, str):
rows.append({"key": key, "ordinal": 0, "val_str": value})
else:
rows.append({"key": key, "ordinal": 0, "val_json": value})
return rows
if isinstance(value, list):
if all(is_scalar(x) for x in value):
for i, x in enumerate(value):
if x is None:
rows.append(_null_row(i))
elif isinstance(x, bool):
rows.append({"key": key, "ordinal": i, "val_bool": bool(x)})
elif isinstance(x, (int, float, Decimal)):
num = x if isinstance(x, Decimal) else Decimal(str(x))
rows.append({"key": key, "ordinal": i, "val_num": num})
elif isinstance(x, str):
rows.append({"key": key, "ordinal": i, "val_str": x})
else:
rows.append({"key": key, "ordinal": i, "val_json": x})
return rows
for i, x in enumerate(value):
rows.append({"key": key, "ordinal": i, "val_json": x})
return rows
rows.append({"key": key, "ordinal": 0, "val_json": value})
return rows
+397 -4
View File
@@ -1,13 +1,33 @@
import os
import mimetypes
import contextlib
from typing import Sequence
from app.database.db import create_session
from app.assets.api import schemas_out
from app.assets.api import schemas_out, schemas_in
from app.assets.database.queries import (
asset_exists_by_hash,
asset_info_exists_for_asset_id,
get_asset_by_hash,
get_asset_info_by_id,
fetch_asset_info_asset_and_tags,
fetch_asset_info_and_asset,
create_asset_info_for_existing_asset,
touch_asset_info_by_id,
update_asset_info_full,
delete_asset_info_by_id,
list_cache_states_by_asset_id,
list_asset_infos_page,
list_tags_with_usage,
get_asset_tags,
add_tags_to_asset_info,
remove_tags_from_asset_info,
pick_best_live_path,
ingest_fs_asset,
set_asset_info_preview,
)
from app.assets.helpers import resolve_destination_from_tags, ensure_within_base
from app.assets.database.models import Asset
def _safe_sort_field(requested: str | None) -> str:
@@ -19,11 +39,28 @@ def _safe_sort_field(requested: str | None) -> str:
return "created_at"
def asset_exists(asset_hash: str) -> bool:
def _get_size_mtime_ns(path: str) -> tuple[int, int]:
st = os.stat(path, follow_symlinks=True)
return st.st_size, getattr(st, "st_mtime_ns", int(st.st_mtime * 1_000_000_000))
def _safe_filename(name: str | None, fallback: str) -> str:
n = os.path.basename((name or "").strip() or fallback)
if n:
return n
return fallback
def asset_exists(*, asset_hash: str) -> bool:
"""
Check if an asset with a given hash exists in database.
"""
with create_session() as session:
return asset_exists_by_hash(session, asset_hash=asset_hash)
def list_assets(
*,
include_tags: Sequence[str] | None = None,
exclude_tags: Sequence[str] | None = None,
name_contains: str | None = None,
@@ -63,7 +100,6 @@ def list_assets(
size=int(asset.size_bytes) if asset else None,
mime_type=asset.mime_type if asset else None,
tags=tags,
preview_url=f"/api/assets/{info.id}/content",
created_at=info.created_at,
updated_at=info.updated_at,
last_access_time=info.last_access_time,
@@ -76,7 +112,12 @@ def list_assets(
has_more=(offset + len(summaries)) < total,
)
def get_asset(asset_info_id: str, owner_id: str = "") -> schemas_out.AssetDetail:
def get_asset(
*,
asset_info_id: str,
owner_id: str = "",
) -> schemas_out.AssetDetail:
with create_session() as session:
res = fetch_asset_info_asset_and_tags(session, asset_info_id=asset_info_id, owner_id=owner_id)
if not res:
@@ -97,6 +138,358 @@ def get_asset(asset_info_id: str, owner_id: str = "") -> schemas_out.AssetDetail
last_access_time=info.last_access_time,
)
def resolve_asset_content_for_download(
*,
asset_info_id: str,
owner_id: str = "",
) -> tuple[str, str, str]:
with create_session() as session:
pair = fetch_asset_info_and_asset(session, asset_info_id=asset_info_id, owner_id=owner_id)
if not pair:
raise ValueError(f"AssetInfo {asset_info_id} not found")
info, asset = pair
states = list_cache_states_by_asset_id(session, asset_id=asset.id)
abs_path = pick_best_live_path(states)
if not abs_path:
raise FileNotFoundError
touch_asset_info_by_id(session, asset_info_id=asset_info_id)
session.commit()
ctype = asset.mime_type or mimetypes.guess_type(info.name or abs_path)[0] or "application/octet-stream"
download_name = info.name or os.path.basename(abs_path)
return abs_path, ctype, download_name
def upload_asset_from_temp_path(
spec: schemas_in.UploadAssetSpec,
*,
temp_path: str,
client_filename: str | None = None,
owner_id: str = "",
expected_asset_hash: str | None = None,
) -> schemas_out.AssetCreated:
"""
Create new asset or update existing asset from a temporary file path.
"""
try:
# NOTE: blake3 is not required right now, so this will fail if blake3 is not installed in local environment
import app.assets.hashing as hashing
digest = hashing.blake3_hash(temp_path)
except Exception as e:
raise RuntimeError(f"failed to hash uploaded file: {e}")
asset_hash = "blake3:" + digest
if expected_asset_hash and asset_hash != expected_asset_hash.strip().lower():
raise ValueError("HASH_MISMATCH")
with create_session() as session:
existing = get_asset_by_hash(session, asset_hash=asset_hash)
if existing is not None:
with contextlib.suppress(Exception):
if temp_path and os.path.exists(temp_path):
os.remove(temp_path)
display_name = _safe_filename(spec.name or (client_filename or ""), fallback=digest)
info = create_asset_info_for_existing_asset(
session,
asset_hash=asset_hash,
name=display_name,
user_metadata=spec.user_metadata or {},
tags=spec.tags or [],
tag_origin="manual",
owner_id=owner_id,
)
tag_names = get_asset_tags(session, asset_info_id=info.id)
session.commit()
return schemas_out.AssetCreated(
id=info.id,
name=info.name,
asset_hash=existing.hash,
size=int(existing.size_bytes) if existing.size_bytes is not None else None,
mime_type=existing.mime_type,
tags=tag_names,
user_metadata=info.user_metadata or {},
preview_id=info.preview_id,
created_at=info.created_at,
last_access_time=info.last_access_time,
created_new=False,
)
base_dir, subdirs = resolve_destination_from_tags(spec.tags)
dest_dir = os.path.join(base_dir, *subdirs) if subdirs else base_dir
os.makedirs(dest_dir, exist_ok=True)
src_for_ext = (client_filename or spec.name or "").strip()
_ext = os.path.splitext(os.path.basename(src_for_ext))[1] if src_for_ext else ""
ext = _ext if 0 < len(_ext) <= 16 else ""
hashed_basename = f"{digest}{ext}"
dest_abs = os.path.abspath(os.path.join(dest_dir, hashed_basename))
ensure_within_base(dest_abs, base_dir)
content_type = (
mimetypes.guess_type(os.path.basename(src_for_ext), strict=False)[0]
or mimetypes.guess_type(hashed_basename, strict=False)[0]
or "application/octet-stream"
)
try:
os.replace(temp_path, dest_abs)
except Exception as e:
raise RuntimeError(f"failed to move uploaded file into place: {e}")
try:
size_bytes, mtime_ns = _get_size_mtime_ns(dest_abs)
except OSError as e:
raise RuntimeError(f"failed to stat destination file: {e}")
with create_session() as session:
result = ingest_fs_asset(
session,
asset_hash=asset_hash,
abs_path=dest_abs,
size_bytes=size_bytes,
mtime_ns=mtime_ns,
mime_type=content_type,
info_name=_safe_filename(spec.name or (client_filename or ""), fallback=digest),
owner_id=owner_id,
preview_id=None,
user_metadata=spec.user_metadata or {},
tags=spec.tags,
tag_origin="manual",
require_existing_tags=False,
)
info_id = result["asset_info_id"]
if not info_id:
raise RuntimeError("failed to create asset metadata")
pair = fetch_asset_info_and_asset(session, asset_info_id=info_id, owner_id=owner_id)
if not pair:
raise RuntimeError("inconsistent DB state after ingest")
info, asset = pair
tag_names = get_asset_tags(session, asset_info_id=info.id)
created_result = schemas_out.AssetCreated(
id=info.id,
name=info.name,
asset_hash=asset.hash,
size=int(asset.size_bytes),
mime_type=asset.mime_type,
tags=tag_names,
user_metadata=info.user_metadata or {},
preview_id=info.preview_id,
created_at=info.created_at,
last_access_time=info.last_access_time,
created_new=result["asset_created"],
)
session.commit()
return created_result
def update_asset(
*,
asset_info_id: str,
name: str | None = None,
tags: list[str] | None = None,
user_metadata: dict | None = None,
owner_id: str = "",
) -> schemas_out.AssetUpdated:
with create_session() as session:
info_row = get_asset_info_by_id(session, asset_info_id=asset_info_id)
if not info_row:
raise ValueError(f"AssetInfo {asset_info_id} not found")
if info_row.owner_id and info_row.owner_id != owner_id:
raise PermissionError("not owner")
info = update_asset_info_full(
session,
asset_info_id=asset_info_id,
name=name,
tags=tags,
user_metadata=user_metadata,
tag_origin="manual",
asset_info_row=info_row,
)
tag_names = get_asset_tags(session, asset_info_id=asset_info_id)
result = schemas_out.AssetUpdated(
id=info.id,
name=info.name,
asset_hash=info.asset.hash if info.asset else None,
tags=tag_names,
user_metadata=info.user_metadata or {},
updated_at=info.updated_at,
)
session.commit()
return result
def set_asset_preview(
*,
asset_info_id: str,
preview_asset_id: str | None = None,
owner_id: str = "",
) -> schemas_out.AssetDetail:
with create_session() as session:
info_row = get_asset_info_by_id(session, asset_info_id=asset_info_id)
if not info_row:
raise ValueError(f"AssetInfo {asset_info_id} not found")
if info_row.owner_id and info_row.owner_id != owner_id:
raise PermissionError("not owner")
set_asset_info_preview(
session,
asset_info_id=asset_info_id,
preview_asset_id=preview_asset_id,
)
res = fetch_asset_info_asset_and_tags(session, asset_info_id=asset_info_id, owner_id=owner_id)
if not res:
raise RuntimeError("State changed during preview update")
info, asset, tags = res
result = schemas_out.AssetDetail(
id=info.id,
name=info.name,
asset_hash=asset.hash if asset else None,
size=int(asset.size_bytes) if asset and asset.size_bytes is not None else None,
mime_type=asset.mime_type if asset else None,
tags=tags,
user_metadata=info.user_metadata or {},
preview_id=info.preview_id,
created_at=info.created_at,
last_access_time=info.last_access_time,
)
session.commit()
return result
def delete_asset_reference(*, asset_info_id: str, owner_id: str, delete_content_if_orphan: bool = True) -> bool:
with create_session() as session:
info_row = get_asset_info_by_id(session, asset_info_id=asset_info_id)
asset_id = info_row.asset_id if info_row else None
deleted = delete_asset_info_by_id(session, asset_info_id=asset_info_id, owner_id=owner_id)
if not deleted:
session.commit()
return False
if not delete_content_if_orphan or not asset_id:
session.commit()
return True
still_exists = asset_info_exists_for_asset_id(session, asset_id=asset_id)
if still_exists:
session.commit()
return True
states = list_cache_states_by_asset_id(session, asset_id=asset_id)
file_paths = [s.file_path for s in (states or []) if getattr(s, "file_path", None)]
asset_row = session.get(Asset, asset_id)
if asset_row is not None:
session.delete(asset_row)
session.commit()
for p in file_paths:
with contextlib.suppress(Exception):
if p and os.path.isfile(p):
os.remove(p)
return True
def create_asset_from_hash(
*,
hash_str: str,
name: str,
tags: list[str] | None = None,
user_metadata: dict | None = None,
owner_id: str = "",
) -> schemas_out.AssetCreated | None:
canonical = hash_str.strip().lower()
with create_session() as session:
asset = get_asset_by_hash(session, asset_hash=canonical)
if not asset:
return None
info = create_asset_info_for_existing_asset(
session,
asset_hash=canonical,
name=_safe_filename(name, fallback=canonical.split(":", 1)[1]),
user_metadata=user_metadata or {},
tags=tags or [],
tag_origin="manual",
owner_id=owner_id,
)
tag_names = get_asset_tags(session, asset_info_id=info.id)
result = schemas_out.AssetCreated(
id=info.id,
name=info.name,
asset_hash=asset.hash,
size=int(asset.size_bytes),
mime_type=asset.mime_type,
tags=tag_names,
user_metadata=info.user_metadata or {},
preview_id=info.preview_id,
created_at=info.created_at,
last_access_time=info.last_access_time,
created_new=False,
)
session.commit()
return result
def add_tags_to_asset(
*,
asset_info_id: str,
tags: list[str],
origin: str = "manual",
owner_id: str = "",
) -> schemas_out.TagsAdd:
with create_session() as session:
info_row = get_asset_info_by_id(session, asset_info_id=asset_info_id)
if not info_row:
raise ValueError(f"AssetInfo {asset_info_id} not found")
if info_row.owner_id and info_row.owner_id != owner_id:
raise PermissionError("not owner")
data = add_tags_to_asset_info(
session,
asset_info_id=asset_info_id,
tags=tags,
origin=origin,
create_if_missing=True,
asset_info_row=info_row,
)
session.commit()
return schemas_out.TagsAdd(**data)
def remove_tags_from_asset(
*,
asset_info_id: str,
tags: list[str],
owner_id: str = "",
) -> schemas_out.TagsRemove:
with create_session() as session:
info_row = get_asset_info_by_id(session, asset_info_id=asset_info_id)
if not info_row:
raise ValueError(f"AssetInfo {asset_info_id} not found")
if info_row.owner_id and info_row.owner_id != owner_id:
raise PermissionError("not owner")
data = remove_tags_from_asset_info(
session,
asset_info_id=asset_info_id,
tags=tags,
)
session.commit()
return schemas_out.TagsRemove(**data)
def list_tags(
prefix: str | None = None,
limit: int = 100,
+35 -1
View File
@@ -27,6 +27,7 @@ def seed_assets(roots: tuple[RootType, ...], enable_logging: bool = False) -> No
t_start = time.perf_counter()
created = 0
skipped_existing = 0
orphans_pruned = 0
paths: list[str] = []
try:
existing_paths: set[str] = set()
@@ -38,6 +39,11 @@ def seed_assets(roots: tuple[RootType, ...], enable_logging: bool = False) -> No
except Exception as e:
logging.exception("fast DB scan failed for %s: %s", r, e)
try:
orphans_pruned = _prune_orphaned_assets(roots)
except Exception as e:
logging.exception("orphan pruning failed: %s", e)
if "models" in roots:
paths.extend(collect_models_files())
if "input" in roots:
@@ -85,15 +91,43 @@ def seed_assets(roots: tuple[RootType, ...], enable_logging: bool = False) -> No
finally:
if enable_logging:
logging.info(
"Assets scan(roots=%s) completed in %.3fs (created=%d, skipped_existing=%d, total_seen=%d)",
"Assets scan(roots=%s) completed in %.3fs (created=%d, skipped_existing=%d, orphans_pruned=%d, total_seen=%d)",
roots,
time.perf_counter() - t_start,
created,
skipped_existing,
orphans_pruned,
len(paths),
)
def _prune_orphaned_assets(roots: tuple[RootType, ...]) -> int:
"""Prune cache states outside configured prefixes, then delete orphaned seed assets."""
all_prefixes = [os.path.abspath(p) for r in roots for p in prefixes_for_root(r)]
if not all_prefixes:
return 0
def make_prefix_condition(prefix: str):
base = prefix if prefix.endswith(os.sep) else prefix + os.sep
escaped, esc = escape_like_prefix(base)
return AssetCacheState.file_path.like(escaped + "%", escape=esc)
matches_valid_prefix = sqlalchemy.or_(*[make_prefix_condition(p) for p in all_prefixes])
orphan_subq = (
sqlalchemy.select(Asset.id)
.outerjoin(AssetCacheState, AssetCacheState.asset_id == Asset.id)
.where(Asset.hash.is_(None), AssetCacheState.id.is_(None))
).scalar_subquery()
with create_session() as sess:
sess.execute(sqlalchemy.delete(AssetCacheState).where(~matches_valid_prefix))
sess.execute(sqlalchemy.delete(AssetInfo).where(AssetInfo.asset_id.in_(orphan_subq)))
result = sess.execute(sqlalchemy.delete(Asset).where(Asset.id.in_(orphan_subq)))
sess.commit()
return result.rowcount
def _fast_db_consistency_pass(
root: RootType,
*,
+2 -2
View File
@@ -3,7 +3,7 @@ import os
import shutil
from app.logger import log_startup_warning
from utils.install_util import get_missing_requirements_message
from comfy.cli_args import args
from studio.cli_args import args
_DB_AVAILABLE = False
Session = None
@@ -24,7 +24,7 @@ except ImportError as e:
------------------------------------------------------------------------
Error importing dependencies: {e}
{get_missing_requirements_message()}
This error is happening because ComfyUI now uses a local sqlite database.
This error is happening because Hanzo Studio now uses a local sqlite database.
------------------------------------------------------------------------
""".strip()
)
+3 -3
View File
@@ -19,7 +19,7 @@ from typing_extensions import NotRequired
from utils.install_util import get_missing_requirements_message, requirements_path
from comfy.cli_args import DEFAULT_VERSION_STRING
from studio.cli_args import DEFAULT_VERSION_STRING
import app.logger
@@ -27,7 +27,7 @@ def frontend_install_warning_message():
return f"""
{get_missing_requirements_message()}
This error is happening because the ComfyUI frontend is no longer shipped as part of the main repo but as a pip package instead.
This error is happening because the Hanzo Studio frontend is no longer shipped as part of the main repo but as a pip package instead.
""".strip()
def parse_version(version: str) -> tuple[int, int, int]:
@@ -87,7 +87,7 @@ ________________________________________________________________________
""".strip()
)
else:
logging.info("ComfyUI frontend version: {}".format(frontend_version_str))
logging.info("Hanzo Studio frontend version: {}".format(frontend_version_str))
except Exception as e:
logging.error(f"Failed to check frontend version: {e}")
+2 -2
View File
@@ -7,7 +7,7 @@ import time
import logging
import folder_paths
import glob
import comfy.utils
import studio.utils
from aiohttp import web
from PIL import Image
from io import BytesIO
@@ -180,7 +180,7 @@ class ModelFileManager:
if safetensors_file:
safetensors_filepath = os.path.join(dirname, safetensors_file)
header = comfy.utils.safetensors_header(safetensors_filepath, max_size=8*1024*1024)
header = studio.utils.safetensors_header(safetensors_filepath, max_size=8*1024*1024)
if header:
safetensors_metadata = json.loads(header)
safetensors_images = safetensors_metadata.get("__metadata__", {}).get("ssmd_cover_images", None)
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
from aiohttp import web
from typing import TYPE_CHECKING, TypedDict
if TYPE_CHECKING:
from studio_api.latest._io_public import NodeReplace
from studio_execution.graph_utils import is_link
import nodes
class NodeStruct(TypedDict):
inputs: dict[str, str | int | float | bool | tuple[str, int]]
class_type: str
_meta: dict[str, str]
def copy_node_struct(node_struct: NodeStruct, empty_inputs: bool = False) -> NodeStruct:
new_node_struct = node_struct.copy()
if empty_inputs:
new_node_struct["inputs"] = {}
else:
new_node_struct["inputs"] = node_struct["inputs"].copy()
new_node_struct["_meta"] = node_struct["_meta"].copy()
return new_node_struct
class NodeReplaceManager:
"""Manages node replacement registrations."""
def __init__(self):
self._replacements: dict[str, list[NodeReplace]] = {}
def register(self, node_replace: NodeReplace):
"""Register a node replacement mapping."""
self._replacements.setdefault(node_replace.old_node_id, []).append(node_replace)
def get_replacement(self, old_node_id: str) -> list[NodeReplace] | None:
"""Get replacements for an old node ID."""
return self._replacements.get(old_node_id)
def has_replacement(self, old_node_id: str) -> bool:
"""Check if a replacement exists for an old node ID."""
return old_node_id in self._replacements
def apply_replacements(self, prompt: dict[str, NodeStruct]):
connections: dict[str, list[tuple[str, str, int]]] = {}
need_replacement: set[str] = set()
for node_number, node_struct in prompt.items():
class_type = node_struct["class_type"]
# need replacement if not in NODE_CLASS_MAPPINGS and has replacement
if class_type not in nodes.NODE_CLASS_MAPPINGS.keys() and self.has_replacement(class_type):
need_replacement.add(node_number)
# keep track of connections
for input_id, input_value in node_struct["inputs"].items():
if is_link(input_value):
conn_number = input_value[0]
connections.setdefault(conn_number, []).append((node_number, input_id, input_value[1]))
for node_number in need_replacement:
node_struct = prompt[node_number]
class_type = node_struct["class_type"]
replacements = self.get_replacement(class_type)
if replacements is None:
continue
# just use the first replacement
replacement = replacements[0]
new_node_id = replacement.new_node_id
# if replacement is not a valid node, skip trying to replace it as will only cause confusion
if new_node_id not in nodes.NODE_CLASS_MAPPINGS.keys():
continue
# first, replace node id (class_type)
new_node_struct = copy_node_struct(node_struct, empty_inputs=True)
new_node_struct["class_type"] = new_node_id
# TODO: consider replacing display_name in _meta as well for error reporting purposes; would need to query node schema
# second, replace inputs
if replacement.input_mapping is not None:
for input_map in replacement.input_mapping:
if "set_value" in input_map:
new_node_struct["inputs"][input_map["new_id"]] = input_map["set_value"]
elif "old_id" in input_map:
new_node_struct["inputs"][input_map["new_id"]] = node_struct["inputs"][input_map["old_id"]]
# finalize input replacement
prompt[node_number] = new_node_struct
# third, replace outputs
if replacement.output_mapping is not None:
# re-mapping outputs requires changing the input values of nodes that receive connections from this one
if node_number in connections:
for conns in connections[node_number]:
conn_node_number, conn_input_id, old_output_idx = conns
for output_map in replacement.output_mapping:
if output_map["old_idx"] == old_output_idx:
new_output_idx = output_map["new_idx"]
previous_input = prompt[conn_node_number]["inputs"][conn_input_id]
previous_input[1] = new_output_idx
def as_dict(self):
"""Serialize all replacements to dict."""
return {
k: [v.as_dict() for v in v_list]
for k, v_list in self._replacements.items()
}
def add_routes(self, routes):
@routes.get("/node_replacements")
async def get_node_replacements(request):
return web.json_response(self.as_dict())
+2 -2
View File
@@ -53,7 +53,7 @@ class SubgraphManager:
return entry_id, entry
async def load_entry_data(self, entry: SubgraphEntry):
with open(entry['path'], 'r') as f:
with open(entry['path'], 'r', encoding='utf-8') as f:
entry['data'] = f.read()
return entry
@@ -100,7 +100,7 @@ class SubgraphManager:
if os.path.exists(blueprints_dir):
for file in glob.glob(os.path.join(blueprints_dir, "*.json")):
file = file.replace('\\', '/')
entry_id, entry = self._create_entry(file, Source.templates, "comfyui")
entry_id, entry = self._create_entry(file, Source.templates, "studio")
subgraphs_dict[entry_id] = entry
self.cached_blueprint_subgraphs = subgraphs_dict
+28 -5
View File
@@ -8,7 +8,7 @@ import shutil
import logging
from aiohttp import web
from urllib import parse
from comfy.cli_args import args
from studio.cli_args import args
import folder_paths
from .app_settings import AppSettings
from typing import TypedDict
@@ -57,8 +57,22 @@ class UserManager():
def get_request_user_id(self, request):
user = "default"
if args.multi_user and "comfy-user" in request.headers:
user = request.headers["comfy-user"]
# If IAM auth is active, extract user from IAM context
iam_user = request.get("iam_user")
if iam_user and iam_user.get("sub") and iam_user["sub"] != "local":
user = iam_user["sub"]
# A token subject must never escape into a System User namespace.
if user.startswith(folder_paths.SYSTEM_USER_PREFIX):
raise KeyError("Unknown user: " + user)
# Auto-register IAM users
if user not in self.users:
display = iam_user.get("name") or iam_user.get("email") or user
self.users[user] = display
return user
if args.multi_user and "studio-user" in request.headers:
user = request.headers["studio-user"]
# Block System Users (use same error message to prevent probing)
if user.startswith(folder_paths.SYSTEM_USER_PREFIX):
raise KeyError("Unknown user: " + user)
@@ -68,14 +82,23 @@ class UserManager():
return user
def get_request_org_id(self, request) -> str | None:
"""Extract org_id from IAM context or fallback to CLI arg."""
iam_user = request.get("iam_user")
if iam_user and iam_user.get("org_id"):
return iam_user["org_id"]
return folder_paths.get_org_id()
def get_request_user_filepath(self, request, file, type="userdata", create_dir=True):
if type == "userdata":
root_dir = folder_paths.get_user_directory()
# Use org-scoped user directory if multi-tenant
org_id = self.get_request_org_id(request)
root_dir = folder_paths.get_org_user_directory(org_id)
else:
raise KeyError("Unknown filepath type:" + type)
user = self.get_request_user_id(request)
user_root = folder_paths.get_public_user_directory(user)
user_root = folder_paths.get_public_user_directory(user, org_id)
if user_root is None:
return None
path = user_root
@@ -0,0 +1,44 @@
#version 300 es
precision highp float;
uniform sampler2D u_image0;
uniform float u_float0; // Brightness slider -100..100
uniform float u_float1; // Contrast slider -100..100
in vec2 v_texCoord;
out vec4 fragColor;
const float MID_GRAY = 0.18; // 18% reflectance
// sRGB gamma 2.2 approximation
vec3 srgbToLinear(vec3 c) {
return pow(max(c, 0.0), vec3(2.2));
}
vec3 linearToSrgb(vec3 c) {
return pow(max(c, 0.0), vec3(1.0/2.2));
}
float mapBrightness(float b) {
return clamp(b / 100.0, -1.0, 1.0);
}
float mapContrast(float c) {
return clamp(c / 100.0 + 1.0, 0.0, 2.0);
}
void main() {
vec4 orig = texture(u_image0, v_texCoord);
float brightness = mapBrightness(u_float0);
float contrast = mapContrast(u_float1);
vec3 lin = srgbToLinear(orig.rgb);
lin = (lin - MID_GRAY) * contrast + brightness + MID_GRAY;
// Convert back to sRGB
vec3 result = linearToSrgb(clamp(lin, 0.0, 1.0));
fragColor = vec4(result, orig.a);
}
@@ -0,0 +1,72 @@
#version 300 es
precision highp float;
uniform sampler2D u_image0;
uniform vec2 u_resolution;
uniform int u_int0; // Mode
uniform float u_float0; // Amount (0 to 100)
in vec2 v_texCoord;
out vec4 fragColor;
const int MODE_LINEAR = 0;
const int MODE_RADIAL = 1;
const int MODE_BARREL = 2;
const int MODE_SWIRL = 3;
const int MODE_DIAGONAL = 4;
const float AMOUNT_SCALE = 0.0005;
const float RADIAL_MULT = 4.0;
const float BARREL_MULT = 8.0;
const float INV_SQRT2 = 0.70710678118;
void main() {
vec2 uv = v_texCoord;
vec4 original = texture(u_image0, uv);
float amount = u_float0 * AMOUNT_SCALE;
if (amount < 0.000001) {
fragColor = original;
return;
}
// Aspect-corrected coordinates for circular effects
float aspect = u_resolution.x / u_resolution.y;
vec2 centered = uv - 0.5;
vec2 corrected = vec2(centered.x * aspect, centered.y);
float r = length(corrected);
vec2 dir = r > 0.0001 ? corrected / r : vec2(0.0);
vec2 offset = vec2(0.0);
if (u_int0 == MODE_LINEAR) {
// Horizontal shift (no aspect correction needed)
offset = vec2(amount, 0.0);
}
else if (u_int0 == MODE_RADIAL) {
// Outward from center, stronger at edges
offset = dir * r * amount * RADIAL_MULT;
offset.x /= aspect; // Convert back to UV space
}
else if (u_int0 == MODE_BARREL) {
// Lens distortion simulation (r² falloff)
offset = dir * r * r * amount * BARREL_MULT;
offset.x /= aspect; // Convert back to UV space
}
else if (u_int0 == MODE_SWIRL) {
// Perpendicular to radial (rotational aberration)
vec2 perp = vec2(-dir.y, dir.x);
offset = perp * r * amount * RADIAL_MULT;
offset.x /= aspect; // Convert back to UV space
}
else if (u_int0 == MODE_DIAGONAL) {
// 45° offset (no aspect correction needed)
offset = vec2(amount, amount) * INV_SQRT2;
}
float red = texture(u_image0, uv + offset).r;
float green = original.g;
float blue = texture(u_image0, uv - offset).b;
fragColor = vec4(red, green, blue, original.a);
}
+78
View File
@@ -0,0 +1,78 @@
#version 300 es
precision highp float;
uniform sampler2D u_image0;
uniform float u_float0; // temperature (-100 to 100)
uniform float u_float1; // tint (-100 to 100)
uniform float u_float2; // vibrance (-100 to 100)
uniform float u_float3; // saturation (-100 to 100)
in vec2 v_texCoord;
out vec4 fragColor;
const float INPUT_SCALE = 0.01;
const float TEMP_TINT_PRIMARY = 0.3;
const float TEMP_TINT_SECONDARY = 0.15;
const float VIBRANCE_BOOST = 2.0;
const float SATURATION_BOOST = 2.0;
const float SKIN_PROTECTION = 0.5;
const float EPSILON = 0.001;
const vec3 LUMA_WEIGHTS = vec3(0.299, 0.587, 0.114);
void main() {
vec4 tex = texture(u_image0, v_texCoord);
vec3 color = tex.rgb;
// Scale inputs: -100/100 → -1/1
float temperature = u_float0 * INPUT_SCALE;
float tint = u_float1 * INPUT_SCALE;
float vibrance = u_float2 * INPUT_SCALE;
float saturation = u_float3 * INPUT_SCALE;
// Temperature (warm/cool): positive = warm, negative = cool
color.r += temperature * TEMP_TINT_PRIMARY;
color.b -= temperature * TEMP_TINT_PRIMARY;
// Tint (green/magenta): positive = green, negative = magenta
color.g += tint * TEMP_TINT_PRIMARY;
color.r -= tint * TEMP_TINT_SECONDARY;
color.b -= tint * TEMP_TINT_SECONDARY;
// Single clamp after temperature/tint
color = clamp(color, 0.0, 1.0);
// Vibrance with skin protection
if (vibrance != 0.0) {
float maxC = max(color.r, max(color.g, color.b));
float minC = min(color.r, min(color.g, color.b));
float sat = maxC - minC;
float gray = dot(color, LUMA_WEIGHTS);
if (vibrance < 0.0) {
// Desaturate: -100 → gray
color = mix(vec3(gray), color, 1.0 + vibrance);
} else {
// Boost less saturated colors more
float vibranceAmt = vibrance * (1.0 - sat);
// Branchless skin tone protection
float isWarmTone = step(color.b, color.g) * step(color.g, color.r);
float warmth = (color.r - color.b) / max(maxC, EPSILON);
float skinTone = isWarmTone * warmth * sat * (1.0 - sat);
vibranceAmt *= (1.0 - skinTone * SKIN_PROTECTION);
color = mix(vec3(gray), color, 1.0 + vibranceAmt * VIBRANCE_BOOST);
}
}
// Saturation
if (saturation != 0.0) {
float gray = dot(color, LUMA_WEIGHTS);
float satMix = saturation < 0.0
? 1.0 + saturation // -100 → gray
: 1.0 + saturation * SATURATION_BOOST; // +100 → 3x boost
color = mix(vec3(gray), color, satMix);
}
fragColor = vec4(clamp(color, 0.0, 1.0), tex.a);
}
@@ -0,0 +1,94 @@
#version 300 es
precision highp float;
uniform sampler2D u_image0;
uniform float u_float0; // Blur radius (020, default ~5)
uniform float u_float1; // Edge threshold (0100, default ~30)
uniform int u_int0; // Step size (0/1 = every pixel, 2+ = skip pixels)
in vec2 v_texCoord;
out vec4 fragColor;
const int MAX_RADIUS = 20;
const float EPSILON = 0.0001;
// Perceptual luminance
float getLuminance(vec3 rgb) {
return dot(rgb, vec3(0.299, 0.587, 0.114));
}
vec4 bilateralFilter(vec2 uv, vec2 texelSize, int radius,
float sigmaSpatial, float sigmaColor)
{
vec4 center = texture(u_image0, uv);
vec3 centerRGB = center.rgb;
float invSpatial2 = -0.5 / (sigmaSpatial * sigmaSpatial);
float invColor2 = -0.5 / (sigmaColor * sigmaColor + EPSILON);
vec3 sumRGB = vec3(0.0);
float sumWeight = 0.0;
int step = max(u_int0, 1);
float radius2 = float(radius * radius);
for (int dy = -MAX_RADIUS; dy <= MAX_RADIUS; dy++) {
if (dy < -radius || dy > radius) continue;
if (abs(dy) % step != 0) continue;
for (int dx = -MAX_RADIUS; dx <= MAX_RADIUS; dx++) {
if (dx < -radius || dx > radius) continue;
if (abs(dx) % step != 0) continue;
vec2 offset = vec2(float(dx), float(dy));
float dist2 = dot(offset, offset);
if (dist2 > radius2) continue;
vec3 sampleRGB = texture(u_image0, uv + offset * texelSize).rgb;
// Spatial Gaussian
float spatialWeight = exp(dist2 * invSpatial2);
// Perceptual color distance (weighted RGB)
vec3 diff = sampleRGB - centerRGB;
float colorDist = dot(diff * diff, vec3(0.299, 0.587, 0.114));
float colorWeight = exp(colorDist * invColor2);
float w = spatialWeight * colorWeight;
sumRGB += sampleRGB * w;
sumWeight += w;
}
}
vec3 resultRGB = sumRGB / max(sumWeight, EPSILON);
return vec4(resultRGB, center.a); // preserve center alpha
}
void main() {
vec2 texelSize = 1.0 / vec2(textureSize(u_image0, 0));
float radiusF = clamp(u_float0, 0.0, float(MAX_RADIUS));
int radius = int(radiusF + 0.5);
if (radius == 0) {
fragColor = texture(u_image0, v_texCoord);
return;
}
// Edge threshold → color sigma
// Squared curve for better low-end control
float t = clamp(u_float1, 0.0, 100.0) / 100.0;
t *= t;
float sigmaColor = mix(0.01, 0.5, t);
// Spatial sigma tied to radius
float sigmaSpatial = max(radiusF * 0.75, 0.5);
fragColor = bilateralFilter(
v_texCoord,
texelSize,
radius,
sigmaSpatial,
sigmaColor
);
}
+124
View File
@@ -0,0 +1,124 @@
#version 300 es
precision highp float;
uniform sampler2D u_image0;
uniform vec2 u_resolution;
uniform float u_float0; // grain amount [0.0 1.0] typical: 0.20.8
uniform float u_float1; // grain size [0.3 3.0] lower = finer grain
uniform float u_float2; // color amount [0.0 1.0] 0 = monochrome, 1 = RGB grain
uniform float u_float3; // luminance bias [0.0 1.0] 0 = uniform, 1 = shadows only
uniform int u_int0; // noise mode [0 or 1] 0 = smooth, 1 = grainy
in vec2 v_texCoord;
layout(location = 0) out vec4 fragColor0;
// High-quality integer hash (pcg-like)
uint pcg(uint v) {
uint state = v * 747796405u + 2891336453u;
uint word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
return (word >> 22u) ^ word;
}
// 2D -> 1D hash input
uint hash2d(uvec2 p) {
return pcg(p.x + pcg(p.y));
}
// Hash to float [0, 1]
float hashf(uvec2 p) {
return float(hash2d(p)) / float(0xffffffffu);
}
// Hash to float with offset (for RGB channels)
float hashf(uvec2 p, uint offset) {
return float(pcg(hash2d(p) + offset)) / float(0xffffffffu);
}
// Convert uniform [0,1] to roughly Gaussian distribution
// Using simple approximation: average of multiple samples
float toGaussian(uvec2 p) {
float sum = hashf(p, 0u) + hashf(p, 1u) + hashf(p, 2u) + hashf(p, 3u);
return (sum - 2.0) * 0.7; // Centered, scaled
}
float toGaussian(uvec2 p, uint offset) {
float sum = hashf(p, offset) + hashf(p, offset + 1u)
+ hashf(p, offset + 2u) + hashf(p, offset + 3u);
return (sum - 2.0) * 0.7;
}
// Smooth noise with better interpolation
float smoothNoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
// Quintic interpolation (less banding than cubic)
f = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
uvec2 ui = uvec2(i);
float a = toGaussian(ui);
float b = toGaussian(ui + uvec2(1u, 0u));
float c = toGaussian(ui + uvec2(0u, 1u));
float d = toGaussian(ui + uvec2(1u, 1u));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
float smoothNoise(vec2 p, uint offset) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
uvec2 ui = uvec2(i);
float a = toGaussian(ui, offset);
float b = toGaussian(ui + uvec2(1u, 0u), offset);
float c = toGaussian(ui + uvec2(0u, 1u), offset);
float d = toGaussian(ui + uvec2(1u, 1u), offset);
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
void main() {
vec4 color = texture(u_image0, v_texCoord);
// Luminance (Rec.709)
float luma = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
// Grain UV (resolution-independent)
vec2 grainUV = v_texCoord * u_resolution / max(u_float1, 0.01);
uvec2 grainPixel = uvec2(grainUV);
float g;
vec3 grainRGB;
if (u_int0 == 1) {
// Grainy mode: pure hash noise (no interpolation = no banding)
g = toGaussian(grainPixel);
grainRGB = vec3(
toGaussian(grainPixel, 100u),
toGaussian(grainPixel, 200u),
toGaussian(grainPixel, 300u)
);
} else {
// Smooth mode: interpolated with quintic curve
g = smoothNoise(grainUV);
grainRGB = vec3(
smoothNoise(grainUV, 100u),
smoothNoise(grainUV, 200u),
smoothNoise(grainUV, 300u)
);
}
// Luminance weighting (less grain in highlights)
float lumWeight = mix(1.0, 1.0 - luma, clamp(u_float3, 0.0, 1.0));
// Strength
float strength = u_float0 * 0.15;
// Color vs monochrome grain
vec3 grainColor = mix(vec3(g), grainRGB, clamp(u_float2, 0.0, 1.0));
color.rgb += grainColor * strength * lumWeight;
fragColor0 = vec4(clamp(color.rgb, 0.0, 1.0), color.a);
}
+133
View File
@@ -0,0 +1,133 @@
#version 300 es
precision mediump float;
uniform sampler2D u_image0;
uniform vec2 u_resolution;
uniform int u_int0; // Blend mode
uniform int u_int1; // Color tint
uniform float u_float0; // Intensity
uniform float u_float1; // Radius
uniform float u_float2; // Threshold
in vec2 v_texCoord;
out vec4 fragColor;
const int BLEND_ADD = 0;
const int BLEND_SCREEN = 1;
const int BLEND_SOFT = 2;
const int BLEND_OVERLAY = 3;
const int BLEND_LIGHTEN = 4;
const float GOLDEN_ANGLE = 2.39996323;
const int MAX_SAMPLES = 48;
const vec3 LUMA = vec3(0.299, 0.587, 0.114);
float hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
vec3 hexToRgb(int h) {
return vec3(
float((h >> 16) & 255),
float((h >> 8) & 255),
float(h & 255)
) * (1.0 / 255.0);
}
vec3 blend(vec3 base, vec3 glow, int mode) {
if (mode == BLEND_SCREEN) {
return 1.0 - (1.0 - base) * (1.0 - glow);
}
if (mode == BLEND_SOFT) {
return mix(
base - (1.0 - 2.0 * glow) * base * (1.0 - base),
base + (2.0 * glow - 1.0) * (sqrt(base) - base),
step(0.5, glow)
);
}
if (mode == BLEND_OVERLAY) {
return mix(
2.0 * base * glow,
1.0 - 2.0 * (1.0 - base) * (1.0 - glow),
step(0.5, base)
);
}
if (mode == BLEND_LIGHTEN) {
return max(base, glow);
}
return base + glow;
}
void main() {
vec4 original = texture(u_image0, v_texCoord);
float intensity = u_float0 * 0.05;
float radius = u_float1 * u_float1 * 0.012;
if (intensity < 0.001 || radius < 0.1) {
fragColor = original;
return;
}
float threshold = 1.0 - u_float2 * 0.01;
float t0 = threshold - 0.15;
float t1 = threshold + 0.15;
vec2 texelSize = 1.0 / u_resolution;
float radius2 = radius * radius;
float sampleScale = clamp(radius * 0.75, 0.35, 1.0);
int samples = int(float(MAX_SAMPLES) * sampleScale);
float noise = hash(gl_FragCoord.xy);
float angleOffset = noise * GOLDEN_ANGLE;
float radiusJitter = 0.85 + noise * 0.3;
float ca = cos(GOLDEN_ANGLE);
float sa = sin(GOLDEN_ANGLE);
vec2 dir = vec2(cos(angleOffset), sin(angleOffset));
vec3 glow = vec3(0.0);
float totalWeight = 0.0;
// Center tap
float centerMask = smoothstep(t0, t1, dot(original.rgb, LUMA));
glow += original.rgb * centerMask * 2.0;
totalWeight += 2.0;
for (int i = 1; i < MAX_SAMPLES; i++) {
if (i >= samples) break;
float fi = float(i);
float dist = sqrt(fi / float(samples)) * radius * radiusJitter;
vec2 offset = dir * dist * texelSize;
vec3 c = texture(u_image0, v_texCoord + offset).rgb;
float mask = smoothstep(t0, t1, dot(c, LUMA));
float w = 1.0 - (dist * dist) / (radius2 * 1.5);
w = max(w, 0.0);
w *= w;
glow += c * mask * w;
totalWeight += w;
dir = vec2(
dir.x * ca - dir.y * sa,
dir.x * sa + dir.y * ca
);
}
glow *= intensity / max(totalWeight, 0.001);
if (u_int1 > 0) {
glow *= hexToRgb(u_int1);
}
vec3 result = blend(original.rgb, glow, u_int0);
result += (noise - 0.5) * (1.0 / 255.0);
fragColor = vec4(clamp(result, 0.0, 1.0), original.a);
}
+222
View File
@@ -0,0 +1,222 @@
#version 300 es
precision highp float;
uniform sampler2D u_image0;
uniform int u_int0; // Mode: 0=Master, 1=Reds, 2=Yellows, 3=Greens, 4=Cyans, 5=Blues, 6=Magentas, 7=Colorize
uniform int u_int1; // Color Space: 0=HSL, 1=HSB/HSV
uniform float u_float0; // Hue (-180 to 180)
uniform float u_float1; // Saturation (-100 to 100)
uniform float u_float2; // Lightness/Brightness (-100 to 100)
uniform float u_float3; // Overlap (0 to 100) - feathering between adjacent color ranges
in vec2 v_texCoord;
out vec4 fragColor;
// Color range modes
const int MODE_MASTER = 0;
const int MODE_RED = 1;
const int MODE_YELLOW = 2;
const int MODE_GREEN = 3;
const int MODE_CYAN = 4;
const int MODE_BLUE = 5;
const int MODE_MAGENTA = 6;
const int MODE_COLORIZE = 7;
// Color space modes
const int COLORSPACE_HSL = 0;
const int COLORSPACE_HSB = 1;
const float EPSILON = 0.0001;
//=============================================================================
// RGB <-> HSL Conversions
//=============================================================================
vec3 rgb2hsl(vec3 c) {
float maxC = max(max(c.r, c.g), c.b);
float minC = min(min(c.r, c.g), c.b);
float delta = maxC - minC;
float h = 0.0;
float s = 0.0;
float l = (maxC + minC) * 0.5;
if (delta > EPSILON) {
s = l < 0.5
? delta / (maxC + minC)
: delta / (2.0 - maxC - minC);
if (maxC == c.r) {
h = (c.g - c.b) / delta + (c.g < c.b ? 6.0 : 0.0);
} else if (maxC == c.g) {
h = (c.b - c.r) / delta + 2.0;
} else {
h = (c.r - c.g) / delta + 4.0;
}
h /= 6.0;
}
return vec3(h, s, l);
}
float hue2rgb(float p, float q, float t) {
t = fract(t);
if (t < 1.0/6.0) return p + (q - p) * 6.0 * t;
if (t < 0.5) return q;
if (t < 2.0/3.0) return p + (q - p) * (2.0/3.0 - t) * 6.0;
return p;
}
vec3 hsl2rgb(vec3 hsl) {
if (hsl.y < EPSILON) return vec3(hsl.z);
float q = hsl.z < 0.5
? hsl.z * (1.0 + hsl.y)
: hsl.z + hsl.y - hsl.z * hsl.y;
float p = 2.0 * hsl.z - q;
return vec3(
hue2rgb(p, q, hsl.x + 1.0/3.0),
hue2rgb(p, q, hsl.x),
hue2rgb(p, q, hsl.x - 1.0/3.0)
);
}
vec3 rgb2hsb(vec3 c) {
float maxC = max(max(c.r, c.g), c.b);
float minC = min(min(c.r, c.g), c.b);
float delta = maxC - minC;
float h = 0.0;
float s = (maxC > EPSILON) ? delta / maxC : 0.0;
float b = maxC;
if (delta > EPSILON) {
if (maxC == c.r) {
h = (c.g - c.b) / delta + (c.g < c.b ? 6.0 : 0.0);
} else if (maxC == c.g) {
h = (c.b - c.r) / delta + 2.0;
} else {
h = (c.r - c.g) / delta + 4.0;
}
h /= 6.0;
}
return vec3(h, s, b);
}
vec3 hsb2rgb(vec3 hsb) {
vec3 rgb = clamp(abs(mod(hsb.x * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0);
return hsb.z * mix(vec3(1.0), rgb, hsb.y);
}
//=============================================================================
// Color Range Weight Calculation
//=============================================================================
float hueDistance(float a, float b) {
float d = abs(a - b);
return min(d, 1.0 - d);
}
float getHueWeight(float hue, float center, float overlap) {
float baseWidth = 1.0 / 6.0;
float feather = baseWidth * overlap;
float d = hueDistance(hue, center);
float inner = baseWidth * 0.5;
float outer = inner + feather;
return 1.0 - smoothstep(inner, outer, d);
}
float getModeWeight(float hue, int mode, float overlap) {
if (mode == MODE_MASTER || mode == MODE_COLORIZE) return 1.0;
if (mode == MODE_RED) {
return max(
getHueWeight(hue, 0.0, overlap),
getHueWeight(hue, 1.0, overlap)
);
}
float center = float(mode - 1) / 6.0;
return getHueWeight(hue, center, overlap);
}
//=============================================================================
// Adjustment Functions
//=============================================================================
float adjustLightness(float l, float amount) {
return amount > 0.0
? l + (1.0 - l) * amount
: l + l * amount;
}
float adjustBrightness(float b, float amount) {
return clamp(b + amount, 0.0, 1.0);
}
float adjustSaturation(float s, float amount) {
return amount > 0.0
? s + (1.0 - s) * amount
: s + s * amount;
}
vec3 colorize(vec3 rgb, float hue, float sat, float light) {
float lum = dot(rgb, vec3(0.299, 0.587, 0.114));
float l = adjustLightness(lum, light);
vec3 hsl = vec3(fract(hue), clamp(sat, 0.0, 1.0), clamp(l, 0.0, 1.0));
return hsl2rgb(hsl);
}
//=============================================================================
// Main
//=============================================================================
void main() {
vec4 original = texture(u_image0, v_texCoord);
float hueShift = u_float0 / 360.0; // -180..180 -> -0.5..0.5
float satAmount = u_float1 / 100.0; // -100..100 -> -1..1
float lightAmount= u_float2 / 100.0; // -100..100 -> -1..1
float overlap = u_float3 / 100.0; // 0..100 -> 0..1
vec3 result;
if (u_int0 == MODE_COLORIZE) {
result = colorize(original.rgb, hueShift, satAmount, lightAmount);
fragColor = vec4(result, original.a);
return;
}
vec3 hsx = (u_int1 == COLORSPACE_HSL)
? rgb2hsl(original.rgb)
: rgb2hsb(original.rgb);
float weight = getModeWeight(hsx.x, u_int0, overlap);
if (u_int0 != MODE_MASTER && hsx.y < EPSILON) {
weight = 0.0;
}
if (weight > EPSILON) {
float h = fract(hsx.x + hueShift * weight);
float s = clamp(adjustSaturation(hsx.y, satAmount * weight), 0.0, 1.0);
float v = (u_int1 == COLORSPACE_HSL)
? clamp(adjustLightness(hsx.z, lightAmount * weight), 0.0, 1.0)
: clamp(adjustBrightness(hsx.z, lightAmount * weight), 0.0, 1.0);
vec3 adjusted = vec3(h, s, v);
result = (u_int1 == COLORSPACE_HSL)
? hsl2rgb(adjusted)
: hsb2rgb(adjusted);
} else {
result = original.rgb;
}
fragColor = vec4(result, original.a);
}
+111
View File
@@ -0,0 +1,111 @@
#version 300 es
#pragma passes 2
precision highp float;
// Blur type constants
const int BLUR_GAUSSIAN = 0;
const int BLUR_BOX = 1;
const int BLUR_RADIAL = 2;
// Radial blur config
const int RADIAL_SAMPLES = 12;
const float RADIAL_STRENGTH = 0.0003;
uniform sampler2D u_image0;
uniform vec2 u_resolution;
uniform int u_int0; // Blur type (BLUR_GAUSSIAN, BLUR_BOX, BLUR_RADIAL)
uniform float u_float0; // Blur radius/amount
uniform int u_pass; // Pass index (0 = horizontal, 1 = vertical)
in vec2 v_texCoord;
layout(location = 0) out vec4 fragColor0;
float gaussian(float x, float sigma) {
return exp(-(x * x) / (2.0 * sigma * sigma));
}
void main() {
vec2 texelSize = 1.0 / u_resolution;
float radius = max(u_float0, 0.0);
// Radial (angular) blur - single pass, doesn't use separable
if (u_int0 == BLUR_RADIAL) {
// Only execute on first pass
if (u_pass > 0) {
fragColor0 = texture(u_image0, v_texCoord);
return;
}
vec2 center = vec2(0.5);
vec2 dir = v_texCoord - center;
float dist = length(dir);
if (dist < 1e-4) {
fragColor0 = texture(u_image0, v_texCoord);
return;
}
vec4 sum = vec4(0.0);
float totalWeight = 0.0;
float angleStep = radius * RADIAL_STRENGTH;
dir /= dist;
float cosStep = cos(angleStep);
float sinStep = sin(angleStep);
float negAngle = -float(RADIAL_SAMPLES) * angleStep;
vec2 rotDir = vec2(
dir.x * cos(negAngle) - dir.y * sin(negAngle),
dir.x * sin(negAngle) + dir.y * cos(negAngle)
);
for (int i = -RADIAL_SAMPLES; i <= RADIAL_SAMPLES; i++) {
vec2 uv = center + rotDir * dist;
float w = 1.0 - abs(float(i)) / float(RADIAL_SAMPLES);
sum += texture(u_image0, uv) * w;
totalWeight += w;
rotDir = vec2(
rotDir.x * cosStep - rotDir.y * sinStep,
rotDir.x * sinStep + rotDir.y * cosStep
);
}
fragColor0 = sum / max(totalWeight, 0.001);
return;
}
// Separable Gaussian / Box blur
int samples = int(ceil(radius));
if (samples == 0) {
fragColor0 = texture(u_image0, v_texCoord);
return;
}
// Direction: pass 0 = horizontal, pass 1 = vertical
vec2 dir = (u_pass == 0) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec4 color = vec4(0.0);
float totalWeight = 0.0;
float sigma = radius / 2.0;
for (int i = -samples; i <= samples; i++) {
vec2 offset = dir * float(i) * texelSize;
vec4 sample_color = texture(u_image0, v_texCoord + offset);
float weight;
if (u_int0 == BLUR_GAUSSIAN) {
weight = gaussian(float(i), sigma);
} else {
// BLUR_BOX
weight = 1.0;
}
color += sample_color * weight;
totalWeight += weight;
}
fragColor0 = color / totalWeight;
}
+19
View File
@@ -0,0 +1,19 @@
#version 300 es
precision highp float;
uniform sampler2D u_image0;
in vec2 v_texCoord;
layout(location = 0) out vec4 fragColor0;
layout(location = 1) out vec4 fragColor1;
layout(location = 2) out vec4 fragColor2;
layout(location = 3) out vec4 fragColor3;
void main() {
vec4 color = texture(u_image0, v_texCoord);
// Output each channel as grayscale to separate render targets
fragColor0 = vec4(vec3(color.r), 1.0); // Red channel
fragColor1 = vec4(vec3(color.g), 1.0); // Green channel
fragColor2 = vec4(vec3(color.b), 1.0); // Blue channel
fragColor3 = vec4(vec3(color.a), 1.0); // Alpha channel
}
+71
View File
@@ -0,0 +1,71 @@
#version 300 es
precision highp float;
// Levels Adjustment
// u_int0: channel (0=RGB, 1=R, 2=G, 3=B) default: 0
// u_float0: input black (0-255) default: 0
// u_float1: input white (0-255) default: 255
// u_float2: gamma (0.01-9.99) default: 1.0
// u_float3: output black (0-255) default: 0
// u_float4: output white (0-255) default: 255
uniform sampler2D u_image0;
uniform int u_int0;
uniform float u_float0;
uniform float u_float1;
uniform float u_float2;
uniform float u_float3;
uniform float u_float4;
in vec2 v_texCoord;
out vec4 fragColor;
vec3 applyLevels(vec3 color, float inBlack, float inWhite, float gamma, float outBlack, float outWhite) {
float inRange = max(inWhite - inBlack, 0.0001);
vec3 result = clamp((color - inBlack) / inRange, 0.0, 1.0);
result = pow(result, vec3(1.0 / gamma));
result = mix(vec3(outBlack), vec3(outWhite), result);
return result;
}
float applySingleChannel(float value, float inBlack, float inWhite, float gamma, float outBlack, float outWhite) {
float inRange = max(inWhite - inBlack, 0.0001);
float result = clamp((value - inBlack) / inRange, 0.0, 1.0);
result = pow(result, 1.0 / gamma);
result = mix(outBlack, outWhite, result);
return result;
}
void main() {
vec4 texColor = texture(u_image0, v_texCoord);
vec3 color = texColor.rgb;
float inBlack = u_float0 / 255.0;
float inWhite = u_float1 / 255.0;
float gamma = u_float2;
float outBlack = u_float3 / 255.0;
float outWhite = u_float4 / 255.0;
vec3 result;
if (u_int0 == 0) {
result = applyLevels(color, inBlack, inWhite, gamma, outBlack, outWhite);
}
else if (u_int0 == 1) {
result = color;
result.r = applySingleChannel(color.r, inBlack, inWhite, gamma, outBlack, outWhite);
}
else if (u_int0 == 2) {
result = color;
result.g = applySingleChannel(color.g, inBlack, inWhite, gamma, outBlack, outWhite);
}
else if (u_int0 == 3) {
result = color;
result.b = applySingleChannel(color.b, inBlack, inWhite, gamma, outBlack, outWhite);
}
else {
result = color;
}
fragColor = vec4(result, texColor.a);
}
+28
View File
@@ -0,0 +1,28 @@
# GLSL Shader Sources
This folder contains the GLSL fragment shaders extracted from blueprint JSON files for easier editing and version control.
## File Naming Convention
`{Blueprint_Name}_{node_id}.frag`
- **Blueprint_Name**: The JSON filename with spaces/special chars replaced by underscores
- **node_id**: The GLSLShader node ID within the subgraph
## Usage
```bash
# Extract shaders from blueprint JSONs to this folder
python update_blueprints.py extract
# Patch edited shaders back into blueprint JSONs
python update_blueprints.py patch
```
## Workflow
1. Run `extract` to pull current shaders from JSONs
2. Edit `.frag` files
3. Run `patch` to update the blueprint JSONs
4. Test
5. Commit both `.frag` files and updated JSONs
+28
View File
@@ -0,0 +1,28 @@
#version 300 es
precision highp float;
uniform sampler2D u_image0;
uniform vec2 u_resolution;
uniform float u_float0; // strength [0.0 2.0] typical: 0.31.0
in vec2 v_texCoord;
layout(location = 0) out vec4 fragColor0;
void main() {
vec2 texel = 1.0 / u_resolution;
// Sample center and neighbors
vec4 center = texture(u_image0, v_texCoord);
vec4 top = texture(u_image0, v_texCoord + vec2( 0.0, -texel.y));
vec4 bottom = texture(u_image0, v_texCoord + vec2( 0.0, texel.y));
vec4 left = texture(u_image0, v_texCoord + vec2(-texel.x, 0.0));
vec4 right = texture(u_image0, v_texCoord + vec2( texel.x, 0.0));
// Edge enhancement (Laplacian)
vec4 edges = center * 4.0 - top - bottom - left - right;
// Add edges back scaled by strength
vec4 sharpened = center + edges * u_float0;
fragColor0 = vec4(clamp(sharpened.rgb, 0.0, 1.0), center.a);
}
+61
View File
@@ -0,0 +1,61 @@
#version 300 es
precision highp float;
uniform sampler2D u_image0;
uniform vec2 u_resolution;
uniform float u_float0; // amount [0.0 - 3.0] typical: 0.5-1.5
uniform float u_float1; // radius [0.5 - 10.0] blur radius in pixels
uniform float u_float2; // threshold [0.0 - 0.1] min difference to sharpen
in vec2 v_texCoord;
layout(location = 0) out vec4 fragColor0;
float gaussian(float x, float sigma) {
return exp(-(x * x) / (2.0 * sigma * sigma));
}
float getLuminance(vec3 color) {
return dot(color, vec3(0.2126, 0.7152, 0.0722));
}
void main() {
vec2 texel = 1.0 / u_resolution;
float radius = max(u_float1, 0.5);
float amount = u_float0;
float threshold = u_float2;
vec4 original = texture(u_image0, v_texCoord);
// Gaussian blur for the "unsharp" mask
int samples = int(ceil(radius));
float sigma = radius / 2.0;
vec4 blurred = vec4(0.0);
float totalWeight = 0.0;
for (int x = -samples; x <= samples; x++) {
for (int y = -samples; y <= samples; y++) {
vec2 offset = vec2(float(x), float(y)) * texel;
vec4 sample_color = texture(u_image0, v_texCoord + offset);
float dist = length(vec2(float(x), float(y)));
float weight = gaussian(dist, sigma);
blurred += sample_color * weight;
totalWeight += weight;
}
}
blurred /= totalWeight;
// Unsharp mask = original - blurred
vec3 mask = original.rgb - blurred.rgb;
// Luminance-based threshold with smooth falloff
float lumaDelta = abs(getLuminance(original.rgb) - getLuminance(blurred.rgb));
float thresholdScale = smoothstep(0.0, threshold, lumaDelta);
mask *= thresholdScale;
// Sharpen: original + mask * amount
vec3 sharpened = original.rgb + mask * amount;
fragColor0 = vec4(clamp(sharpened, 0.0, 1.0), original.a);
}
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""
Shader Blueprint Updater
Syncs GLSL shader files between this folder and blueprint JSON files.
File naming convention:
{Blueprint Name}_{node_id}.frag
Usage:
python update_blueprints.py extract # Extract shaders from JSONs to here
python update_blueprints.py patch # Patch shaders back into JSONs
python update_blueprints.py # Same as patch (default)
"""
import json
import logging
import sys
import re
from pathlib import Path
logging.basicConfig(level=logging.INFO, format='%(message)s')
logger = logging.getLogger(__name__)
GLSL_DIR = Path(__file__).parent
BLUEPRINTS_DIR = GLSL_DIR.parent
def get_blueprint_files():
"""Get all blueprint JSON files."""
return sorted(BLUEPRINTS_DIR.glob("*.json"))
def sanitize_filename(name):
"""Convert blueprint name to safe filename."""
return re.sub(r'[^\w\-]', '_', name)
def extract_shaders():
"""Extract all shaders from blueprint JSONs to this folder."""
extracted = 0
for json_path in get_blueprint_files():
blueprint_name = json_path.stem
try:
with open(json_path, 'r') as f:
data = json.load(f)
except (json.JSONDecodeError, IOError) as e:
logger.warning("Skipping %s: %s", json_path.name, e)
continue
# Find GLSLShader nodes in subgraphs
for subgraph in data.get('definitions', {}).get('subgraphs', []):
for node in subgraph.get('nodes', []):
if node.get('type') == 'GLSLShader':
node_id = node.get('id')
widgets = node.get('widgets_values', [])
# Find shader code (first string that looks like GLSL)
for widget in widgets:
if isinstance(widget, str) and widget.startswith('#version'):
safe_name = sanitize_filename(blueprint_name)
frag_name = f"{safe_name}_{node_id}.frag"
frag_path = GLSL_DIR / frag_name
with open(frag_path, 'w') as f:
f.write(widget)
logger.info(" Extracted: %s", frag_name)
extracted += 1
break
logger.info("\nExtracted %d shader(s)", extracted)
def patch_shaders():
"""Patch shaders from this folder back into blueprint JSONs."""
# Build lookup: blueprint_name -> [(node_id, shader_code), ...]
shader_updates = {}
for frag_path in sorted(GLSL_DIR.glob("*.frag")):
# Parse filename: {blueprint_name}_{node_id}.frag
parts = frag_path.stem.rsplit('_', 1)
if len(parts) != 2:
logger.warning("Skipping %s: invalid filename format", frag_path.name)
continue
blueprint_name, node_id_str = parts
try:
node_id = int(node_id_str)
except ValueError:
logger.warning("Skipping %s: invalid node_id", frag_path.name)
continue
with open(frag_path, 'r') as f:
shader_code = f.read()
if blueprint_name not in shader_updates:
shader_updates[blueprint_name] = []
shader_updates[blueprint_name].append((node_id, shader_code))
# Apply updates to JSON files
patched = 0
for json_path in get_blueprint_files():
blueprint_name = sanitize_filename(json_path.stem)
if blueprint_name not in shader_updates:
continue
try:
with open(json_path, 'r') as f:
data = json.load(f)
except (json.JSONDecodeError, IOError) as e:
logger.error("Error reading %s: %s", json_path.name, e)
continue
modified = False
for node_id, shader_code in shader_updates[blueprint_name]:
# Find the node and update
for subgraph in data.get('definitions', {}).get('subgraphs', []):
for node in subgraph.get('nodes', []):
if node.get('id') == node_id and node.get('type') == 'GLSLShader':
widgets = node.get('widgets_values', [])
if len(widgets) > 0 and widgets[0] != shader_code:
widgets[0] = shader_code
modified = True
logger.info(" Patched: %s (node %d)", json_path.name, node_id)
patched += 1
if modified:
with open(json_path, 'w') as f:
json.dump(data, f)
if patched == 0:
logger.info("No changes to apply.")
else:
logger.info("\nPatched %d shader(s)", patched)
def main():
if len(sys.argv) < 2:
command = "patch"
else:
command = sys.argv[1].lower()
if command == "extract":
logger.info("Extracting shaders from blueprints...")
extract_shaders()
elif command in ("patch", "update", "apply"):
logger.info("Patching shaders into blueprints...")
patch_shaders()
else:
logger.info(__doc__)
sys.exit(1)
if __name__ == "__main__":
main()
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"revision": 0, "last_node_id": 29, "last_link_id": 0, "nodes": [{"id": 29, "type": "4c9d6ea4-b912-40e5-8766-6793a9758c53", "pos": [1970, -230], "size": [180, 86], "flags": {}, "order": 5, "mode": 0, "inputs": [{"label": "image", "localized_name": "images.image0", "name": "images.image0", "type": "IMAGE", "link": null}], "outputs": [{"label": "R", "localized_name": "IMAGE0", "name": "IMAGE0", "type": "IMAGE", "links": []}, {"label": "G", "localized_name": "IMAGE1", "name": "IMAGE1", "type": "IMAGE", "links": []}, {"label": "B", "localized_name": "IMAGE2", "name": "IMAGE2", "type": "IMAGE", "links": []}, {"label": "A", "localized_name": "IMAGE3", "name": "IMAGE3", "type": "IMAGE", "links": []}], "title": "Image Channels", "properties": {"proxyWidgets": []}, "widgets_values": []}], "links": [], "version": 0.4, "definitions": {"subgraphs": [{"id": "4c9d6ea4-b912-40e5-8766-6793a9758c53", "version": 1, "state": {"lastGroupId": 0, "lastNodeId": 28, "lastLinkId": 39, "lastRerouteId": 0}, "revision": 0, "config": {}, "name": "Image Channels", "inputNode": {"id": -10, "bounding": [1820, -185, 120, 60]}, "outputNode": {"id": -20, "bounding": [2460, -215, 120, 120]}, "inputs": [{"id": "3522932b-2d86-4a1f-a02a-cb29f3a9d7fe", "name": "images.image0", "type": "IMAGE", "linkIds": [39], "localized_name": "images.image0", "label": "image", "pos": [1920, -165]}], "outputs": [{"id": "605cb9c3-b065-4d9b-81d2-3ec331889b2b", "name": "IMAGE0", "type": "IMAGE", "linkIds": [26], "localized_name": "IMAGE0", "label": "R", "pos": [2480, -195]}, {"id": "fb44a77e-0522-43e9-9527-82e7465b3596", "name": "IMAGE1", "type": "IMAGE", "linkIds": [27], "localized_name": "IMAGE1", "label": "G", "pos": [2480, -175]}, {"id": "81460ee6-0131-402a-874f-6bf3001fc4ff", "name": "IMAGE2", "type": "IMAGE", "linkIds": [28], "localized_name": "IMAGE2", "label": "B", "pos": [2480, -155]}, {"id": "ae690246-80d4-4951-b1d9-9306d8a77417", "name": "IMAGE3", "type": "IMAGE", "linkIds": [29], "localized_name": "IMAGE3", "label": "A", "pos": [2480, -135]}], "widgets": [], "nodes": [{"id": 23, "type": "GLSLShader", "pos": [2000, -330], "size": [400, 172], "flags": {}, "order": 0, "mode": 0, "inputs": [{"label": "image", "localized_name": "images.image0", "name": "images.image0", "type": "IMAGE", "link": 39}, {"localized_name": "fragment_shader", "name": "fragment_shader", "type": "STRING", "widget": {"name": "fragment_shader"}, "link": null}, {"localized_name": "size_mode", "name": "size_mode", "type": "COMFY_DYNAMICCOMBO_V3", "widget": {"name": "size_mode"}, "link": null}, {"label": "image1", "localized_name": "images.image1", "name": "images.image1", "shape": 7, "type": "IMAGE", "link": null}], "outputs": [{"label": "R", "localized_name": "IMAGE0", "name": "IMAGE0", "type": "IMAGE", "links": [26]}, {"label": "G", "localized_name": "IMAGE1", "name": "IMAGE1", "type": "IMAGE", "links": [27]}, {"label": "B", "localized_name": "IMAGE2", "name": "IMAGE2", "type": "IMAGE", "links": [28]}, {"label": "A", "localized_name": "IMAGE3", "name": "IMAGE3", "type": "IMAGE", "links": [29]}], "properties": {"Node name for S&R": "GLSLShader"}, "widgets_values": ["#version 300 es\nprecision highp float;\n\nuniform sampler2D u_image0;\n\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nlayout(location = 1) out vec4 fragColor1;\nlayout(location = 2) out vec4 fragColor2;\nlayout(location = 3) out vec4 fragColor3;\n\nvoid main() {\n vec4 color = texture(u_image0, v_texCoord);\n // Output each channel as grayscale to separate render targets\n fragColor0 = vec4(vec3(color.r), 1.0); // Red channel\n fragColor1 = vec4(vec3(color.g), 1.0); // Green channel\n fragColor2 = vec4(vec3(color.b), 1.0); // Blue channel\n fragColor3 = vec4(vec3(color.a), 1.0); // Alpha channel\n}\n", "from_input"]}], "groups": [], "links": [{"id": 39, "origin_id": -10, "origin_slot": 0, "target_id": 23, "target_slot": 0, "type": "IMAGE"}, {"id": 26, "origin_id": 23, "origin_slot": 0, "target_id": -20, "target_slot": 0, "type": "IMAGE"}, {"id": 27, "origin_id": 23, "origin_slot": 1, "target_id": -20, "target_slot": 1, "type": "IMAGE"}, {"id": 28, "origin_id": 23, "origin_slot": 2, "target_id": -20, "target_slot": 2, "type": "IMAGE"}, {"id": 29, "origin_id": 23, "origin_slot": 3, "target_id": -20, "target_slot": 3, "type": "IMAGE"}], "extra": {"workflowRendererVersion": "LG"}, "category": "Image Tools/Color adjust"}]}}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

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