Compare commits
78
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70d2068264 | ||
|
|
722ea7fd39 | ||
|
|
845ddd9dae | ||
|
|
f569319988 | ||
|
|
c249ac41a4 | ||
|
|
268857c16e | ||
|
|
a9223e59e8 | ||
|
|
10eec303fd | ||
|
|
b07530398d | ||
|
|
0d307dceb2 | ||
|
|
1b713f904c | ||
|
|
c969641d7d | ||
|
|
b7dff20861 | ||
|
|
e696fb7480 | ||
|
|
b3f20495f8 | ||
|
|
179b577add | ||
|
|
c2336e0c1e | ||
|
|
0538abf199 | ||
|
|
7eefdcd206 | ||
|
|
b238b8c568 | ||
|
|
e97194613c | ||
|
|
b31a1ea3e2 | ||
|
|
dd21fd9f85 | ||
|
|
109cd04a54 | ||
|
|
552f47b844 | ||
|
|
b0d1b06a6f | ||
|
|
023367bfec | ||
|
|
b2121abc71 | ||
|
|
0a3146e00f | ||
|
|
8e55ecac9a | ||
|
|
b6317999dd | ||
|
|
5e9f72bc16 | ||
|
|
acd3bcc99c | ||
|
|
2ea07a2678 | ||
|
|
1dba6abcbf | ||
|
|
cc352ba658 | ||
|
|
3bb37419b5 | ||
|
|
5c9215afe3 | ||
|
|
82a8487e7b | ||
|
|
ef128fadcc | ||
|
|
35f4a0328c | ||
|
|
9978f26e25 | ||
|
|
f8007e6ced | ||
|
|
5ed1dacbdc | ||
|
|
4200c52000 | ||
|
|
1d67adb0fb | ||
|
|
50c338ecfd | ||
|
|
203375e5db | ||
|
|
cea869bd14 | ||
|
|
ace9d057f8 | ||
|
|
4d98076483 | ||
|
|
7ff75c0cce | ||
|
|
28ed5bd9d1 | ||
|
|
fd02d0f6b9 | ||
|
|
b24a2a12c1 | ||
|
|
49d300aba3 | ||
|
|
929671031b | ||
|
|
c443d56f5c | ||
|
|
43b7c60325 | ||
|
|
d774d3f508 | ||
|
|
40c26e3080 | ||
|
|
1063ee3d0e | ||
|
|
0337677c77 | ||
|
|
0e774c87b6 | ||
|
|
201cfaf2f3 | ||
|
|
b289513c9d | ||
|
|
e56eee9dc6 | ||
|
|
a9165125b5 | ||
|
|
a9d77be4e7 | ||
|
|
b2e96ef256 | ||
|
|
55d5c7a58e | ||
|
|
fef673ade4 | ||
|
|
c0bd700407 | ||
|
|
8e6423dc30 | ||
|
|
e46945427d | ||
|
|
57bb837d35 | ||
|
|
99c3d53d8b | ||
|
|
4ab38a1f06 |
+8
-2
@@ -1,7 +1,11 @@
|
||||
models/
|
||||
output/
|
||||
input/
|
||||
custom_nodes/
|
||||
custom_nodes/*
|
||||
# ...but the pack manifest must reach the build context so the image can vendor
|
||||
# the packs reproducibly (scripts/install_custom_nodes.sh clones them at build).
|
||||
# Local clones stay excluded so the image builds them fresh from the manifest.
|
||||
!custom_nodes/hanzo-packs.txt
|
||||
user/
|
||||
temp/
|
||||
orgs/
|
||||
@@ -19,7 +23,9 @@ venv/
|
||||
.ruff_cache/
|
||||
tests/
|
||||
tests-unit/
|
||||
scripts/
|
||||
scripts/*
|
||||
# ...except the pack installer, which the image build invokes.
|
||||
!scripts/install_custom_nodes.sh
|
||||
notebooks/
|
||||
*.swp
|
||||
.env
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
|
||||
jobs:
|
||||
check-line-endings:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
use_prior_commit: 'true'
|
||||
comment:
|
||||
if: ${{ github.event.label.name == 'Run-CI-Test' }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
|
||||
jobs:
|
||||
send-webhook:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
env:
|
||||
DESKTOP_REPO_DISPATCH_TOKEN: ${{ secrets.DESKTOP_REPO_DISPATCH_TOKEN }}
|
||||
steps:
|
||||
|
||||
@@ -5,7 +5,7 @@ on: [push, pull_request]
|
||||
jobs:
|
||||
ruff:
|
||||
name: Run Ruff
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
pylint:
|
||||
name: Run Pylint
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -8,7 +8,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
steps:
|
||||
- name: Checkout Hanzo Studio
|
||||
uses: actions/checkout@v4
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
- name: Start Hanzo Studio server
|
||||
run: |
|
||||
python main.py --cpu 2>&1 | tee console_output.log &
|
||||
wait-for-it --service 127.0.0.1:8188 -t 30
|
||||
wait-for-it --service 127.0.0.1:8188 -t 120
|
||||
working-directory: studio
|
||||
- name: Check for unhandled exceptions in server log
|
||||
run: |
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
generate-models:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -12,7 +12,7 @@ on:
|
||||
|
||||
jobs:
|
||||
update-ci-container:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
# Skip pre-releases unless manually triggered
|
||||
if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease
|
||||
steps:
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
|
||||
jobs:
|
||||
update-version:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
# Don't run on fork PRs
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
permissions:
|
||||
|
||||
+7
-2
@@ -5,8 +5,12 @@ __pycache__/
|
||||
!/input/example.png
|
||||
/models/
|
||||
/temp/
|
||||
/custom_nodes/
|
||||
!custom_nodes/example_node.py.example
|
||||
# Ignore vendored pack contents but keep the directory listable so the tracked
|
||||
# files below can be re-included (a file under a fully-excluded dir cannot be).
|
||||
/custom_nodes/*
|
||||
!/custom_nodes/example_node.py.example
|
||||
# Pinned pack manifest is tracked; the pack sources it clones are not (vendored at build).
|
||||
!/custom_nodes/hanzo-packs.txt
|
||||
extra_model_paths.yaml
|
||||
/.vs
|
||||
.vscode/
|
||||
@@ -24,3 +28,4 @@ web_custom_versions/
|
||||
openapi.yaml
|
||||
filtered-openapi.yaml
|
||||
uv.lock
|
||||
/custom_nodes_quarantine/
|
||||
|
||||
+34
@@ -25,9 +25,43 @@ RUN chmod +x /tmp/branding/apply-branding.sh && /tmp/branding/apply-branding.sh
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Vendor the Hanzo Studio custom-node packs (segmentation, controlnet, ipadapter,
|
||||
# upscaling, video, utils) at their pinned commits and install their deps with the
|
||||
# torch/numpy/transformers stack locked. See custom_nodes/hanzo-packs.txt.
|
||||
RUN chmod +x scripts/install_custom_nodes.sh && scripts/install_custom_nodes.sh
|
||||
|
||||
# Create directories for runtime volumes
|
||||
RUN mkdir -p models output input custom_nodes user
|
||||
|
||||
# /app/orgs is the tenant-data PVC; /app/models is ephemeral overlay. Register
|
||||
# .models on the PVC as the default model store (main.py auto-loads this file)
|
||||
# so models survive pod restarts and downloads land durable.
|
||||
RUN printf '%s\n' \
|
||||
'hanzo:' \
|
||||
' base_path: /app/orgs/.models' \
|
||||
' is_default: true' \
|
||||
' checkpoints: checkpoints' \
|
||||
' configs: configs' \
|
||||
' loras: loras' \
|
||||
' vae: vae' \
|
||||
' text_encoders: text_encoders' \
|
||||
' diffusion_models: diffusion_models' \
|
||||
' clip_vision: clip_vision' \
|
||||
' style_models: style_models' \
|
||||
' embeddings: embeddings' \
|
||||
' diffusers: diffusers' \
|
||||
' vae_approx: vae_approx' \
|
||||
' controlnet: controlnet' \
|
||||
' gligen: gligen' \
|
||||
' upscale_models: upscale_models' \
|
||||
' latent_upscale_models: latent_upscale_models' \
|
||||
' hypernetworks: hypernetworks' \
|
||||
' photomaker: photomaker' \
|
||||
' classifiers: classifiers' \
|
||||
' model_patches: model_patches' \
|
||||
' audio_encoders: audio_encoders' \
|
||||
> extra_model_paths.yaml
|
||||
|
||||
EXPOSE 8188
|
||||
|
||||
# Default: listen on all interfaces, CPU mode
|
||||
|
||||
@@ -38,6 +38,41 @@ studio/
|
||||
- `Dockerfile` -- Container build (applies `branding/` then runs `main.py`)
|
||||
- `hanzo.yml` + `.github/workflows/cicd.yml` -- canonical CI/CD (hanzoai/ci); builds `ghcr.io/hanzoai/studio`, rolls the `studio` operator Service CR at `studio.hanzo.ai`.
|
||||
|
||||
## Custom-node packs ("hanzo packages")
|
||||
The popular ComfyUI packs are vendored as first-class hanzo packages, pinned and
|
||||
reproducible. One and only one way to add/bump them:
|
||||
|
||||
- **Manifest**: `custom_nodes/hanzo-packs.txt` — `<dir> <git_url> <commit_sha>
|
||||
<spdx>` lines (immutable SHAs). This is the tracked source of truth; the cloned
|
||||
pack sources are gitignored (vendored at build).
|
||||
- **Deps**: `custom_nodes-requirements.txt` — hand-resolved, only what's not
|
||||
already in `requirements.txt`. Installed with the studio torch(cu130)/NumPy 2.x/
|
||||
Transformers 5.x stack **locked** so packs can never downgrade it.
|
||||
- **Installer**: `scripts/install_custom_nodes.sh` clones each pack at its pin and
|
||||
installs deps; the lock is derived from the live env (correct for both the CPU
|
||||
image and the GB10 cu130 venv). `ultralytics` is installed `--no-deps` (else it
|
||||
pulls non-headless opencv). Local (GB10): `STUDIO_PY=.venv/bin/python
|
||||
STUDIO_PIP="uv pip install --python .venv/bin/python" scripts/install_custom_nodes.sh`.
|
||||
- **Image**: `Dockerfile` runs the installer after `COPY . .`; `.dockerignore`
|
||||
re-includes only the manifest + installer (`custom_nodes/*` / `scripts/*` form).
|
||||
So `git push origin main` → CI builds `ghcr.io/hanzoai/studio` with the packs →
|
||||
studio.hanzo.ai gets them on the next image build.
|
||||
|
||||
**Compat shim — why packs work here at all.** This fork renamed ComfyUI's core
|
||||
packages (`comfy`→`studio`, `comfy_extras`→`studio_extras`, `comfy/comfy_types`→
|
||||
`studio/node_types`, and the `Comfy*`→`Studio*` API classes; see
|
||||
`scripts/rename_modules.py`). `studio_compat/` is one meta-path finder that lazily
|
||||
aliases every `comfy*` import to its `studio*` counterpart and mirrors
|
||||
`ComfyExtension`/`io.ComfyNode`→`Studio*` — so upstream packs run **unmodified**,
|
||||
no forks/renames. It is the phase-4 redirect `rename_modules.py` deferred, done
|
||||
with the 3.12 `find_spec` API. Activated by `import studio_compat` at the top of
|
||||
`main.py`. `nodes.py` also honors the upstream `comfy_entrypoint` V3 name.
|
||||
|
||||
Restart the local worker only when idle: `scripts/studio-service-swap.sh` polls
|
||||
`/queue` and restarts on the first empty window. Bad packs are isolated by the
|
||||
loader (studio still boots); genuinely-incompatible ones go to
|
||||
`custom_nodes_quarantine/` (e.g. was-node-suite: hard `numba` dep vs NumPy 2.5).
|
||||
|
||||
## Multi-tenant + IAM (cloud: studio.hanzo.ai)
|
||||
Standalone app; `console.hanzo.ai` link-outs land here already authenticated via
|
||||
shared Hanzo IAM (standard OIDC code flow). No embedding.
|
||||
@@ -60,13 +95,97 @@ shared Hanzo IAM (standard OIDC code flow). No embedding.
|
||||
the prompt worker from the queue item's `org_id`.
|
||||
- **GPU federation** (`middleware/{worker_client,prompt_router,compute_config,
|
||||
visor_client}.py`): `/v1/workers/register` (heartbeat), `/v1/workers` (list),
|
||||
`/v1/worker/execute` (push). Worker↔coordinator trust via
|
||||
`X-Worker-Token` (`STUDIO_WORKER_TOKEN`, KMS-sourced). NAT'd local boxes (GB10)
|
||||
join outbound — pull channel is specced in `docs/federation.md`.
|
||||
`/v1/worker/execute` (in-cluster push fast path). Worker↔coordinator trust via
|
||||
`X-Worker-Token` (`STUDIO_WORKER_TOKEN`, KMS-sourced).
|
||||
- **Durable render queue** (`middleware/tasks_queue.py`, `docs/federation.md`):
|
||||
`SqlitePromptQueue` — crash-durable render queue in a single SQLite file
|
||||
(stdlib `sqlite3`, WAL, **zero external processes**). `STUDIO_QUEUE_DB=<path>`
|
||||
→ `server.py` swaps `PromptQueue` for it (precedence **STUDIO_QUEUE_DB >
|
||||
STUDIO_PERSIST_QUEUE > memory**; default local behavior unchanged). The
|
||||
`PromptQueue` seam maps to a durable job state machine: `put→INSERT (pending,
|
||||
idempotent on prompt_id)`, `get→claim next pending under BEGIN IMMEDIATE
|
||||
(exactly-once, leased)`, `task_done→done | retry/failed`. Crash-recovery: a
|
||||
lease + reap returns an abandoned claim to `pending` (on every get(), a
|
||||
heartbeat thread, and on boot) so another process re-runs it — idempotent
|
||||
(SaveImage suffixes increment). Multiple Studio processes on the same DB file
|
||||
compete safely. Tested: `middleware_test/tasks_queue_test.py` (incl.
|
||||
crash-recovery across instances). Future (federation.md §6): a remote queue
|
||||
backend served by Hanzo Tasks + a Go/unified-binary (HIP-0106) migration.
|
||||
- **Engine selector** (`middleware/engine_selector.py`): `/v1/engines` lists
|
||||
execution targets for the org (`local` + registered compute_config workers) and
|
||||
`PUT /v1/engines/default` sets a per-org default (stored on
|
||||
`ComputeProfile.default_engine`). `prompt_router.route_prompt` routes to the
|
||||
chosen worker; `local` = unchanged. Leased cloud machines (Visor
|
||||
`GET /v1/machines`) are a future engine class (interface stubbed, not built).
|
||||
Frontend picker is a TODO. Tested: `middleware_test/engine_selector_test.py`.
|
||||
- **Chat Copilot** (`middleware/copilot.py` + `branding/hanzo-copilot.{js,css}`):
|
||||
a sidebar chat that reads and MUTATES the workflow graph in natural language.
|
||||
Backend `POST /v1/copilot/chat` proxies one OpenAI tool-calling round to a
|
||||
chat endpoint (`STUDIO_COPILOT_URL`; default = local engine
|
||||
`127.0.0.1:1234` if its port is open, else `api.hanzo.ai`; `STUDIO_COPILOT_MODEL`
|
||||
default `zen`; token from `STUDIO_COPILOT_TOKEN`→`STUDIO_COMMERCE_TOKEN`→request
|
||||
bearer). It returns `{reply, ops}`; `add_node` ops are validated against
|
||||
`nodes.NODE_CLASS_MAPPINGS` server-side so the model can't invent node types.
|
||||
The **frontend** applies ops against `app.graph` (LiteGraph) — op schema:
|
||||
`add_node`(id?/pos?) · `set_widget` · `connect`(slots by name or index) ·
|
||||
`set_prompt` · `move_node` · `delete_node` · `layout` (columnar tidy) ·
|
||||
`queue`. Every op is guarded (unknown type/slot = skip + note, never throw).
|
||||
Injected into the prebuilt frontend by `branding/patch_copilot.py` (copies the
|
||||
bundle into static/, CSS `<link>` in `<head>`, deferred `<script>` before
|
||||
`</body>`; idempotent, wired as step 9 of `apply-branding.sh`) — survives
|
||||
frontend upgrades. `window.HanzoCopilot.applyOps` is exposed for tests. Tab
|
||||
registered via `app.extensionManager.registerSidebarTab` (id `hanzo-copilot`).
|
||||
Tested: `middleware_test/copilot_test.py` (op-validation + mocked tool loop) and
|
||||
`branding/copilot_smoke.py` (headless: applier mutates a mock graph + panel
|
||||
renders). Live end-to-end needs one server restart to register the route.
|
||||
- **Identity user menu + session API** (`middleware/session.py` +
|
||||
`branding/hanzo-identity.{js,css}`): the top-right user menu, driven entirely
|
||||
by the IAM-validated token. Backend (thin, `/v1` house style, registered in
|
||||
`add_routes` so it works auth-on and auth-off): `GET /v1/session` →
|
||||
`{user,email,org,orgs[],project,projects[],console_url,iam_url,authenticated}`
|
||||
read from `request["iam_user"]`; `POST /v1/session/org` sets the active org
|
||||
(validated against membership → `studio_active_org` cookie); `POST
|
||||
/v1/session/project` sets the workflow-workspace project scope; `GET|POST
|
||||
/logout` clears the session + selection cookies and bounces to the IAM
|
||||
end-session → `/login`. **Org switching is real, one hook**: the auth
|
||||
middleware applies `session.resolve_org(...)` to a COPY of the validated user
|
||||
(never the cached dict) so `iam_user["org_id"]` — the id every tenancy/output
|
||||
path already reads — reflects the switch; membership comes from
|
||||
`_orgs_from_claims` (`owner` + `organizations`/`orgs`/`groups`, list or CSV or
|
||||
`{name}` dicts, home first, deduped). Frontend is a SPA-agnostic fixed pill +
|
||||
dropdown (avatar/initials, user, org switcher, project switcher, Console
|
||||
link → `console.hanzo.ai`, Logout); 401 → "Sign in with Hanzo" → `/login`.
|
||||
Injected by `branding/patch_identity.py` (step 10 of `apply-branding.sh`,
|
||||
same seam as the copilot). `window.HanzoIdentity` is exposed for tests.
|
||||
Tested: `middleware_test/session_test.py` (resolve/orgs/routes/logout, real
|
||||
aiohttp app) + `scratchpad/studio-iam/test_identity.py` (headless: pill,
|
||||
dropdown, org switch re-scope, signed-out). The `/v1/session` routes need one
|
||||
server restart to register (static + de-Comfy load without restart).
|
||||
- **De-Comfy display strings** (`branding/patch_destring.py`, step 11 —
|
||||
runs LAST): the systematic English brand-word sweep the curated phrase list in
|
||||
`patch_frontend.py` structurally can't reach. Rule 1: whole-word `ComfyUI` →
|
||||
`Hanzo Studio` only where it reads as a display word (preceded by
|
||||
open-quote/space/`>`/`(`, followed by close-quote/space/sentence-punct) — that
|
||||
boundary skips every code use in the bundle (`ComfyUIStarted`, `.ComfyUI`,
|
||||
`=ComfyUI`, the `/^(ComfyUI-.../` regex, dotted i18n keys `.category.ComfyUI`)
|
||||
and hyphenated product names (`ComfyUI-Manager`). Rule 2: curated bare-`Comfy`
|
||||
**values** as exact `key:` value`` pairs so the Settings dialog **keys**
|
||||
(`Comfy`, `Comfy-Desktop`, `Comfy.ColorPalette`) are preserved while the
|
||||
displayed section header becomes **Studio** / **Studio Desktop** (this is the
|
||||
"Settings must not say Comfy" fix) + `Comfy Cloud` → `Hanzo Cloud`. Scope:
|
||||
English only — files above `LOCALE_RATIO` (0.15%) non-ASCII are skipped (all 33
|
||||
locale bundles are ≥0.31%; English bundles ~0.02%) and each match needs a
|
||||
pure-ASCII window, so the non-English `main-*/nodeDefs-*/commands-*` chunks are
|
||||
left as-is (noted, out of scope). Idempotent. Verified: served English display
|
||||
`ComfyUI` 63→0; a few example filesystem paths (`Documents/ComfyUI`) and the
|
||||
external comfy.org service labels (`Comfy API Key`, `Comfy Node Registry`,
|
||||
behind disabled API-nodes) are intentionally left. patch_frontend.py keeps its
|
||||
orthogonal concern (URLs/domains + `Comfy-Org` → `Hanzo AI`).
|
||||
|
||||
## Tests
|
||||
`tests-unit/{folder_paths_test,middleware_test,app_test,prompt_server_test}` —
|
||||
run `uv run pytest tests-unit/folder_paths_test tests-unit/middleware_test
|
||||
tests-unit/app_test tests-unit/prompt_server_test -q`. The auth/tenancy work is
|
||||
covered by `middleware_test/iam_auth_test.py` and
|
||||
covered by `middleware_test/iam_auth_test.py`,
|
||||
`middleware_test/session_test.py` and
|
||||
`folder_paths_test/org_scoping_test.py`.
|
||||
|
||||
@@ -22,10 +22,13 @@ Modifications made in this fork
|
||||
branding/. Backend Python packages are namespaced under studio_* and the
|
||||
server reports itself as "Hanzo Studio".
|
||||
|
||||
- Hanzo Engine integration nodes (custom_nodes/hanzo_engine/): custom nodes
|
||||
that call a local Hanzo Engine OpenAI-compatible server — HanzoChat
|
||||
(chat/completions), HanzoImageGen (images/generations), and
|
||||
HanzoVisionCaption (vision chat/completions). Hanzo Engine is the native-Rust,
|
||||
- Hanzo Engine integration nodes (custom_nodes/hanzo_engine/): first-party
|
||||
nodes that run AI workloads on a local Hanzo Engine OpenAI-compatible server
|
||||
over a shared client — HanzoChat (chat/completions, streaming), HanzoImageGen
|
||||
(images/generations), HanzoTTS (audio/speech), HanzoASR
|
||||
(audio/transcriptions), HanzoEngineRequest (generic /v1 escape hatch),
|
||||
HanzoVisionCaption (vision chat/completions), HanzoTextToVideo (videos),
|
||||
HanzoImageTo3D (3d), and HanzoSaveText. Hanzo Engine is the native-Rust,
|
||||
all-modality inference and training backend.
|
||||
|
||||
- Fashion/swimwear starter workflows (user/default/workflows/fashion/):
|
||||
|
||||
@@ -71,7 +71,47 @@ python3 "$BRANDING_DIR/patch_sidebar_logo.py" "$STATIC_DIR"
|
||||
# --- 7. Apply the black + Hanzo-purple theme ---
|
||||
# Installs hanzo-theme.css, injects it as the last stylesheet in index.html, and
|
||||
# rewrites the "dark" palette's canvas clear color to pure black.
|
||||
echo "[7/7] Applying black + purple theme..."
|
||||
echo "[7/8] Applying black + purple theme..."
|
||||
python3 "$BRANDING_DIR/patch_theme.py" "$STATIC_DIR" "$BRANDING_DIR"
|
||||
|
||||
# --- 8. Seed the default 25-tab fashion workspace ---
|
||||
# Injects a <head> bootstrap that pre-populates the localStorage keys the
|
||||
# frontend reads to restore open workflow tabs. Idempotent; bump SEED_VERSION
|
||||
# in patch_workspace_seed.py to force existing profiles to re-seed once.
|
||||
echo "[8/9] Seeding default workspace tabs..."
|
||||
python3 "$BRANDING_DIR/patch_workspace_seed.py" "$STATIC_DIR"
|
||||
|
||||
# --- 9. Inject the Copilot sidebar chat (frontend bundle + on-brand CSS) ---
|
||||
# Copies hanzo-copilot.{js,css} into static/ and injects them into index.html
|
||||
# (CSS link in <head>, deferred <script> before </body>). Pairs with the backend
|
||||
# route POST /v1/copilot/chat. Idempotent; bump BUNDLE_VERSION in patch_copilot.py.
|
||||
echo "[9/11] Injecting Copilot..."
|
||||
python3 "$BRANDING_DIR/patch_copilot.py" "$STATIC_DIR" "$BRANDING_DIR"
|
||||
|
||||
# --- 10. Inject the Identity user menu (frontend bundle + on-brand CSS) ---
|
||||
# Top-right user pill driven by GET /v1/session: login/logout, org switcher,
|
||||
# project scope, Console link. Idempotent; bump BUNDLE_VERSION in patch_identity.py.
|
||||
echo "[10/11] Injecting Identity menu..."
|
||||
python3 "$BRANDING_DIR/patch_identity.py" "$STATIC_DIR" "$BRANDING_DIR"
|
||||
|
||||
# --- 11. De-Comfy remaining English display strings ---
|
||||
# Sweeps the served English bundle for user-visible 'ComfyUI'/'Comfy' -> Studio
|
||||
# that the curated phrase list in patch_frontend.py cannot reach (mid-string),
|
||||
# plus the Settings dialog section headers. Runs LAST so it mops up everything.
|
||||
echo "[11/12] De-Comfy display strings..."
|
||||
python3 "$BRANDING_DIR/patch_destring.py" "$STATIC_DIR"
|
||||
|
||||
# --- 12. Force "Nodes 2.0" (Comfy.VueNodes.Enabled) on, remove the toggles ---
|
||||
# studio.hanzo.ai ships the modern DOM node renderer as the only node design:
|
||||
# default the setting on, hide it from the Settings dialog (type:hidden), and
|
||||
# drop the logo-menu switch. Idempotent; see patch_vuenodes.py.
|
||||
echo "[12/13] Forcing Nodes 2.0 on (no toggle)..."
|
||||
python3 "$BRANDING_DIR/patch_vuenodes.py" "$STATIC_DIR"
|
||||
|
||||
# --- 13. Inject the deep-link opener (open + optionally run a workflow by URL) ---
|
||||
# ?org=<org>&workflow=<path>&run=1 sets the session org, loads the saved workflow,
|
||||
# and queues it (dispatching to the org's BYO-GPU). Idempotent; see patch_deeplink.py.
|
||||
echo "[13/13] Injecting deep-link opener..."
|
||||
python3 "$BRANDING_DIR/patch_deeplink.py" "$STATIC_DIR" "$BRANDING_DIR"
|
||||
|
||||
echo "=== Hanzo Studio branding complete ==="
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Headless smoke test for the Copilot frontend bundle (hanzo-copilot.js).
|
||||
|
||||
Loads the REAL bundle into a page against a stub window.app / window.LiteGraph
|
||||
(a minimal LiteGraph subset) and asserts:
|
||||
1. window.HanzoCopilot.applyOps mutates the mock graph: add / connect /
|
||||
set_widget / set_prompt / move / delete / layout / queue.
|
||||
2. The sidebar tab registers and its render(el) builds the chat panel DOM.
|
||||
|
||||
This is the frontend half of the copilot tests. Live end-to-end (the panel
|
||||
talking to POST /v1/copilot/chat) needs the one server restart that registers
|
||||
the backend route; the bundle itself is exercised here without a server.
|
||||
|
||||
Run: python3 branding/copilot_smoke.py (needs: pip install playwright)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BUNDLE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hanzo-copilot.js")
|
||||
|
||||
# Stub LiteGraph + app: the smallest surface applyOps touches.
|
||||
STUB = """
|
||||
window.__connections = [];
|
||||
window.__queued = null;
|
||||
window.LiteGraph = {
|
||||
_id: 1000,
|
||||
createNode(type) {
|
||||
return {
|
||||
type, id: ++window.LiteGraph._id, pos: [0, 0], size: [200, 100],
|
||||
inputs: [{ name: 'image' }, { name: 'model' }],
|
||||
outputs: [{ name: 'IMAGE' }],
|
||||
widgets: [{ name: 'text', value: '' }, { name: 'seed', value: 0 }],
|
||||
connect(outIdx, target, inIdx) {
|
||||
window.__connections.push({ from: this.id, outIdx, to: target.id, inIdx });
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
(function () {
|
||||
var nodes = [];
|
||||
window.__nodes = nodes;
|
||||
window.app = {
|
||||
extensionManager: { registerSidebarTab(t) { window.__tab = t; } },
|
||||
graph: {
|
||||
_nodes: nodes, links: {},
|
||||
add(n) { nodes.push(n); },
|
||||
remove(n) { var i = nodes.indexOf(n); if (i >= 0) nodes.splice(i, 1); },
|
||||
getNodeById(id) { return nodes.find(function (n) { return n.id == id; }) || null; },
|
||||
setDirtyCanvas() {},
|
||||
},
|
||||
queuePrompt(n) { window.__queued = n; },
|
||||
async graphToPrompt() { return { output: {} }; },
|
||||
};
|
||||
})();
|
||||
"""
|
||||
|
||||
OPS = [
|
||||
{"op": "add_node", "type": "CLIPTextEncode", "id": "pos", "pos": [10, 20]},
|
||||
{"op": "add_node", "type": "SaveImage", "id": "save"},
|
||||
{"op": "set_prompt", "node_id": "pos", "text": "a red one-piece at golden hour"},
|
||||
{"op": "set_widget", "node_id": "save", "name": "seed", "value": 7},
|
||||
{"op": "connect", "from_node": "pos", "from_slot": "IMAGE", "to_node": "save", "to_slot": "image"},
|
||||
{"op": "move_node", "node_id": "save", "pos": [300, 40]},
|
||||
{"op": "layout"},
|
||||
{"op": "queue"},
|
||||
{"op": "delete_node", "node_id": "pos"},
|
||||
{"op": "bogus", "node_id": "pos"},
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
page = browser.new_page()
|
||||
page.set_content("<!doctype html><html><head></head><body></body></html>")
|
||||
page.evaluate(STUB)
|
||||
page.add_script_tag(path=BUNDLE)
|
||||
|
||||
assert page.evaluate("typeof window.HanzoCopilot") == "object", "HanzoCopilot not exposed"
|
||||
|
||||
# Apply the op batch against the mock graph.
|
||||
res = page.evaluate("(ops) => window.HanzoCopilot.applyOps(ops)", OPS)
|
||||
print("applyOps ->", res)
|
||||
|
||||
nodes = page.evaluate("window.__nodes.map(function(n){return {id:n.id,type:n.type,pos:n.pos};})")
|
||||
conns = page.evaluate("window.__connections")
|
||||
queued = page.evaluate("window.__queued")
|
||||
save_seed = page.evaluate(
|
||||
"(function(){var n=window.__nodes.find(function(x){return x.type=='SaveImage';});"
|
||||
"return n.widgets.find(function(w){return w.name=='seed';}).value;})()"
|
||||
)
|
||||
|
||||
# 2 added, then 1 deleted -> only SaveImage remains.
|
||||
types = sorted(n["type"] for n in nodes)
|
||||
assert types == ["SaveImage"], f"expected only SaveImage after delete, got {types}"
|
||||
assert len(conns) == 1 and conns[0]["outIdx"] == 0 and conns[0]["inIdx"] == 0, f"bad connect: {conns}"
|
||||
assert save_seed == 7, f"set_widget failed: seed={save_seed}"
|
||||
assert queued == 0, f"queue op did not fire: {queued}"
|
||||
# bogus op is skipped, not thrown, and noted.
|
||||
assert any("bogus" in note for note in res["notes"]), f"bogus op not noted: {res['notes']}"
|
||||
# 8 good ops applied (add,add,set_prompt,set_widget,connect,move,layout,queue,delete = 9);
|
||||
# bogus skipped.
|
||||
assert res["applied"] == 9, f"expected 9 applied, got {res['applied']}"
|
||||
|
||||
# Sidebar tab registered; render builds the panel.
|
||||
page.wait_for_function("() => window.__tab !== undefined", timeout=5000)
|
||||
tab = page.evaluate("({id: window.__tab.id, title: window.__tab.title, icon: window.__tab.icon})")
|
||||
assert tab["id"] == "hanzo-copilot", f"bad tab id: {tab}"
|
||||
panel = page.evaluate(
|
||||
"(function(){var d=document.createElement('div');document.body.appendChild(d);"
|
||||
"window.__tab.render(d);return {panel:d.classList.contains('hanzo-copilot'),"
|
||||
"chips:d.querySelectorAll('.hz-chip').length,input:!!d.querySelector('textarea')};})()"
|
||||
)
|
||||
assert panel["panel"] and panel["input"] and panel["chips"] >= 3, f"panel not built: {panel}"
|
||||
|
||||
browser.close()
|
||||
print("tab ->", tab)
|
||||
print("panel ->", panel)
|
||||
print("PASS: copilot frontend smoke")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,137 @@
|
||||
/* Hanzo Studio Copilot — black + Hanzo-purple sidebar chat.
|
||||
* Reuses the theme's CSS vars (hanzo-theme.css) so it tracks the shell palette;
|
||||
* hard-coded fallbacks keep it on-brand if a var is ever absent.
|
||||
*/
|
||||
.hanzo-copilot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--comfy-menu-bg, #0a0a0a);
|
||||
color: var(--fg-color, #fff);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hanzo-copilot .hz-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
border-bottom: 1px solid var(--border-color, #1f1f1f);
|
||||
}
|
||||
.hanzo-copilot .hz-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--p-primary-color, #8b5cf6);
|
||||
box-shadow: 0 0 8px var(--p-primary-color, #8b5cf6);
|
||||
}
|
||||
|
||||
.hanzo-copilot .hz-messages {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.hanzo-copilot .hz-msg { display: flex; }
|
||||
.hanzo-copilot .hz-user { justify-content: flex-end; }
|
||||
.hanzo-copilot .hz-assistant { justify-content: flex-start; }
|
||||
.hanzo-copilot .hz-bubble {
|
||||
max-width: 84%;
|
||||
padding: 8px 11px;
|
||||
border-radius: 12px;
|
||||
line-height: 1.45;
|
||||
word-wrap: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
.hanzo-copilot .hz-user .hz-bubble {
|
||||
background: var(--p-button-primary-background, #7c3aed);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
.hanzo-copilot .hz-assistant .hz-bubble {
|
||||
background: #1a1a1f;
|
||||
border: 1px solid var(--border-color, #1f1f1f);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
.hanzo-copilot .hz-typing { opacity: 0.6; letter-spacing: 3px; }
|
||||
|
||||
.hanzo-copilot .hz-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px 12px 4px;
|
||||
}
|
||||
.hanzo-copilot .hz-chip {
|
||||
background: transparent;
|
||||
color: #c4b5fd;
|
||||
border: 1px solid #2a2140;
|
||||
border-radius: 999px;
|
||||
padding: 5px 10px;
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.hanzo-copilot .hz-chip:hover {
|
||||
background: rgba(139, 92, 246, 0.16);
|
||||
border-color: var(--p-primary-color, #8b5cf6);
|
||||
}
|
||||
|
||||
.hanzo-copilot .hz-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 12px 12px;
|
||||
border-top: 1px solid var(--border-color, #1f1f1f);
|
||||
align-items: flex-end;
|
||||
}
|
||||
.hanzo-copilot .hz-input textarea {
|
||||
flex: 1 1 auto;
|
||||
resize: none;
|
||||
background: var(--comfy-input-bg, #0a0a0a);
|
||||
color: var(--fg-color, #fff);
|
||||
border: 1px solid var(--border-color, #1f1f1f);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
.hanzo-copilot .hz-input textarea:focus {
|
||||
border-color: var(--p-primary-color, #8b5cf6);
|
||||
}
|
||||
.hanzo-copilot .hz-send {
|
||||
background: var(--p-button-primary-background, #7c3aed);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 9px 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.hanzo-copilot .hz-send:hover { background: #6d28d9; }
|
||||
.hanzo-copilot .hz-send:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* Fallback toast (used only when the app toast service is unavailable) */
|
||||
.hanzo-copilot-toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 12px);
|
||||
background: #17171c;
|
||||
color: #fff;
|
||||
border: 1px solid #2a2140;
|
||||
border-left: 3px solid var(--p-primary-color, #8b5cf6);
|
||||
border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
z-index: 100000;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s, transform 0.25s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hanzo-copilot-toast.show { opacity: 1; transform: translate(-50%, 0); }
|
||||
@@ -0,0 +1,359 @@
|
||||
/* Hanzo Studio Copilot — a sidebar chat that reads and MUTATES the workflow graph.
|
||||
*
|
||||
* You talk to the canvas in natural language ("add a 4x upscale after the save
|
||||
* image"). The backend (POST /v1/copilot/chat) decides graph operations; THIS
|
||||
* file applies them against app.graph (LiteGraph). Every op is guarded — an
|
||||
* unknown node type or bad slot is skipped and noted, never thrown.
|
||||
*
|
||||
* Injected as the last <script> by branding/patch_copilot.py so window.app exists
|
||||
* (or is about to — we poll). window.HanzoCopilot.applyOps is exposed immediately
|
||||
* for programmatic use and headless tests.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
var VERSION = "1";
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
function app() { return window.app; }
|
||||
function graph() { return window.app && window.app.graph; }
|
||||
function LG() { return window.LiteGraph; }
|
||||
|
||||
function waitFor(pred, timeoutMs) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var t0 = Date.now();
|
||||
(function tick() {
|
||||
var v = pred();
|
||||
if (v) return resolve(v);
|
||||
if (Date.now() - t0 > timeoutMs) return reject(new Error("timeout"));
|
||||
setTimeout(tick, 120);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
function resolveSlot(list, slot) {
|
||||
// slot may be an index (number) or a slot name (string). Returns an index.
|
||||
if (typeof slot === "number") return slot;
|
||||
if (slot == null) return 0;
|
||||
var name = String(slot).toLowerCase();
|
||||
for (var i = 0; i < (list || []).length; i++) {
|
||||
var s = list[i];
|
||||
var n = (s && (s.name || s.label)) || s;
|
||||
if (String(n).toLowerCase() === name) return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- applier
|
||||
// Applies one batch of ops. Returns {applied, notes[]}. New nodes are referenced
|
||||
// in later ops by the id label the model gave them in add_node.
|
||||
function applyOps(ops) {
|
||||
var g = graph();
|
||||
var notes = [];
|
||||
var applied = 0;
|
||||
if (!g) { return { applied: 0, notes: ["no graph"] }; }
|
||||
var local = {}; // model-label / id -> node
|
||||
|
||||
function resolve(id) {
|
||||
if (id == null) return null;
|
||||
if (local[id]) return local[id];
|
||||
var n = g.getNodeById(typeof id === "string" && /^\d+$/.test(id) ? Number(id) : id);
|
||||
if (!n && typeof id === "number") n = g.getNodeById(id);
|
||||
return n || null;
|
||||
}
|
||||
function setWidget(node, name, value) {
|
||||
if (!node.widgets) return false;
|
||||
var lname = String(name).toLowerCase();
|
||||
for (var i = 0; i < node.widgets.length; i++) {
|
||||
var w = node.widgets[i];
|
||||
if (w.name && w.name.toLowerCase() === lname) {
|
||||
w.value = value;
|
||||
if (typeof w.callback === "function") { try { w.callback(value); } catch (e) {} }
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
(ops || []).forEach(function (op, idx) {
|
||||
try {
|
||||
switch (op.op) {
|
||||
case "add_node": {
|
||||
var Lg = LG();
|
||||
var node = Lg && Lg.createNode ? Lg.createNode(op.type) : null;
|
||||
if (!node) { notes.push("skip add_node: unknown type " + op.type); break; }
|
||||
g.add(node);
|
||||
if (Array.isArray(op.pos)) node.pos = [op.pos[0], op.pos[1]];
|
||||
if (op.id != null) local[op.id] = node;
|
||||
local[node.id] = node;
|
||||
applied++;
|
||||
break;
|
||||
}
|
||||
case "set_widget": {
|
||||
var n1 = resolve(op.node_id);
|
||||
if (!n1) { notes.push("skip set_widget: no node " + op.node_id); break; }
|
||||
if (!setWidget(n1, op.name, op.value)) { notes.push("skip set_widget: no widget " + op.name + " on " + op.node_id); break; }
|
||||
applied++;
|
||||
break;
|
||||
}
|
||||
case "set_prompt": {
|
||||
var n2 = resolve(op.node_id);
|
||||
if (!n2 || !n2.widgets) { notes.push("skip set_prompt: no node " + op.node_id); break; }
|
||||
var target = null;
|
||||
for (var j = 0; j < n2.widgets.length; j++) {
|
||||
if (n2.widgets[j].name && n2.widgets[j].name.toLowerCase() === "text") { target = n2.widgets[j]; break; }
|
||||
}
|
||||
if (!target) target = n2.widgets.find(function (w) { return typeof w.value === "string"; });
|
||||
if (!target) { notes.push("skip set_prompt: no text widget on " + op.node_id); break; }
|
||||
target.value = op.text;
|
||||
if (typeof target.callback === "function") { try { target.callback(op.text); } catch (e) {} }
|
||||
applied++;
|
||||
break;
|
||||
}
|
||||
case "connect": {
|
||||
var from = resolve(op.from_node), to = resolve(op.to_node);
|
||||
if (!from || !to) { notes.push("skip connect: missing node"); break; }
|
||||
var oi = resolveSlot(from.outputs, op.from_slot);
|
||||
var ii = resolveSlot(to.inputs, op.to_slot);
|
||||
from.connect(oi, to, ii);
|
||||
applied++;
|
||||
break;
|
||||
}
|
||||
case "move_node": {
|
||||
var n3 = resolve(op.node_id);
|
||||
if (!n3 || !Array.isArray(op.pos)) { notes.push("skip move_node: " + op.node_id); break; }
|
||||
n3.pos = [op.pos[0], op.pos[1]];
|
||||
applied++;
|
||||
break;
|
||||
}
|
||||
case "delete_node": {
|
||||
var n4 = resolve(op.node_id);
|
||||
if (!n4) { notes.push("skip delete_node: no node " + op.node_id); break; }
|
||||
g.remove(n4);
|
||||
applied++;
|
||||
break;
|
||||
}
|
||||
case "layout": {
|
||||
layout();
|
||||
applied++;
|
||||
break;
|
||||
}
|
||||
case "queue": {
|
||||
if (app() && typeof app().queuePrompt === "function") { app().queuePrompt(0); applied++; }
|
||||
else notes.push("skip queue: unavailable");
|
||||
break;
|
||||
}
|
||||
default:
|
||||
notes.push("skip unknown op " + op.op);
|
||||
}
|
||||
} catch (e) {
|
||||
notes.push("error on op " + idx + " (" + op.op + "): " + e.message);
|
||||
}
|
||||
});
|
||||
|
||||
if (app() && app().graph && app().graph.setDirtyCanvas) app().graph.setDirtyCanvas(true, true);
|
||||
return { applied: applied, notes: notes };
|
||||
}
|
||||
|
||||
// Simple left-to-right columnar tidy: column = longest link-path from a source.
|
||||
function layout() {
|
||||
var g = graph();
|
||||
if (!g || !g._nodes) return;
|
||||
var nodes = g._nodes;
|
||||
var col = {};
|
||||
nodes.forEach(function (n) { col[n.id] = 0; });
|
||||
for (var pass = 0; pass < nodes.length; pass++) {
|
||||
var changed = false;
|
||||
nodes.forEach(function (n) {
|
||||
(n.inputs || []).forEach(function (inp) {
|
||||
if (inp && inp.link != null && g.links && g.links[inp.link]) {
|
||||
var src = g.links[inp.link].origin_id;
|
||||
if (col[src] != null && col[n.id] < col[src] + 1) { col[n.id] = col[src] + 1; changed = true; }
|
||||
}
|
||||
});
|
||||
});
|
||||
if (!changed) break;
|
||||
}
|
||||
var COLW = 340, ROWH = 240, rowOf = {};
|
||||
nodes.slice().sort(function (a, b) { return (col[a.id] - col[b.id]) || (a.id - b.id); }).forEach(function (n) {
|
||||
var c = col[n.id] || 0;
|
||||
rowOf[c] = (rowOf[c] || 0);
|
||||
n.pos = [80 + c * COLW, 80 + rowOf[c] * ROWH];
|
||||
rowOf[c]++;
|
||||
});
|
||||
if (g.setDirtyCanvas) g.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
window.HanzoCopilot = { applyOps: applyOps, layout: layout, VERSION: VERSION };
|
||||
|
||||
// ---------------------------------------------------------------- catalog
|
||||
var _objectInfo = null;
|
||||
function getObjectInfo() {
|
||||
if (_objectInfo) return Promise.resolve(_objectInfo);
|
||||
return fetch("./object_info").then(function (r) { return r.json(); }).then(function (j) {
|
||||
_objectInfo = j; return j;
|
||||
});
|
||||
}
|
||||
function inputTypeStr(spec) {
|
||||
if (Array.isArray(spec)) {
|
||||
var t = spec[0];
|
||||
if (typeof t === "string") return t;
|
||||
if (Array.isArray(t)) return "COMBO";
|
||||
return "ANY";
|
||||
}
|
||||
return "ANY";
|
||||
}
|
||||
function buildCatalog(info, graphJson) {
|
||||
var types = Object.keys(info);
|
||||
var present = {};
|
||||
Object.keys(graphJson || {}).forEach(function (id) {
|
||||
var ct = graphJson[id] && graphJson[id].class_type;
|
||||
if (ct) present[ct] = true;
|
||||
});
|
||||
var detail = {};
|
||||
Object.keys(present).forEach(function (t) {
|
||||
var d = info[t]; if (!d) return;
|
||||
var ins = {};
|
||||
["required", "optional"].forEach(function (grp) {
|
||||
var block = d.input && d.input[grp];
|
||||
if (block) Object.keys(block).forEach(function (name) { ins[name] = inputTypeStr(block[name]); });
|
||||
});
|
||||
detail[t] = { inputs: ins, outputs: d.output_name || d.output || [] };
|
||||
});
|
||||
return { types: types, nodes: detail };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- toast
|
||||
function toast(msg) {
|
||||
try {
|
||||
var em = app() && app().extensionManager;
|
||||
if (em && em.toast && typeof em.toast.add === "function") {
|
||||
em.toast.add({ severity: "success", summary: "Copilot", detail: msg, life: 4000 });
|
||||
return;
|
||||
}
|
||||
} catch (e) {}
|
||||
var el = document.createElement("div");
|
||||
el.className = "hanzo-copilot-toast";
|
||||
el.textContent = msg;
|
||||
document.body.appendChild(el);
|
||||
setTimeout(function () { el.classList.add("show"); }, 10);
|
||||
setTimeout(function () { el.classList.remove("show"); setTimeout(function () { el.remove(); }, 300); }, 3800);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- chat state + UI
|
||||
var state = { messages: [], busy: false };
|
||||
var CHIPS = [
|
||||
"Add a 4x upscale after the save image",
|
||||
"Connect the latent output to a new VAE decode",
|
||||
"Set the positive prompt to a red one-piece on a beach at golden hour",
|
||||
"Tidy up the layout",
|
||||
];
|
||||
|
||||
function esc(s) { return String(s).replace(/[&<>]/g, function (c) { return { "&": "&", "<": "<", ">": ">" }[c]; }); }
|
||||
|
||||
function renderMessages(box) {
|
||||
box.innerHTML = "";
|
||||
state.messages.forEach(function (m) {
|
||||
var row = document.createElement("div");
|
||||
row.className = "hz-msg hz-" + m.role;
|
||||
row.innerHTML = '<div class="hz-bubble">' + esc(m.content).replace(/\n/g, "<br>") + "</div>";
|
||||
box.appendChild(row);
|
||||
});
|
||||
if (state.busy) {
|
||||
var t = document.createElement("div");
|
||||
t.className = "hz-msg hz-assistant";
|
||||
t.innerHTML = '<div class="hz-bubble hz-typing">…</div>';
|
||||
box.appendChild(t);
|
||||
}
|
||||
box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
async function send(text, box) {
|
||||
if (!text || state.busy) return;
|
||||
state.messages.push({ role: "user", content: text });
|
||||
state.busy = true;
|
||||
renderMessages(box);
|
||||
try {
|
||||
var gp = await app().graphToPrompt();
|
||||
var graphJson = (gp && gp.output) || {};
|
||||
var info = await getObjectInfo().catch(function () { return {}; });
|
||||
var catalog = buildCatalog(info, graphJson);
|
||||
var resp = await fetch("./v1/copilot/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ messages: state.messages, graph_json: graphJson, catalog: catalog }),
|
||||
});
|
||||
var data = await resp.json().catch(function () { return {}; });
|
||||
if (!resp.ok) throw new Error(data.error || ("HTTP " + resp.status));
|
||||
var reply = data.reply || "(no reply)";
|
||||
state.messages.push({ role: "assistant", content: reply });
|
||||
state.busy = false;
|
||||
renderMessages(box);
|
||||
var ops = data.ops || [];
|
||||
if (ops.length) {
|
||||
var res = applyOps(ops);
|
||||
var summary = res.applied + " change" + (res.applied === 1 ? "" : "s") + " applied";
|
||||
if (res.notes.length) summary += " · " + res.notes.length + " skipped";
|
||||
toast(summary);
|
||||
if (res.notes.length) {
|
||||
state.messages.push({ role: "assistant", content: res.notes.join("\n") });
|
||||
renderMessages(box);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
state.busy = false;
|
||||
state.messages.push({ role: "assistant", content: "⚠ " + e.message });
|
||||
renderMessages(box);
|
||||
}
|
||||
}
|
||||
|
||||
function buildUI(el) {
|
||||
el.classList.add("hanzo-copilot");
|
||||
el.innerHTML =
|
||||
'<div class="hz-head"><span class="hz-dot"></span> Copilot</div>' +
|
||||
'<div class="hz-messages"></div>' +
|
||||
'<div class="hz-chips"></div>' +
|
||||
'<div class="hz-input"><textarea rows="2" placeholder="Tell the canvas what to do…"></textarea>' +
|
||||
'<button class="hz-send" title="Send">Send</button></div>';
|
||||
var box = el.querySelector(".hz-messages");
|
||||
var ta = el.querySelector("textarea");
|
||||
var chips = el.querySelector(".hz-chips");
|
||||
CHIPS.forEach(function (c) {
|
||||
var b = document.createElement("button");
|
||||
b.className = "hz-chip"; b.textContent = c;
|
||||
b.onclick = function () { ta.value = c; ta.focus(); };
|
||||
chips.appendChild(b);
|
||||
});
|
||||
function fire() { var v = ta.value.trim(); ta.value = ""; send(v, box); }
|
||||
el.querySelector(".hz-send").onclick = fire;
|
||||
ta.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); fire(); }
|
||||
});
|
||||
if (!state.messages.length) {
|
||||
state.messages.push({ role: "assistant", content: "Hi — I can edit this workflow for you. Try a chip below or just tell me what to change." });
|
||||
}
|
||||
renderMessages(box);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- boot
|
||||
function register() {
|
||||
var a = app();
|
||||
if (a && a.extensionManager && typeof a.extensionManager.registerSidebarTab === "function") {
|
||||
a.extensionManager.registerSidebarTab({
|
||||
id: "hanzo-copilot",
|
||||
icon: "pi pi-comments",
|
||||
title: "Copilot",
|
||||
tooltip: "Chat Copilot — edit the workflow in natural language",
|
||||
type: "custom",
|
||||
render: function (el) { buildUI(el); },
|
||||
destroy: function () {},
|
||||
});
|
||||
console.log("[Hanzo Copilot] sidebar tab registered v" + VERSION);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
waitFor(function () { return app() && app().extensionManager; }, 30000)
|
||||
.then(register)
|
||||
.catch(function () { console.warn("[Hanzo Copilot] app.extensionManager not found; copilot tab not mounted"); });
|
||||
})();
|
||||
@@ -0,0 +1,103 @@
|
||||
/* Hanzo Studio deep-link — open a saved workflow (and optionally run it) straight
|
||||
* from a URL, so a link in the gallery / console / chat lands you on the graph
|
||||
* ready to render.
|
||||
*
|
||||
* Params (query string or hash, e.g. ?org=karma&workflow=fashion/shoot_valentina.json&run=1):
|
||||
* org active org to scope userdata + render output to (sets the studio
|
||||
* session org cookie BEFORE loading, so a re-run dispatches to the
|
||||
* org's BYO-GPU and lands the output in that org's gallery).
|
||||
* workflow path under the user's workflows/ dir (e.g. "fashion/shoot_x.json").
|
||||
* run "1" to auto-queue the graph once loaded (re-run in one click).
|
||||
*
|
||||
* Injected as the last <script> by branding/patch_deeplink.py so window.app
|
||||
* exists. Guarded end-to-end: a bad param logs and no-ops, never throws.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
var VERSION = "1";
|
||||
function app() { return window.app; }
|
||||
|
||||
function params() {
|
||||
var out = {};
|
||||
var q = new URLSearchParams(window.location.search);
|
||||
q.forEach(function (v, k) { out[k] = v; });
|
||||
if (window.location.hash && window.location.hash.length > 1) {
|
||||
var h = new URLSearchParams(window.location.hash.slice(1));
|
||||
h.forEach(function (v, k) { if (out[k] == null) out[k] = v; });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function waitFor(cond, ms) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var t0 = Date.now();
|
||||
(function poll() {
|
||||
var v; try { v = cond(); } catch (e) { v = null; }
|
||||
if (v) return resolve(v);
|
||||
if (Date.now() - t0 > ms) return reject(new Error("timeout"));
|
||||
setTimeout(poll, 150);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
function toast(msg, ok) {
|
||||
var el = document.createElement("div");
|
||||
el.textContent = msg;
|
||||
el.style.cssText =
|
||||
"position:fixed;top:14px;left:50%;transform:translateX(-50%);z-index:99999;" +
|
||||
"padding:8px 14px;border-radius:8px;font:13px system-ui;color:#fff;" +
|
||||
"background:" + (ok ? "#1f7a4d" : "#8a1f1f") + ";box-shadow:0 4px 16px rgba(0,0,0,.35)";
|
||||
document.body.appendChild(el);
|
||||
setTimeout(function () { el.style.transition = "opacity .4s"; el.style.opacity = "0"; }, 3200);
|
||||
setTimeout(function () { el.remove(); }, 3700);
|
||||
}
|
||||
|
||||
async function setOrg(org) {
|
||||
// Set the studio active org so userdata + render output scope to it. Same-origin
|
||||
// fetch carries cookies; the response Set-Cookie updates studio_active_org.
|
||||
var r = await fetch("/v1/session/org", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ org: org }),
|
||||
});
|
||||
if (!r.ok) throw new Error("org switch failed (" + r.status + ")");
|
||||
}
|
||||
|
||||
async function loadWorkflow(wf) {
|
||||
var key = "workflows/" + String(wf).replace(/^\/+/, "");
|
||||
var r = await fetch("/userdata/" + encodeURIComponent(key), { credentials: "same-origin" });
|
||||
if (!r.ok) throw new Error("workflow not found: " + wf + " (" + r.status + ")");
|
||||
var data = await r.json();
|
||||
await app().loadGraphData(data);
|
||||
return key;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
var p = params();
|
||||
if (!p.workflow && !p.org) return; // nothing to do — normal studio load
|
||||
await waitFor(function () { return app() && typeof app().loadGraphData === "function"; }, 30000);
|
||||
try {
|
||||
if (p.org) { await setOrg(p.org); }
|
||||
if (p.workflow) {
|
||||
await loadWorkflow(p.workflow);
|
||||
toast("Opened " + p.workflow + (p.org ? " · " + p.org : ""), true);
|
||||
if (String(p.run) === "1" && typeof app().queuePrompt === "function") {
|
||||
// Give the graph a tick to settle before queuing.
|
||||
setTimeout(function () {
|
||||
try { app().queuePrompt(0); toast("Queued render → your GPU", true); }
|
||||
catch (e) { toast("Auto-run failed: " + e.message, false); }
|
||||
}, 400);
|
||||
}
|
||||
} else if (p.org) {
|
||||
toast("Switched to " + p.org, true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[Hanzo deep-link]", e);
|
||||
toast(e.message, false);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
console.log("[Hanzo deep-link] v" + VERSION + " ready");
|
||||
})();
|
||||
@@ -0,0 +1,111 @@
|
||||
/* Hanzo Studio Identity menu — top-right user pill + dropdown.
|
||||
* Matches console.hanzo.ai's design system: monochrome true-black surfaces,
|
||||
* hairline borders, 9px radii, neutral (non-chromatic) accents. No brand-purple
|
||||
* — console is deliberately monochrome, so the pill reads as the same product
|
||||
* family as console's OrgSwitcher / user menu.
|
||||
* --color1 #050505 (surface) · --backgroundHover #171717 · --borderColor
|
||||
* #23262b · --borderColorHover #363b40 · --color12 #eef0f3 (text) ·
|
||||
* --color11 #cdd2da · --color9 #848a93 (muted) · red10 #e5484d (destructive) */
|
||||
|
||||
.hanzo-identity {
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
right: 12px;
|
||||
z-index: 3000;
|
||||
font-family: 'Basel', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
color: #eef0f3;
|
||||
}
|
||||
|
||||
.hzid-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
padding: 0 10px 0 4px;
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #23262b;
|
||||
border-radius: 9px;
|
||||
color: #eef0f3;
|
||||
cursor: pointer;
|
||||
transition: border-color .12s ease, background .12s ease;
|
||||
}
|
||||
.hzid-pill:hover { border-color: #363b40; background: #171717; }
|
||||
|
||||
.hzid-avatar,
|
||||
.hzid-avatar-lg {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #26292b;
|
||||
color: #eef0f3;
|
||||
font-weight: 500;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.hzid-avatar { width: 24px; height: 24px; font-size: 10px; }
|
||||
.hzid-avatar-lg { width: 36px; height: 36px; font-size: 14px; flex: 0 0 auto; }
|
||||
|
||||
.hzid-pillname { font-weight: 500; max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hzid-org {
|
||||
font-size: 11px;
|
||||
color: #a4abb4;
|
||||
background: #171717;
|
||||
border: 1px solid #23262b;
|
||||
padding: 1px 7px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.hzid-caret { color: #848a93; font-size: 10px; }
|
||||
|
||||
.hzid-signin { text-decoration: none; padding: 0 14px; color: #eef0f3; }
|
||||
.hzid-signin:hover { color: #fff; }
|
||||
|
||||
/* dropdown panel */
|
||||
.hzid-panel {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
right: 0;
|
||||
min-width: 236px;
|
||||
background: #050505;
|
||||
border: 1px solid #23262b;
|
||||
border-radius: 9px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, .55);
|
||||
padding: 8px;
|
||||
animation: hzid-in .12s ease;
|
||||
}
|
||||
@keyframes hzid-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.hzid-id { display: flex; align-items: center; gap: 10px; padding: 6px 8px 10px; }
|
||||
.hzid-idtext { min-width: 0; }
|
||||
.hzid-name { font-weight: 500; color: #eef0f3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hzid-email { font-size: 11px; color: #848a93; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.hzid-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
color: #6b727e;
|
||||
padding: 8px 8px 3px;
|
||||
}
|
||||
|
||||
.hzid-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 7px 8px;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
color: #cdd2da;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hzid-item:hover { background: #171717; color: #eef0f3; }
|
||||
.hzid-active { color: #eef0f3; }
|
||||
.hzid-tick { width: 14px; flex: 0 0 auto; color: #eef0f3; font-size: 12px; }
|
||||
.hzid-sep { height: 1px; background: #23262b; margin: 8px 4px; }
|
||||
.hzid-logout:hover { background: rgba(229, 72, 77, .12); color: #e5484d; }
|
||||
.hzid-logout .hzid-tick { color: #e5484d; }
|
||||
@@ -0,0 +1,192 @@
|
||||
/* Hanzo Studio Identity menu — the top-right user menu, driven entirely by the
|
||||
* IAM-validated session the backend exposes at GET /v1/session.
|
||||
*
|
||||
* It reads {user,email,org,orgs[],project,projects[],console_url,iam_url} and
|
||||
* renders a pill (avatar/initials + name). Opening it shows the signed-in user,
|
||||
* an org switcher (POST /v1/session/org -> reload, re-scoped to that org for
|
||||
* tenancy + output paths), a project switcher (POST /v1/session/project), a
|
||||
* link back to Hanzo Console, and Logout (GET /logout -> clears the session
|
||||
* cookie -> IAM end-session -> /login).
|
||||
*
|
||||
* Self-contained and SPA-agnostic: a fixed element in the menu-bar corner, so it
|
||||
* never fights the Vue app. Injected as a deferred <script> by patch_identity.py.
|
||||
* window.HanzoIdentity is exposed for headless tests.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
if (window.__hanzoIdentityLoaded) return;
|
||||
window.__hanzoIdentityLoaded = true;
|
||||
var VERSION = "3";
|
||||
|
||||
// ------------------------------------------------------------- dom helper
|
||||
function el(tag, attrs, kids) {
|
||||
var n = document.createElement(tag);
|
||||
if (attrs) Object.keys(attrs).forEach(function (k) {
|
||||
if (k === "class") n.className = attrs[k];
|
||||
else if (k === "text") n.textContent = attrs[k];
|
||||
else if (k === "html") n.innerHTML = attrs[k];
|
||||
else if (k.slice(0, 2) === "on") n[k.toLowerCase()] = attrs[k];
|
||||
else n.setAttribute(k, attrs[k]);
|
||||
});
|
||||
(kids || []).forEach(function (c) { if (c) n.appendChild(c); });
|
||||
return n;
|
||||
}
|
||||
|
||||
function initials(name, email) {
|
||||
var src = (name || email || "?").trim();
|
||||
var parts = src.split(/[\s@._-]+/).filter(Boolean);
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
return src.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- api
|
||||
function getSession() {
|
||||
return fetch("./v1/session", { headers: { Accept: "application/json" }, credentials: "same-origin" })
|
||||
.then(function (r) {
|
||||
// 401 is the real "not signed in" (auth on, no valid token) -> Sign in.
|
||||
// A 200 body is always a usable session (a real IAM user in the cloud, or
|
||||
// the local single-user stub when auth is off) -> render the pill.
|
||||
if (r.status === 401) return { signedOut: true };
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
function post(path, body) {
|
||||
return fetch("./" + path, {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}).then(function (r) { return r.json().catch(function () { return {}; }); });
|
||||
}
|
||||
|
||||
var state = { session: null, open: false };
|
||||
|
||||
function switchOrg(org) {
|
||||
if (!org || (state.session && org === state.session.org)) return;
|
||||
post("v1/session/org", { org: org }).then(function () { location.reload(); });
|
||||
}
|
||||
function switchProject(p) {
|
||||
if (!p || (state.session && p === state.session.project)) return;
|
||||
post("v1/session/project", { project: p }).then(function () { location.reload(); });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- render
|
||||
function close() { state.open = false; paint(); }
|
||||
|
||||
function menuPanel(s) {
|
||||
var rows = [];
|
||||
rows.push(el("div", { class: "hzid-id" }, [
|
||||
el("div", { class: "hzid-avatar-lg", text: initials(s.user, s.email) }),
|
||||
el("div", { class: "hzid-idtext" }, [
|
||||
el("div", { class: "hzid-name", text: s.user || "User" }),
|
||||
el("div", { class: "hzid-email", text: s.email || "" }),
|
||||
]),
|
||||
]));
|
||||
|
||||
// Organization switcher
|
||||
rows.push(el("div", { class: "hzid-label", text: "Organization" }));
|
||||
(s.orgs || [s.org]).forEach(function (o) {
|
||||
rows.push(el("button", {
|
||||
class: "hzid-item" + (o === s.org ? " hzid-active" : ""),
|
||||
onclick: function () { switchOrg(o); },
|
||||
}, [
|
||||
el("span", { class: "hzid-tick", text: o === s.org ? "✓" : "" }),
|
||||
el("span", { text: o }),
|
||||
]));
|
||||
});
|
||||
|
||||
// Project (workflow workspace) switcher
|
||||
if (s.projects && s.projects.length) {
|
||||
rows.push(el("div", { class: "hzid-label", text: "Project" }));
|
||||
s.projects.forEach(function (p) {
|
||||
rows.push(el("button", {
|
||||
class: "hzid-item" + (p === s.project ? " hzid-active" : ""),
|
||||
onclick: function () { switchProject(p); },
|
||||
}, [
|
||||
el("span", { class: "hzid-tick", text: p === s.project ? "✓" : "" }),
|
||||
el("span", { text: p }),
|
||||
]));
|
||||
});
|
||||
}
|
||||
|
||||
rows.push(el("div", { class: "hzid-sep" }));
|
||||
// Exit the advanced node editor back to the content-forward Studio Home.
|
||||
// Without this there is NO way out of Advanced mode (the node editor is the
|
||||
// full-screen ComfyUI SPA) — the user gets stuck.
|
||||
rows.push(el("a", { class: "hzid-item hzid-link", href: "/studio" }, [
|
||||
el("span", { class: "hzid-tick", text: "←" }), el("span", { text: "Exit to Studio Home" }),
|
||||
]));
|
||||
rows.push(el("a", { class: "hzid-item hzid-link", href: s.console_url || "https://console.hanzo.ai", target: "_blank", rel: "noopener" }, [
|
||||
el("span", { class: "hzid-tick", text: "↗" }), el("span", { text: "Console" }),
|
||||
]));
|
||||
rows.push(el("a", { class: "hzid-item hzid-logout", href: "/logout" }, [
|
||||
el("span", { class: "hzid-tick", text: "⏻" }), el("span", { text: "Logout" }),
|
||||
]));
|
||||
return el("div", { class: "hzid-panel" }, rows);
|
||||
}
|
||||
|
||||
function paint() {
|
||||
var root = document.getElementById("hanzo-identity");
|
||||
if (!root) return;
|
||||
root.innerHTML = "";
|
||||
var s = state.session;
|
||||
if (!s) return;
|
||||
|
||||
if (s.signedOut) {
|
||||
root.appendChild(el("a", { class: "hzid-pill hzid-signin", href: "/login" }, [
|
||||
el("span", { text: "Sign in with Hanzo" }),
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
var pill = el("button", { class: "hzid-pill", title: (s.email || s.user) + " · " + s.org, onclick: function (e) { e.stopPropagation(); state.open = !state.open; paint(); } }, [
|
||||
el("span", { class: "hzid-avatar", text: initials(s.user, s.email) }),
|
||||
el("span", { class: "hzid-pillname", text: s.user || "User" }),
|
||||
el("span", { class: "hzid-org", text: s.org || "" }),
|
||||
el("span", { class: "hzid-caret", text: "▾" }),
|
||||
]);
|
||||
root.appendChild(pill);
|
||||
if (state.open) root.appendChild(menuPanel(s));
|
||||
}
|
||||
|
||||
// dismiss on outside click
|
||||
document.addEventListener("click", function (e) {
|
||||
if (state.open && !e.target.closest("#hanzo-identity")) close();
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- boot
|
||||
function mount() {
|
||||
if (document.getElementById("hanzo-identity")) return;
|
||||
// Always-visible "Exit to Studio" button so Advanced mode is never a trap.
|
||||
// Fixed top-left, above the node editor; independent of the user menu.
|
||||
if (!document.getElementById("hanzo-exit-advanced")) {
|
||||
var exit = el("a", {
|
||||
id: "hanzo-exit-advanced", href: "/studio", title: "Exit Advanced — back to Studio Home",
|
||||
style: "position:fixed;top:10px;left:12px;z-index:100000;display:flex;align-items:center;gap:6px;" +
|
||||
"background:rgba(20,20,24,.92);color:#ececf1;border:1px solid #33333c;border-radius:8px;" +
|
||||
"padding:6px 11px;font:600 12px/1 Inter,system-ui,sans-serif;text-decoration:none;cursor:pointer;backdrop-filter:blur(8px)"
|
||||
}, [el("span", { text: "←" }), el("span", { text: "Studio" })]);
|
||||
document.body.appendChild(exit);
|
||||
}
|
||||
var root = el("div", { id: "hanzo-identity", class: "hanzo-identity" });
|
||||
document.body.appendChild(root);
|
||||
getSession().then(function (s) {
|
||||
state.session = s; paint();
|
||||
console.log("[Hanzo Identity] mounted v" + VERSION + (s && s.user ? " as " + s.user : ""));
|
||||
}).catch(function (err) {
|
||||
// /v1/session not registered yet (backend pre-restart) — stay silent.
|
||||
console.warn("[Hanzo Identity] session unavailable:", err && err.message);
|
||||
});
|
||||
}
|
||||
|
||||
// testability: let a test set the session and repaint without the network.
|
||||
window.HanzoIdentity = {
|
||||
VERSION: VERSION,
|
||||
mount: mount,
|
||||
reload: function () { return getSession().then(function (s) { state.session = s; paint(); return s; }); },
|
||||
_set: function (s) { state.session = s; paint(); },
|
||||
};
|
||||
|
||||
if (document.body) mount();
|
||||
else document.addEventListener("DOMContentLoaded", mount);
|
||||
})();
|
||||
@@ -62,3 +62,81 @@ body.litegraph,
|
||||
#vue-app {
|
||||
background-color: #000000 !important;
|
||||
}
|
||||
|
||||
/* ── hover/active states: dark surfaces, never light (fix white-on-white) ── */
|
||||
:root, :root.dark-theme {
|
||||
--p-content-hover-background: #1f1f23 !important;
|
||||
--p-content-hover-color: #ffffff !important;
|
||||
--p-navigation-item-hover-background: #1f1f23 !important;
|
||||
--p-list-option-focus-background: #26262b !important;
|
||||
--p-select-option-focus-background: #26262b !important;
|
||||
--p-button-text-secondary-hover-background: #1f1f23 !important;
|
||||
--p-button-text-primary-hover-background: rgba(139,92,246,.16) !important;
|
||||
--p-button-secondary-hover-background: #26262b !important;
|
||||
--p-togglebutton-hover-background: #26262b !important;
|
||||
--p-tab-hover-background: #1f1f23 !important;
|
||||
--p-menubar-item-focus-background: #26262b !important;
|
||||
--p-menu-item-focus-background: #26262b !important;
|
||||
--p-tree-node-hover-background: #1f1f23 !important;
|
||||
--p-surface-100: #26262b !important;
|
||||
--p-surface-200: #1f1f23 !important;
|
||||
--p-highlight-background: rgba(139,92,246,.20) !important;
|
||||
--p-highlight-color: #ffffff !important;
|
||||
}
|
||||
.side-tool-bar-container .p-button:hover,
|
||||
.side-bar-button:hover,
|
||||
.comfyui-button:hover {
|
||||
background: #1f1f23 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.p-button-text:not(.p-button-danger):hover { background: #1f1f23 !important; color: #fff !important; }
|
||||
|
||||
/* ── the H logo menu button: keep the mark visible on hover/open ── */
|
||||
button:has(> .comfyui-logo):hover,
|
||||
button:has(.comfyui-logo):hover,
|
||||
.p-button:has(.comfyui-logo):hover,
|
||||
button:has(.comfyui-logo)[aria-expanded="true"],
|
||||
.comfyui-logo-menu-trigger:hover {
|
||||
background: #1f1f23 !important;
|
||||
}
|
||||
button:has(.comfyui-logo):hover .comfyui-logo,
|
||||
button:has(.comfyui-logo):hover svg,
|
||||
button:has(.comfyui-logo):hover path {
|
||||
color: #ffffff !important;
|
||||
fill: #ffffff !important;
|
||||
}
|
||||
|
||||
/* ── active/selected/checked states: never white (white-box-under-H fix) ── */
|
||||
:root, :root.dark-theme {
|
||||
--p-button-secondary-active-background: #26262b !important;
|
||||
--p-togglebutton-checked-background: #2a2140 !important;
|
||||
--p-togglebutton-checked-color: #c4b5fd !important;
|
||||
--p-navigation-item-active-background: #26262b !important;
|
||||
--p-tab-active-background: #0a0a0a !important;
|
||||
--p-button-text-primary-active-background: rgba(139,92,246,.24) !important;
|
||||
}
|
||||
.side-tool-bar-container .p-button.p-highlight,
|
||||
.side-tool-bar-container .p-togglebutton.p-togglebutton-checked,
|
||||
.side-tool-bar-container [aria-pressed="true"],
|
||||
.side-tool-bar-container .p-button:active,
|
||||
.p-selectbutton .p-button.p-highlight {
|
||||
background: #2a2140 !important;
|
||||
color: #c4b5fd !important;
|
||||
}
|
||||
.side-tool-bar-container .p-button.p-highlight svg,
|
||||
.side-tool-bar-container [aria-pressed="true"] svg { color: #c4b5fd !important; fill: currentColor !important; }
|
||||
|
||||
/* ── selected sidebar icon: purple-tinted dark, never white ── */
|
||||
:root, :root.dark-theme {
|
||||
--interface-panel-selected-surface: #2a2140 !important;
|
||||
--interface-menu-component-surface-selected: #2a2140 !important;
|
||||
}
|
||||
.side-bar-button-selected {
|
||||
background-color: #2a2140 !important;
|
||||
color: #c4b5fd !important;
|
||||
}
|
||||
.side-bar-button-selected svg, .side-bar-button-selected i { color: #c4b5fd !important; }
|
||||
.selected:not(.p-galleria-thumbnail-item) {
|
||||
background-color: #2a2140 !important;
|
||||
color: #e5e5e5 !important;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Inject the Hanzo Studio Copilot into the prebuilt frontend.
|
||||
|
||||
Two orthogonal levers, each in its own lane (mirrors patch_theme.py):
|
||||
1. Assets -> copy hanzo-copilot.js + hanzo-copilot.css into static/.
|
||||
2. index.html seam ->
|
||||
- <link> for the CSS as the last stylesheet in <head>
|
||||
- <script defer> for the bundle as the last element before </body>, so
|
||||
window.app / window.LiteGraph exist (the bundle also polls, so order is
|
||||
forgiving) and the copilot registers its sidebar tab.
|
||||
|
||||
Idempotent: strips any prior copilot link/script (matched by filename) and
|
||||
re-injects with a ?v=BUNDLE_VERSION cache-buster. Bump BUNDLE_VERSION when the
|
||||
JS/CSS changes so browsers pick it up on the next load.
|
||||
|
||||
Usage: patch_copilot.py <static_dir> <branding_dir>
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
STATIC_DIR = sys.argv[1]
|
||||
BRANDING_DIR = sys.argv[2]
|
||||
|
||||
BUNDLE_VERSION = "1"
|
||||
JS = "hanzo-copilot.js"
|
||||
CSS = "hanzo-copilot.css"
|
||||
LINK_TAG = f'<link rel="stylesheet" href="{CSS}?v={BUNDLE_VERSION}"/>'
|
||||
SCRIPT_TAG = f'<script src="{JS}?v={BUNDLE_VERSION}" defer></script>'
|
||||
|
||||
|
||||
def install_assets() -> None:
|
||||
for fname in (JS, CSS):
|
||||
shutil.copyfile(os.path.join(BRANDING_DIR, fname), os.path.join(STATIC_DIR, fname))
|
||||
print(f" Installed {JS} + {CSS}")
|
||||
|
||||
|
||||
def inject() -> None:
|
||||
index = os.path.join(STATIC_DIR, "index.html")
|
||||
if not os.path.isfile(index):
|
||||
print(" WARNING: index.html not found — copilot not injected")
|
||||
return
|
||||
with open(index, encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
|
||||
# Strip any prior copilot tags (any ?v=) so re-runs stay clean.
|
||||
html = re.sub(r'<link[^>]*href="%s[^"]*"[^>]*>' % re.escape(CSS), "", html)
|
||||
html = re.sub(r'<script[^>]*src="%s[^"]*"[^>]*></script>' % re.escape(JS), "", html)
|
||||
|
||||
if "</head>" in html:
|
||||
html = html.replace("</head>", LINK_TAG + "</head>", 1)
|
||||
if "</body>" in html:
|
||||
html = html.replace("</body>", SCRIPT_TAG + "</body>", 1)
|
||||
else: # no </body> (minified edge) — append; the script still runs
|
||||
html += SCRIPT_TAG
|
||||
|
||||
with open(index, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
print(f" Injected copilot link + script (v{BUNDLE_VERSION}) into index.html")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
install_assets()
|
||||
inject()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Inject the Hanzo Studio deep-link opener into the prebuilt frontend.
|
||||
|
||||
Mirrors patch_copilot.py (same seam, same idempotency):
|
||||
1. Assets -> copy hanzo-deeplink.js into static/.
|
||||
2. index.html -> <script defer> as the last element before </body>, so
|
||||
window.app exists when it reads the URL and opens/runs the
|
||||
workflow.
|
||||
|
||||
Idempotent: strips any prior deeplink script (matched by filename) and re-injects
|
||||
with a ?v=BUNDLE_VERSION cache-buster. Bump BUNDLE_VERSION when the JS changes.
|
||||
|
||||
Usage: patch_deeplink.py <static_dir> <branding_dir>
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
STATIC_DIR = sys.argv[1]
|
||||
BRANDING_DIR = sys.argv[2]
|
||||
|
||||
BUNDLE_VERSION = "1"
|
||||
JS = "hanzo-deeplink.js"
|
||||
SCRIPT_TAG = f'<script src="{JS}?v={BUNDLE_VERSION}" defer></script>'
|
||||
|
||||
|
||||
def install_assets() -> None:
|
||||
shutil.copyfile(os.path.join(BRANDING_DIR, JS), os.path.join(STATIC_DIR, JS))
|
||||
print(f" Installed {JS}")
|
||||
|
||||
|
||||
def inject() -> None:
|
||||
index = os.path.join(STATIC_DIR, "index.html")
|
||||
if not os.path.isfile(index):
|
||||
print(" WARNING: index.html not found — deep-link not injected")
|
||||
return
|
||||
with open(index, encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
|
||||
# Strip any prior deeplink tag (any ?v=) so re-runs stay clean.
|
||||
html = re.sub(r'<script[^>]*src="%s[^"]*"[^>]*></script>' % re.escape(JS), "", html)
|
||||
|
||||
if "</body>" in html:
|
||||
html = html.replace("</body>", SCRIPT_TAG + "</body>", 1)
|
||||
else: # no </body> (minified edge) — append; the script still runs
|
||||
html += SCRIPT_TAG
|
||||
|
||||
with open(index, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
print(f" Injected deep-link script (v{BUNDLE_VERSION}) into index.html")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
install_assets()
|
||||
inject()
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""De-Comfy the served English frontend: user-visible 'ComfyUI'/'Comfy' brand
|
||||
words -> 'Hanzo Studio'/'Studio', without touching code identifiers, i18n keys,
|
||||
setting keys, or the non-English locale bundles.
|
||||
|
||||
One concern only: the PRODUCT brand word in English UI copy. (URLs/domains and
|
||||
the org name 'Comfy-Org'->'Hanzo AI' stay in patch_frontend.py; logos/theme
|
||||
elsewhere.) Runs after patch_frontend.py in apply-branding.sh and mops up every
|
||||
mid-string 'ComfyUI' the curated phrase list there structurally cannot reach
|
||||
(e.g. `Update ComfyUI`, `Installing ComfyUI`), plus the Settings dialog section
|
||||
headers.
|
||||
|
||||
Two rules:
|
||||
|
||||
1. Whole-word 'ComfyUI' -> 'Hanzo Studio', but only where it reads as a word in
|
||||
a display string: preceded by open-quote / space / '>' / '(' and followed by
|
||||
close-quote / space / sentence punctuation. That boundary skips every code
|
||||
use found in the bundle — ComfyUIStarted, .ComfyUI, =ComfyUI, the
|
||||
/^(ComfyUI-.../ regex, and dotted i18n keys like .category.ComfyUI (the '.'
|
||||
is not an opening delimiter) — and hyphenated product names (ComfyUI-Manager,
|
||||
the '-' is not a closing delimiter) so the actual third-party tool keeps its
|
||||
name.
|
||||
|
||||
2. Curated bare-'Comfy' *values*, replaced as exact key:`value` pairs so the
|
||||
setting KEYS ('Comfy', 'Comfy-Desktop', 'Comfy.ColorPalette', which the app
|
||||
reads programmatically) are preserved while the displayed section header
|
||||
becomes 'Studio' / 'Studio Desktop'. Plus 'Comfy Cloud' -> 'Hanzo Cloud'.
|
||||
|
||||
Scope guard: English only. A file is processed iff its non-ASCII byte ratio is
|
||||
below LOCALE_RATIO (English bundles ~0.02%, every locale bundle >=0.31%), and
|
||||
each individual match is additionally required to sit in a pure-ASCII window so a
|
||||
stray CJK/RTL run is never rewritten. Idempotent: a second run finds no
|
||||
display-context 'ComfyUI' and the exact value pairs are already rewritten.
|
||||
|
||||
Usage: patch_destring.py <static_dir>
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
STATIC_DIR = sys.argv[1]
|
||||
|
||||
# A file is treated as a non-English locale bundle (skipped) above this ratio.
|
||||
# English bundles measure ~0.0002; the lowest locale bundle ~0.0031.
|
||||
LOCALE_RATIO = 0.0015
|
||||
ASCII_WINDOW = 24 # chars of context each side that must be ASCII to rewrite
|
||||
|
||||
WORD_RE = re.compile(r"(?<=[\s`\"'>(])ComfyUI(?=[\s`\"'.,!?:;<)])")
|
||||
WORD_REPL = "Hanzo Studio"
|
||||
|
||||
# Exact key:`value` pairs — key preserved, display value rebranded.
|
||||
CURATED = [
|
||||
("Comfy:`Comfy`", "Comfy:`Studio`"), # settingsCategories header
|
||||
('"Comfy-Desktop":`Comfy-Desktop`', '"Comfy-Desktop":`Studio Desktop`'),
|
||||
("comfy:`Comfy`", "comfy:`Studio`"),
|
||||
("Comfy Cloud", "Hanzo Cloud"),
|
||||
]
|
||||
|
||||
EXTS = (".js", ".html", ".json")
|
||||
|
||||
|
||||
def _non_ascii_ratio(raw: bytes) -> float:
|
||||
if not raw:
|
||||
return 1.0
|
||||
return sum(1 for c in raw if c > 127) / len(raw)
|
||||
|
||||
|
||||
def _replace_word(text: str) -> tuple[str, int]:
|
||||
"""Apply WORD_RE, but only where the surrounding window is pure ASCII."""
|
||||
out, last, n = [], 0, 0
|
||||
for mobj in WORD_RE.finditer(text):
|
||||
s, e = mobj.start(), mobj.end()
|
||||
ctx = text[max(0, s - ASCII_WINDOW):e + ASCII_WINDOW]
|
||||
out.append(text[last:s])
|
||||
if all(ord(c) < 128 for c in ctx):
|
||||
out.append(WORD_REPL)
|
||||
n += 1
|
||||
else:
|
||||
out.append(mobj.group(0))
|
||||
last = e
|
||||
out.append(text[last:])
|
||||
return "".join(out), n
|
||||
|
||||
|
||||
def patch_file(path: str) -> int:
|
||||
raw = open(path, "rb").read()
|
||||
if _non_ascii_ratio(raw) > LOCALE_RATIO:
|
||||
return 0 # non-English locale bundle — out of scope
|
||||
text = raw.decode("utf-8", errors="ignore")
|
||||
original = text
|
||||
|
||||
text, changed = _replace_word(text)
|
||||
for old, new in CURATED:
|
||||
if old in text:
|
||||
changed += text.count(old)
|
||||
text = text.replace(old, new)
|
||||
|
||||
# Collapse any accidental doubling from overlapping prior passes.
|
||||
text = text.replace("Hanzo Studio Studio", "Hanzo Studio").replace("Hanzo Cloud Cloud", "Hanzo Cloud")
|
||||
|
||||
if text != original:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
return changed
|
||||
|
||||
|
||||
def _display_count(static_dir: str) -> int:
|
||||
total = 0
|
||||
for root, _dirs, files in os.walk(static_dir):
|
||||
for fn in files:
|
||||
if not fn.endswith(EXTS):
|
||||
continue
|
||||
p = os.path.join(root, fn)
|
||||
raw = open(p, "rb").read()
|
||||
if _non_ascii_ratio(raw) > LOCALE_RATIO:
|
||||
continue
|
||||
total += len(WORD_RE.findall(raw.decode("utf-8", errors="ignore")))
|
||||
return total
|
||||
|
||||
|
||||
def main() -> None:
|
||||
before = _display_count(STATIC_DIR)
|
||||
files = changed = 0
|
||||
for root, _dirs, names in os.walk(STATIC_DIR):
|
||||
for fn in names:
|
||||
if not fn.endswith(EXTS):
|
||||
continue
|
||||
n = patch_file(os.path.join(root, fn))
|
||||
if n:
|
||||
files += 1
|
||||
changed += n
|
||||
after = _display_count(STATIC_DIR)
|
||||
print(f" De-Comfy: {changed} display strings across {files} English files "
|
||||
f"(display 'ComfyUI' {before} -> {after})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Inject the Hanzo Studio Identity menu into the prebuilt frontend.
|
||||
|
||||
Mirrors patch_copilot.py exactly (same two orthogonal levers):
|
||||
1. Assets -> copy hanzo-identity.js + hanzo-identity.css into static/.
|
||||
2. index.html seam ->
|
||||
- <link> for the CSS as the last stylesheet in <head>
|
||||
- <script defer> for the bundle as the last element before </body>.
|
||||
|
||||
Idempotent: strips any prior identity link/script (matched by filename) and
|
||||
re-injects with a ?v=BUNDLE_VERSION cache-buster. Bump BUNDLE_VERSION when the
|
||||
JS/CSS changes so browsers pick it up on the next load.
|
||||
|
||||
Usage: patch_identity.py <static_dir> <branding_dir>
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
STATIC_DIR = sys.argv[1]
|
||||
BRANDING_DIR = sys.argv[2]
|
||||
|
||||
BUNDLE_VERSION = "4"
|
||||
JS = "hanzo-identity.js"
|
||||
CSS = "hanzo-identity.css"
|
||||
LINK_TAG = f'<link rel="stylesheet" href="{CSS}?v={BUNDLE_VERSION}"/>'
|
||||
SCRIPT_TAG = f'<script src="{JS}?v={BUNDLE_VERSION}" defer></script>'
|
||||
|
||||
|
||||
def install_assets() -> None:
|
||||
for fname in (JS, CSS):
|
||||
shutil.copyfile(os.path.join(BRANDING_DIR, fname), os.path.join(STATIC_DIR, fname))
|
||||
print(f" Installed {JS} + {CSS}")
|
||||
|
||||
|
||||
def inject() -> None:
|
||||
index = os.path.join(STATIC_DIR, "index.html")
|
||||
if not os.path.isfile(index):
|
||||
print(" WARNING: index.html not found — identity menu not injected")
|
||||
return
|
||||
with open(index, encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
|
||||
html = re.sub(r'<link[^>]*href="%s[^"]*"[^>]*>' % re.escape(CSS), "", html)
|
||||
html = re.sub(r'<script[^>]*src="%s[^"]*"[^>]*></script>' % re.escape(JS), "", html)
|
||||
|
||||
if "</head>" in html:
|
||||
html = html.replace("</head>", LINK_TAG + "</head>", 1)
|
||||
if "</body>" in html:
|
||||
html = html.replace("</body>", SCRIPT_TAG + "</body>", 1)
|
||||
else:
|
||||
html += SCRIPT_TAG
|
||||
|
||||
with open(index, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
print(f" Injected identity link + script (v{BUNDLE_VERSION}) into index.html")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
install_assets()
|
||||
inject()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Force ComfyUI "Nodes 2.0" (Vue/DOM node rendering, the `Comfy.VueNodes.Enabled`
|
||||
setting) ON for the cloud build and remove it as a user-facing choice.
|
||||
|
||||
Upstream ships Nodes 2.0 as an *experimental, default-off* toggle exposed in two
|
||||
places: a row in the Settings dialog (category Comfy > "Nodes 2.0") and a switch
|
||||
in the top-left logo menu (analytics id `menu_nodes_2.0_toggle`). studio.hanzo.ai
|
||||
ships the modern node design as the one and only node renderer, so this patch:
|
||||
|
||||
1. Flips the registered default of `Comfy.VueNodes.Enabled` !1 -> !0 (on).
|
||||
2. Sets its `type` boolean -> hidden, so the Settings dialog stops rendering the
|
||||
row (ComfyUI's own mechanism — same as `Comfy.NodeLibrary.Bookmarks.V2`).
|
||||
Same hidden treatment for the sibling `Comfy.VueNodes.AutoScaleLayout` so the
|
||||
entire "Nodes 2.0" settings category collapses (no orphan row). Its default
|
||||
is already !0 upstream.
|
||||
3. Flips every inline read fallback `...Enabled`)??!1` -> `??!0`, so a fresh
|
||||
profile (no persisted value) resolves to on at every call site.
|
||||
4. Deletes the `nodes-2.0-toggle` item from the logo-menu model so the menu
|
||||
switch is gone too.
|
||||
|
||||
Operates on the served frontend bundle (comfyui-frontend-package/static), anchored
|
||||
on the setting id / menu-item literal (not on minified hashes), so it survives a
|
||||
frontend bump as long as the setting keeps its id. Idempotent: after a run the
|
||||
`boolean`/`!1`/menu-item strings for these settings no longer exist, so a second
|
||||
run is a no-op. Prints what it changed.
|
||||
|
||||
Usage: patch_vuenodes.py <static_dir>
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
STATIC_DIR = sys.argv[1]
|
||||
|
||||
# Registration objects to force-on + hide. Anchored on the setting id and bounded
|
||||
# to that one object by the lazy run up to its `versionAdded`.
|
||||
FORCE_ON = "Comfy.VueNodes.Enabled" # experimental default-off -> on
|
||||
HIDE_ONLY = "Comfy.VueNodes.AutoScaleLayout" # already default-on -> just hide row
|
||||
|
||||
# Logo-menu item literal (top-left menu switch). Removing it drops the switch.
|
||||
MENU_ITEM = "{key:`nodes-2.0-toggle`,label:`Nodes 2.0`},"
|
||||
|
||||
|
||||
def _reg_pattern(setting_id: str) -> re.Pattern:
|
||||
# id:`<setting_id>` ... versionAdded:`x.y.z` (one registration object)
|
||||
return re.compile(
|
||||
r"id:`" + re.escape(setting_id) + r"`.{0,500}?versionAdded:`[^`]*`",
|
||||
re.S,
|
||||
)
|
||||
|
||||
|
||||
def patch_text(text: str) -> tuple[str, int]:
|
||||
n = 0
|
||||
|
||||
def force_on(m: re.Match) -> str:
|
||||
seg = m.group(0)
|
||||
seg = seg.replace("type:`boolean`", "type:`hidden`")
|
||||
seg = seg.replace("defaultValue:!1", "defaultValue:!0")
|
||||
return seg
|
||||
|
||||
def hide_only(m: re.Match) -> str:
|
||||
return m.group(0).replace("type:`boolean`", "type:`hidden`")
|
||||
|
||||
text, c = _reg_pattern(FORCE_ON).subn(force_on, text)
|
||||
n += c
|
||||
text, c = _reg_pattern(HIDE_ONLY).subn(hide_only, text)
|
||||
n += c
|
||||
|
||||
# Inline read fallbacks: unset -> on.
|
||||
needle = FORCE_ON + "`)??!1"
|
||||
if needle in text:
|
||||
n += text.count(needle)
|
||||
text = text.replace(needle, FORCE_ON + "`)??!0")
|
||||
|
||||
# Remove the logo-menu switch item.
|
||||
if MENU_ITEM in text:
|
||||
n += text.count(MENU_ITEM)
|
||||
text = text.replace(MENU_ITEM, "")
|
||||
|
||||
return text, n
|
||||
|
||||
|
||||
def main() -> None:
|
||||
files = changed = 0
|
||||
for root, _dirs, names in os.walk(os.path.join(STATIC_DIR, "assets")):
|
||||
for fn in names:
|
||||
if not fn.endswith(".js"):
|
||||
continue
|
||||
path = os.path.join(root, fn)
|
||||
with open(path, encoding="utf-8", errors="ignore") as f:
|
||||
original = f.read()
|
||||
text, n = patch_text(original)
|
||||
if n and text != original:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
files += 1
|
||||
changed += n
|
||||
print(f" Nodes 2.0 forced on + toggles removed: {changed} edits across {files} files")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Seed the default multi-tab fashion workspace for Hanzo Studio.
|
||||
|
||||
Injects a tiny <head> bootstrap script into index.html that pre-populates the
|
||||
localStorage keys the frontend reads on boot to restore open workflow tabs:
|
||||
|
||||
Comfy.OpenWorkflowsPaths JSON array of workflow paths (one tab each)
|
||||
Comfy.ActiveWorkflowIndex restore split index (0 = open all as trailing tabs)
|
||||
|
||||
Why localStorage: the frontend's restore path (restoreWorkflowTabsState in
|
||||
GraphView useWorkflowPersistence) reads these through getStorageValue()/
|
||||
setStorageValue(), which are localStorage helpers with a clientId-suffixed
|
||||
sessionStorage layer read FIRST. On re-seed we therefore also purge any stale
|
||||
`<key>:<clientId>` sessionStorage shadows so the fresh localStorage seed is
|
||||
authoritative even on a same-tab reload (otherwise a previously-saved 1-tab
|
||||
shadow would mask the seed).
|
||||
|
||||
ComfyUI always opens its own blank "Unsaved Workflow" as the active tab on boot
|
||||
(initializeWorkflow -> loadDefaultWorkflow); the 25 seeded fashion workflows are
|
||||
restored alongside it in the background, so the workspace opens with 26 tabs.
|
||||
There is no seed key that suppresses the default tab: loadPersistedWorkflow only
|
||||
rehydrates drafts / the last-edited graph blob, never a userdata file by path.
|
||||
|
||||
The seed runs at most once per browser profile, guarded by hanzoWorkspaceSeed.
|
||||
Bump SEED_VERSION to force every existing profile to re-seed exactly once.
|
||||
|
||||
Idempotent: re-running strips the previous seed <script> and re-injects the
|
||||
current one, so this is safe to call on every branding pass.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
|
||||
STATIC_DIR = sys.argv[1]
|
||||
INDEX = f"{STATIC_DIR}/index.html"
|
||||
|
||||
SEED_VERSION = "v3"
|
||||
|
||||
# Curated 8-design x 3-pose matrix + the shared 4K upscaler = 25 tabs.
|
||||
DESIGNS = [
|
||||
"festival_fuchsia", "neptune_green", "floralmesh", "stripedgem",
|
||||
"redonepiece", "valentina", "red", "ches",
|
||||
]
|
||||
POSES = ["product", "hover", "shoot"]
|
||||
PATHS = [f"workflows/fashion/{pose}_{d}.json" for d in DESIGNS for pose in POSES]
|
||||
PATHS.append("workflows/fashion/upscale_4k.json")
|
||||
|
||||
# Marker id lets us find & replace our own block idempotently.
|
||||
MARKER = "hanzo-workspace-seed"
|
||||
|
||||
paths_json = "[" + ", ".join('"%s"' % p for p in PATHS) + "]"
|
||||
|
||||
SEED_SCRIPT = (
|
||||
f'<script id="{MARKER}">try{{'
|
||||
f"if(localStorage.getItem('hanzoWorkspaceSeed')!=='{SEED_VERSION}'){{"
|
||||
f"localStorage.setItem('Comfy.OpenWorkflowsPaths',JSON.stringify({paths_json}));"
|
||||
f"localStorage.setItem('Comfy.ActiveWorkflowIndex','0');"
|
||||
f"for(var i=sessionStorage.length-1;i>=0;i--){{var k=sessionStorage.key(i);"
|
||||
f"if(k&&(k.indexOf('Comfy.OpenWorkflowsPaths:')===0"
|
||||
f"||k.indexOf('Comfy.ActiveWorkflowIndex:')===0))sessionStorage.removeItem(k);}}"
|
||||
f"localStorage.setItem('hanzoWorkspaceSeed','{SEED_VERSION}');"
|
||||
f"}}}}catch(e){{}}</script>"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with open(INDEX, "r", encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
|
||||
# Remove any prior seed block (marked or the original unmarked variant).
|
||||
html = re.sub(r'<script id="%s">.*?</script>' % re.escape(MARKER), "", html,
|
||||
flags=re.DOTALL)
|
||||
html = re.sub(r"<script>try\{if\(localStorage\.getItem\('hanzoWorkspaceSeed'\).*?</script>",
|
||||
"", html, flags=re.DOTALL)
|
||||
|
||||
if MARKER not in html:
|
||||
html = html.replace("<head>", "<head>" + SEED_SCRIPT, 1)
|
||||
|
||||
with open(INDEX, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
|
||||
print(f" Seeded workspace ({len(PATHS)} tabs, guard {SEED_VERSION}) into index.html")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,51 @@
|
||||
# Hand-resolved Python deps for the vendored custom-node packs (see
|
||||
# custom_nodes/hanzo-packs.txt). Install via scripts/install_custom_nodes.sh, which
|
||||
# locks the studio cu130 torch stack / NumPy 2.x / Transformers 5.x (derived from
|
||||
# the live env) so none of these can downgrade them. Only deps NOT already in
|
||||
# requirements.txt are listed; the studio-critical packages are locked, not here.
|
||||
# `ultralytics` is intentionally absent -- the installer adds it with --no-deps so
|
||||
# it cannot pull the non-headless opencv-python that fights opencv-*-headless.
|
||||
|
||||
# --- Impact-Pack / segment_anything: detailer + SAM masks ---
|
||||
segment-anything
|
||||
timm
|
||||
scikit-image
|
||||
piexif
|
||||
dill
|
||||
matplotlib
|
||||
|
||||
# --- Impact-Subpack (ultralytics detectors): light deps only; ultralytics itself
|
||||
# is installed --no-deps by the installer, then these fill in its runtime needs ---
|
||||
py-cpuinfo
|
||||
ultralytics-thop
|
||||
pandas
|
||||
|
||||
# --- comfyui_controlnet_aux: pose/depth/edge preprocessors ---
|
||||
opencv-contrib-python-headless
|
||||
onnxruntime
|
||||
addict
|
||||
yapf
|
||||
omegaconf
|
||||
ftfy
|
||||
yacs
|
||||
fvcore
|
||||
trimesh
|
||||
albumentations
|
||||
mediapipe
|
||||
python-dateutil
|
||||
importlib_metadata
|
||||
scikit-learn
|
||||
|
||||
# --- ComfyUI-KJNodes ---
|
||||
color-matcher
|
||||
mss
|
||||
|
||||
# --- VideoHelperSuite / Frame-Interpolation: video I/O ---
|
||||
imageio
|
||||
imageio-ffmpeg
|
||||
|
||||
# --- misc utils used across packs ---
|
||||
gitpython
|
||||
joblib
|
||||
pilgram
|
||||
fairscale
|
||||
@@ -0,0 +1,99 @@
|
||||
# Hanzo Studio custom-node packs -- pinned, reproducible, first-class "hanzo packages".
|
||||
#
|
||||
# Format: <dir_name> <git_url> <commit_sha> <spdx_license> # friendly-ref
|
||||
#
|
||||
# Installed by scripts/install_custom_nodes.sh (invoked in the Docker image build
|
||||
# and runnable locally) so the studio.hanzo.ai image ships the same packs the
|
||||
# local GB10 worker runs. Upstream packs import comfy.* / subclass io.ComfyNode /
|
||||
# expose comfy_entrypoint -- studio_compat aliases those to the studio.* names at
|
||||
# boot and the loader honors comfy_entrypoint, so packs run UNMODIFIED (no fork,
|
||||
# no source edits). Python deps: custom_nodes-requirements.txt (+ -constraints.txt).
|
||||
#
|
||||
# Pinned to immutable commit SHAs. To bump a pack: replace its SHA + re-run the
|
||||
# installer (patch-bump; never blind-track HEAD). All licenses are OSI (GPL/AGPL/
|
||||
# MIT/Apache) -- none are non-commercial.
|
||||
|
||||
ComfyUI-Impact-Pack https://github.com/ltdrdata/ComfyUI-Impact-Pack.git 429d0159ad429e64d2b3916e6e7be9c22d025c3c GPL-3.0 # 8.28
|
||||
ComfyUI-Impact-Subpack https://github.com/ltdrdata/ComfyUI-Impact-Subpack.git 50c7b71a6a224734cc9b21963c6d1926816a97f1 AGPL-3.0 # 1.3.4 Ultralytics detectors
|
||||
ComfyUI_IPAdapter_plus https://github.com/cubiq/ComfyUI_IPAdapter_plus.git a0f451a5113cf9becb0847b92884cb10cbdec0ef GPL-3.0 # HEAD 2025-04-14
|
||||
comfyui_controlnet_aux https://github.com/Fannovel16/comfyui_controlnet_aux.git e8b689a513c3e6b63edc44066560ca5919c0576e Apache-2.0 # HEAD 2026-04-13
|
||||
ComfyUI_UltimateSDUpscale https://github.com/ssitu/ComfyUI_UltimateSDUpscale.git a5547db9e1d07d3318bb21e9e9c474f4c1e9c8df GPL-3.0 # HEAD 2026-06-22
|
||||
ComfyUI-Frame-Interpolation https://github.com/Fannovel16/ComfyUI-Frame-Interpolation.git 26545cc2dd95bc3d27f056016300673bdeee78f5 MIT # HEAD 2026-03-29
|
||||
ComfyUI-VideoHelperSuite https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite.git 4ee72c065db22c9d96c2427954dc69e7b908444b GPL-3.0 # HEAD 2026-05-14
|
||||
rgthree-comfy https://github.com/rgthree/rgthree-comfy.git 27b4f4cdcf3b127c29d5d8135ac1536ecbd4c383 MIT # HEAD 2026-06-20
|
||||
ComfyUI-Custom-Scripts https://github.com/pythongosssss/ComfyUI-Custom-Scripts.git 609f3afaa74b2f88ef9ce8d939626065e3247469 MIT # HEAD 2026-02-12
|
||||
ComfyUI-KJNodes https://github.com/kijai/ComfyUI-KJNodes.git e27a505b3ba6ce42687fe00500deda103d9d6071 GPL-3.0 # HEAD 2026-07-01
|
||||
cg-use-everywhere https://github.com/chrisgoringe/cg-use-everywhere.git 632ed7bb51bb18ceb03ccaefe1f34be8bd416500 Apache-2.0 # HEAD 2026-06-22
|
||||
ComfyUI-segment-anything-2 https://github.com/kijai/ComfyUI-segment-anything-2.git 0c35fff5f382803e2310103357b5e985f5437f32 Apache-2.0 # HEAD 2025-09-28 SAM2
|
||||
comfyui_segment_anything https://github.com/storyicon/comfyui_segment_anything.git ab6395596399d5048639cdab7e44ec9fae857a93 Apache-2.0 # HEAD GroundingDINO+SAM
|
||||
ComfyUI-Inpaint-CropAndStitch https://github.com/lquesada/ComfyUI-Inpaint-CropAndStitch.git 3617559bcb9d15ff60b24c6800701402eb2cd478 GPL-3.0 # 2026-05-25 seam-invisible masked inpaint (couture_best dep)
|
||||
|
||||
# --- BAKEOFF-ONLY (cloned for the garment-swap bake-off; NOT auto-installed) ----
|
||||
# ComfyUI-CatvtonFluxWrapper https://github.com/lujiazho/ComfyUI-CatvtonFluxWrapper.git 06558c42c539397af8e4d221ddccea870de72d1f (no LICENSE file)
|
||||
# CatVTON-Flux diffusers wrapper. Hardcodes the GATED black-forest-labs/FLUX.1-Fill-dev
|
||||
# diffusers repo + CC-BY-NC CatVTON LoRA, so it won't load without HF auth. The winning
|
||||
# workflow (couture_best.json) reproduces CatVTON's in-context transfer with CORE nodes
|
||||
# + the catvton_flux_lora_alpha weights via LoraLoaderModelOnly, so this pack is NOT a
|
||||
# runtime dependency. Kept as a record of what was evaluated.
|
||||
|
||||
# --- QUARANTINED (not installed; kept here as a record) -------------------------
|
||||
# was-node-suite-comfyui https://github.com/WASasquatch/was-node-suite-comfyui.git ea935d1044ae5a26efa54ebeb18fe9020af49a45 MIT
|
||||
# Reason: hard `from numba import jit` at import; numba has no release supporting
|
||||
# the studio venv's NumPy 2.5 ("Numba needs NumPy 2.4 or less"). Re-enabling would
|
||||
# require downgrading NumPy (forbidden -- breaks the cu130 torch stack) or waiting
|
||||
# for a numba release that supports NumPy 2.5. Revisit then. License (MIT) is fine.
|
||||
|
||||
# --- 3D generation + texturing (image->3D mesh / gaussian-splat, PBR texture bake) ---
|
||||
# Packs install NODE/MODEL CODE ONLY -- all permissive (MIT/Apache). Model WEIGHTS are
|
||||
# NOT shipped here: each downloads its own weights at runtime from the model's source.
|
||||
# Where those weights are non-open, the friendly-ref NOTES it -- bring your own license.
|
||||
# The software runs whatever you point it at; it just tells you the terms. No gate.
|
||||
ComfyUI-3D-Pack https://github.com/MrForExample/ComfyUI-3D-Pack.git 9e8096e50c5bcf35e1f3e34c6ae06216101f8a11 MIT # TripoSR+InstantMesh+gaussian-splat+mesh/texture export (weights per-model, mostly MIT/Apache)
|
||||
Comfy3D_Pre_Builds https://github.com/MrForExample/Comfy3D_Pre_Builds.git d31ef4c84dfa47d789cc5e4af04992763e4e9fbd MIT # prebuilt native deps for ComfyUI-3D-Pack (nvdiffrast/pytorch3d)
|
||||
ComfyUI-Flowty-TripoSR https://github.com/flowtyone/ComfyUI-Flowty-TripoSR.git a0c94ac60a7cc062604f61aeeea6d0d493521de3 MIT # fast image->3D mesh; TripoSR weights MIT
|
||||
TRELLIS.2 https://github.com/microsoft/TRELLIS.2.git 75fbf0183001ed9876c8dbb35de6b68552ee08bd MIT # Microsoft TRELLIS.2 image->3D (SLAT + RFT), best-in-class; weights MIT
|
||||
Pixal3D https://github.com/TencentARC/Pixal3D.git cdbb2bbffbf4e6f298b5f2af3d1d76a8d823d2af MIT # TencentARC Pixal3D pixel-aligned image->3D (TRELLIS.2 core + DinoV3 + MoGe); MIT weights, commercial-clean
|
||||
ComfyUI-Hunyuan3DWrapper https://github.com/kijai/ComfyUI-Hunyuan3DWrapper.git 2609efa38f6a98292476f714839b7c1e5f9b699a Apache-2.0 # node code Apache; NOTICE: Hunyuan3D *weights* = Tencent Community License (no commercial EU/UK/KR, 100M-MAU cap) -- bring your own license
|
||||
|
||||
# --- music + song generation (text/lyrics -> full song w/ vocals + accompaniment) ---
|
||||
# Same rule: NODE CODE ONLY (permissive); model WEIGHTS download at runtime, BYO-license
|
||||
# where non-open. ACE-Step = the commercial-clean pick (Apache weights). LeVo (Tencent AI
|
||||
# Lab, best-quality per its paper) has an unsettled weights license -> noted, bring your own.
|
||||
ComfyUI_ACE-Step https://github.com/billwuhao/ComfyUI_ACE-Step.git d4c2dc104a6977c83c9406b62464102c757a879c Apache-2.0 # ACE-Step 3.5B text->music foundation model; node Apache + weights Apache-2.0 (commercial-clean)
|
||||
|
||||
# --- video generation (text/image -> video) ---
|
||||
ComfyUI-WanVideoWrapper https://github.com/kijai/ComfyUI-WanVideoWrapper.git 088128b224242e110d3906c6750e9a3a348a659b Apache-2.0 # WAN 2.1/2.2 t2v+i2v; node Apache + WAN weights Apache-2.0 (commercial-clean)
|
||||
ComfyUI-LTXVideo https://github.com/Lightricks/ComfyUI-LTXVideo.git aceeae9635f6d493f2893ba3c411a1c36031788a Apache-2.0 # LTX-Video fast t2v/i2v; node Apache, weights OpenRAIL-M (open, use-restrictions apply)
|
||||
ComfyUI-CogVideoXWrapper https://github.com/kijai/ComfyUI-CogVideoXWrapper.git fdb8abd2790b5459ddc7066c31861bb0b62e988b Apache-2.0 # CogVideoX t2v/i2v; node Apache, CogVideoX-2b Apache (5b = custom CogVideoX license, check)
|
||||
ComfyUI-HunyuanVideoWrapper https://github.com/kijai/ComfyUI-HunyuanVideoWrapper.git fcbd6729a9b0b8ff6037c598bbada4a6bdc6d967 Apache-2.0 # node Apache; NOTICE: HunyuanVideo *weights* = Tencent Community License (no commercial EU/UK/KR) -- bring your own license
|
||||
|
||||
# --- voice / TTS (text -> speech, voice clone) ---
|
||||
comfyui-kokoro https://github.com/stavsap/comfyui-kokoro.git cfb59b5605b351e196182c836f0b48c1a784e52f MIT # Kokoro-82M TTS; node MIT + weights Apache-2.0 (commercial-clean, fast)
|
||||
ComfyUI_ChatterBox_Voice https://github.com/ShmuelRonen/ComfyUI_ChatterBox_Voice.git 6e5fc4e268656235c50e1e791f6b5d7afe615677 MIT # Resemble Chatterbox TTS + voice clone; node MIT + weights MIT (commercial-clean)
|
||||
ComfyUI-F5-TTS https://github.com/niknah/ComfyUI-F5-TTS.git da9776ac5ba04f2d0b837f7dc6d5684499f3ba0b MIT # node MIT; NOTICE: F5-TTS *weights* = CC-BY-NC-4.0 (NON-COMMERCIAL) -- bring your own license for commercial
|
||||
|
||||
# --- infra: run GGUF-quantized diffusion/LLM models in-workflow (memory-efficient) ---
|
||||
ComfyUI-GGUF https://github.com/city96/ComfyUI-GGUF.git 6ea2651e7df66d7585f6ffee804b20e92fb38b8a Apache-2.0 # GGUF-quantized model loader (Flux/SD/video in low VRAM); Apache
|
||||
|
||||
# --- foley / sound-effects (text->SFX, video->audio) — the named gap ---
|
||||
ComfyUI-MMAudio https://github.com/kijai/ComfyUI-MMAudio.git 8eaeb72edc3aaf2059b57f2d96a1f6f689f19ae2 Apache-2.0 # MMAudio video->audio / text->SFX (Sony, CVPR2025); node Apache, NOTICE: weights CC-BY-NC-4.0 -- BYO-license for commercial
|
||||
ComfyUI-AudioLDM https://github.com/sanbuphy/ComfyUI-AudioLDM.git d1edf02527f1cdc5c037842ffed1a2b564aa52fc MIT # AudioLDM2 text->audio/SFX; node MIT, NOTICE: weights CC-BY-NC-SA-4.0 -- BYO-license for commercial
|
||||
|
||||
# --- audio source separation (music -> stems: vocals/drums/bass) ---
|
||||
audio-separation-nodes https://github.com/christian-byrne/audio-separation-nodes-comfyui.git ac339561973f0c1e56db2f9d40f11b0fddda6763 MIT # Demucs-based stem separation (vocals/drums/bass/other); node MIT + Demucs weights MIT (commercial-clean)
|
||||
|
||||
# --- voice/TTS (production, commercial-clean additions) ---
|
||||
ComfyUI-VibeVoice https://github.com/wildminder/ComfyUI-VibeVoice.git 99a98031d79e07f279a7aa6d01ce89042e8bba26 MIT # Microsoft VibeVoice long-form conversational TTS; node + weights MIT (commercial-clean)
|
||||
|
||||
# --- image gen: text-render + photorealism (permissive alternatives to FLUX) ---
|
||||
ComfyUI-KwaiKolorsWrapper https://github.com/kijai/ComfyUI-KwaiKolorsWrapper.git 6fc1cd9d20bb7537facf180e5494b486b9710e24 Apache-2.0 # Kwai Kolors t2i photorealism; node + weights Apache-2.0 (commercial-clean)
|
||||
|
||||
# --- video gen: permissive Apache alternatives ---
|
||||
ComfyUI-MochiWrapper https://github.com/kijai/ComfyUI-MochiWrapper.git e1bd05240ac31b72166e9d952c75dd5735352311 Apache-2.0 # Genmo Mochi-1 t2v high-quality; node + weights Apache-2.0 (commercial-clean)
|
||||
ComfyUI-PyramidFlowWrapper https://github.com/kijai/ComfyUI-PyramidFlowWrapper.git 426e3653048575dd1d1d49763a1e02fcfccf5078 Apache-2.0 # Pyramid-Flow t2v transformer-flow; node Apache, NOTICE: weights custom license -- verify for commercial
|
||||
|
||||
# --- music: long-form lyrics->song (Apache) ---
|
||||
ComfyUI_DiffRhythm https://github.com/billwuhao/ComfyUI_DiffRhythm.git 2601a33e58fd49c63451f82db6a1c70b7b1f09fe Apache-2.0 # DiffRhythm full-length lyrics->song (non-autoregressive, fast); node + weights Apache-2.0
|
||||
|
||||
# --- voice: streaming + emotional TTS (Apache) ---
|
||||
CosyVoice-ComfyUI https://github.com/AIFSH/CosyVoice-ComfyUI.git 67e650c761cd5306a4a06bc050196b84ab592aa3 Apache-2.0 # Alibaba CosyVoice2 real-time streaming + emotional TTS + zero-shot clone; node + weights Apache-2.0
|
||||
@@ -1,40 +1,80 @@
|
||||
# Hanzo Engine nodes
|
||||
|
||||
Custom nodes that call a local **Hanzo Engine** from a Hanzo Studio graph.
|
||||
Hanzo Engine is the native-Rust, all-modality inference and training backend
|
||||
(the training loop lives engine-side); these nodes talk to its
|
||||
OpenAI-compatible HTTP API.
|
||||
First-party Hanzo Studio nodes that run AI workloads on the native-Rust
|
||||
**Hanzo Engine** instead of in-process Python. Each node is one call to the
|
||||
engine's OpenAI-compatible HTTP API; the shared transport and Studio<->engine
|
||||
type conversions live in `client.py`, so every node stays a few lines and a new
|
||||
engine endpoint becomes a new small node with no framework change.
|
||||
|
||||
## Configuration
|
||||
|
||||
Set the engine base URL via environment variable (default shown):
|
||||
One way to point at an engine: the base URL, default `http://127.0.0.1:1234`,
|
||||
overridable by env. Paths are always `/v1/*` (never `/api/*`); a trailing `/v1`
|
||||
on the base URL is tolerated.
|
||||
|
||||
```bash
|
||||
export HANZO_ENGINE_URL=http://127.0.0.1:1234/v1
|
||||
export HANZO_ENGINE_URL=http://127.0.0.1:1234
|
||||
hanzo engine serve -m Qwen/Qwen3-4B --port 1234 # or any local path
|
||||
```
|
||||
|
||||
Connection or protocol failures surface as a clear error on the failing node —
|
||||
the server does not crash.
|
||||
Engine-down and protocol failures raise a clear error on the failing node; the
|
||||
server keeps running.
|
||||
|
||||
## Nodes (category: `Hanzo/Engine`)
|
||||
|
||||
| Node | Route | In → Out |
|
||||
|------|-------|----------|
|
||||
| **Hanzo Chat (Engine)** | `POST /v1/chat/completions` | model + prompt + system → `text` (STRING) |
|
||||
| **Hanzo Image Gen (Engine)** | `POST /v1/images/generations` | prompt + size + steps → `IMAGE` |
|
||||
| **Hanzo Vision Caption (Engine)** | `POST /v1/chat/completions` (vision) | `IMAGE` + prompt → `caption` (STRING) |
|
||||
| **Hanzo Save Text** | — | `text` (STRING) → writes `.txt` to `output/` |
|
||||
| Node | Endpoint | In -> Out |
|
||||
|------|----------|-----------|
|
||||
| **Hanzo Chat (Engine)** | `POST /v1/chat/completions` (streams) | model + prompt -> `text` |
|
||||
| **Hanzo Image Gen (Engine)** | `POST /v1/images/generations` | prompt + width/height -> `IMAGE` |
|
||||
| **Hanzo TTS (Engine)** | `POST /v1/audio/speech` | text -> `AUDIO` |
|
||||
| **Hanzo Music (Engine)** | `POST /v1/audio/music` | prompt -> `AUDIO` |
|
||||
| **Hanzo ASR (Engine)** | `POST /v1/audio/transcriptions` | `AUDIO` -> `text` |
|
||||
| **Hanzo Engine Request** | any `/v1` path | method + path + JSON -> `response` |
|
||||
| **Hanzo Vision Caption (Engine)** | `POST /v1/chat/completions` (vision) | `IMAGE` + prompt -> `caption` |
|
||||
| **Hanzo Text to Video (Engine)** | async `/v1/videos` | prompt -> `IMAGE` frames |
|
||||
| **Hanzo Image to 3D (Engine)** | async `/v1/3d` | `IMAGE` (+`texture`) -> mesh path |
|
||||
| **Hanzo Dub / Lip-Sync (Engine)** | `POST /v1/animate` | `IMAGE` + `AUDIO` -> `IMAGE` frames + `AUDIO` |
|
||||
| **Hanzo Save Text** | -- | `text` -> writes `output/*.txt` |
|
||||
|
||||
- **Hanzo Chat** — prompt engineering and caption/text chains inside a graph.
|
||||
- **Hanzo Image Gen** — decodes the engine's `b64_json` (or `url`) response into a
|
||||
Hanzo Studio `IMAGE` tensor. Requests `n` images at the chosen `size`.
|
||||
- **Hanzo Vision Caption** — encodes the input image as an OpenAI `image_url`
|
||||
data URL and asks a vision model (e.g. Qwen3-VL) to caption it. Use for
|
||||
LoRA/dataset prep.
|
||||
- **Hanzo Save Text** — writes captions to `output/` as `.txt` (image/caption
|
||||
pairs) and previews them in the node. Completes the caption-dataset loop.
|
||||
- **Chat** streams tokens over SSE, driving the node progress bar, and returns
|
||||
the full text (also previewed on the node). `model` is a dropdown populated
|
||||
from `/v1/models`; `default` always routes to the loaded model.
|
||||
- **Image Gen** sends the engine's `width`/`height`/`n` schema and decodes the
|
||||
`b64_json` (or `url`) response into a Studio `IMAGE`.
|
||||
- **TTS/Music/ASR** speak the Studio `AUDIO` type (`{waveform, sample_rate}`)
|
||||
and compose with the built-in Load/Save/Preview Audio nodes. TTS and Music
|
||||
serve `wav`/`pcm` (the engine's synchronous audio formats); Music is stereo
|
||||
44.1 kHz ACE-Step.
|
||||
- **Engine Request** is the forward-compatible escape hatch: reach any endpoint
|
||||
the day it ships, then promote it to a dedicated node here.
|
||||
- **Image to 3D** exposes the `texture` flag: on (default) runs the full
|
||||
SLAT + FlexiCubes stage for a textured GLB; off returns the coarse mesh.
|
||||
- **Text to Video / Image to 3D** use the engine's async job protocol
|
||||
(`submit -> poll -> fetch content`) and report progress. **Dub** is
|
||||
synchronous: it posts the portrait `IMAGE` + driving `AUDIO` and returns the
|
||||
animated frames plus the audio (mux them with a video-combine node).
|
||||
|
||||
## Requirements
|
||||
|
||||
Uses only libraries already present in Hanzo Studio: `requests`, `Pillow`,
|
||||
`numpy`, `torch`. No extra install.
|
||||
Only libraries already in Hanzo Studio: `requests`, `Pillow`, `numpy`, `torch`
|
||||
(always), plus `av` (loaded lazily, for the audio/video decode paths). WAV
|
||||
encode/decode uses the stdlib `wave` module. No extra install.
|
||||
|
||||
## Verification
|
||||
|
||||
`contract_check.py` drives every node through real HTTP against a stub engine
|
||||
that enforces the exact request fields each Rust handler deserializes and the
|
||||
async job protocol (submit -> poll -> content):
|
||||
|
||||
```bash
|
||||
python custom_nodes/hanzo_engine/contract_check.py
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
- `user/default/workflows/hanzo-native-pipeline.json` -- Chat writes an image
|
||||
prompt, Image Gen renders it, image + prompt are saved.
|
||||
- `user/default/workflows/hanzo-full-generative-pipeline.json` -- the flagship
|
||||
multimodal pipeline: Chat expands a prompt, which fans out to Image Gen ->
|
||||
(textured Image-to-3D **and** WAN Text-to-Video), plus TTS narration and
|
||||
ACE-Step Music. One graph, every modality, all on the native engine.
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Single client layer for the Hanzo Engine — the native-Rust, all-modality
|
||||
inference backend served over its OpenAI-compatible HTTP API.
|
||||
|
||||
Transport, config, and Studio<->engine type conversions live here so the nodes
|
||||
in `nodes.py` stay thin: one node == one endpoint. New engine endpoints
|
||||
(music, dub, ...) become a new small node over this same layer, no framework
|
||||
change.
|
||||
|
||||
Base URL resolves from the HANZO_ENGINE_URL env var (default
|
||||
http://127.0.0.1:1234); a trailing `/v1` is tolerated and stripped so callers
|
||||
always build `/v1/...` paths themselves. Engine-down and protocol errors raise
|
||||
EngineError, which Studio reports on the failing node instead of crashing the
|
||||
server.
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
DEFAULT_BASE_URL = "http://127.0.0.1:1234"
|
||||
POLL_INTERVAL_S = 2.0
|
||||
|
||||
|
||||
class EngineError(RuntimeError):
|
||||
"""Raised on any engine transport or protocol failure."""
|
||||
|
||||
|
||||
def base_url() -> str:
|
||||
raw = os.environ.get("HANZO_ENGINE_URL", DEFAULT_BASE_URL).strip().rstrip("/")
|
||||
if raw.endswith("/v1"):
|
||||
raw = raw[:-3]
|
||||
return raw or DEFAULT_BASE_URL
|
||||
|
||||
|
||||
def _url(path: str) -> str:
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
return f"{base_url()}{path}"
|
||||
|
||||
|
||||
def _down_msg(url: str, exc: Exception) -> str:
|
||||
return (
|
||||
f"Hanzo Engine request to {url} failed: {exc}. Is the engine running? "
|
||||
f"Start it with `hanzo engine serve` and set HANZO_ENGINE_URL "
|
||||
f"(default {DEFAULT_BASE_URL})."
|
||||
)
|
||||
|
||||
|
||||
def _json_or_raise(resp: requests.Response, url: str) -> dict:
|
||||
if not resp.ok:
|
||||
raise EngineError(f"Hanzo Engine {url} returned HTTP {resp.status_code}: {resp.text[:500]}")
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError as exc:
|
||||
raise EngineError(f"Hanzo Engine {url} returned a non-JSON response: {resp.text[:200]}") from exc
|
||||
|
||||
|
||||
# --- HTTP -------------------------------------------------------------------
|
||||
|
||||
def post_json(path: str, payload: dict, timeout: int = 120) -> dict:
|
||||
url = _url(path)
|
||||
try:
|
||||
resp = requests.post(url, json=payload, timeout=timeout)
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise EngineError(_down_msg(url, exc)) from exc
|
||||
return _json_or_raise(resp, url)
|
||||
|
||||
|
||||
def get_json(path: str, timeout: int = 30) -> dict:
|
||||
url = _url(path)
|
||||
try:
|
||||
resp = requests.get(url, timeout=timeout)
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise EngineError(_down_msg(url, exc)) from exc
|
||||
return _json_or_raise(resp, url)
|
||||
|
||||
|
||||
def post_bytes(path: str, payload: dict, timeout: int = 300) -> bytes:
|
||||
"""POST JSON, return the raw response body (endpoints that return binary,
|
||||
e.g. /v1/audio/speech)."""
|
||||
url = _url(path)
|
||||
try:
|
||||
resp = requests.post(url, json=payload, timeout=timeout)
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise EngineError(_down_msg(url, exc)) from exc
|
||||
if not resp.ok:
|
||||
raise EngineError(f"Hanzo Engine {url} returned HTTP {resp.status_code}: {resp.text[:300]}")
|
||||
return resp.content
|
||||
|
||||
|
||||
def post_multipart(path: str, files: dict, data: dict, timeout: int = 300) -> dict:
|
||||
url = _url(path)
|
||||
try:
|
||||
resp = requests.post(url, files=files, data=data, timeout=timeout)
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise EngineError(_down_msg(url, exc)) from exc
|
||||
return _json_or_raise(resp, url)
|
||||
|
||||
|
||||
def get_bytes(path_or_url: str, timeout: int = 120) -> bytes:
|
||||
url = path_or_url if path_or_url.startswith("http") else _url(path_or_url)
|
||||
try:
|
||||
resp = requests.get(url, timeout=timeout)
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise EngineError(f"Hanzo Engine fetch {url} failed: {exc}.") from exc
|
||||
if not resp.ok:
|
||||
raise EngineError(f"Hanzo Engine fetch {url} returned HTTP {resp.status_code}.")
|
||||
return resp.content
|
||||
|
||||
|
||||
def list_models() -> list:
|
||||
"""GET /v1/models -> [id, ...]. Returns [] if the engine is unreachable so
|
||||
node definitions never fail to load."""
|
||||
try:
|
||||
data = get_json("/v1/models", timeout=5)
|
||||
except EngineError:
|
||||
return []
|
||||
items = data.get("data") or []
|
||||
return [it["id"] for it in items if isinstance(it, dict) and it.get("id")]
|
||||
|
||||
|
||||
def stream_chat(payload: dict, timeout: int = 300):
|
||||
"""Yield content deltas from a streaming /v1/chat/completions (OpenAI SSE)."""
|
||||
url = _url("/v1/chat/completions")
|
||||
body = {**payload, "stream": True}
|
||||
try:
|
||||
resp = requests.post(url, json=body, stream=True, timeout=timeout)
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise EngineError(_down_msg(url, exc)) from exc
|
||||
if not resp.ok:
|
||||
raise EngineError(f"Hanzo Engine {url} returned HTTP {resp.status_code}: {resp.text[:500]}")
|
||||
with resp:
|
||||
for line in resp.iter_lines(decode_unicode=True):
|
||||
if not line or not line.startswith("data:"):
|
||||
continue
|
||||
chunk = line[len("data:"):].strip()
|
||||
if chunk == "[DONE]":
|
||||
break
|
||||
try:
|
||||
obj = json.loads(chunk)
|
||||
except ValueError:
|
||||
continue
|
||||
delta = ((obj.get("choices") or [{}])[0].get("delta") or {}).get("content")
|
||||
if delta:
|
||||
yield delta
|
||||
|
||||
|
||||
def extract_message_text(data: dict) -> str:
|
||||
try:
|
||||
content = (data["choices"][0].get("message") or {}).get("content")
|
||||
except (KeyError, IndexError, TypeError) as exc:
|
||||
raise EngineError(f"Unexpected Hanzo Engine response shape: {json.dumps(data)[:300]}") from exc
|
||||
if isinstance(content, list): # OpenAI structured content parts
|
||||
return "".join(p.get("text", "") for p in content
|
||||
if isinstance(p, dict) and p.get("type") == "text").strip()
|
||||
return (content or "").strip()
|
||||
|
||||
|
||||
def run_job(resource: str, payload: dict, job_timeout: int, progress=None) -> bytes:
|
||||
"""Submit an async generation job, poll to completion, return its content.
|
||||
|
||||
POST /v1/{resource} -> {"id": ...}
|
||||
GET /v1/{resource}/{id} -> {"status": ..., "progress"?: ...}
|
||||
GET /v1/{resource}/{id}/content -> raw bytes
|
||||
"""
|
||||
submit = post_json(f"/v1/{resource}", payload, timeout=60)
|
||||
job_id = submit.get("id")
|
||||
if not job_id:
|
||||
raise EngineError(f"Hanzo Engine /v1/{resource} did not return a job id: {submit}")
|
||||
deadline = time.monotonic() + job_timeout
|
||||
while True:
|
||||
body = get_json(f"/v1/{resource}/{job_id}", timeout=60)
|
||||
status = str(body.get("status", "")).lower()
|
||||
if status == "completed":
|
||||
break
|
||||
if status in {"failed", "cancelled", "error"}:
|
||||
msg = body.get("error") or body.get("message") or status
|
||||
raise EngineError(f"Hanzo Engine {resource} job {job_id} {status}: {msg}")
|
||||
if progress is not None:
|
||||
progress(body.get("progress"))
|
||||
if time.monotonic() > deadline:
|
||||
raise EngineError(
|
||||
f"Hanzo Engine {resource} job {job_id} did not complete within "
|
||||
f"{job_timeout}s (last status: {status or 'unknown'})."
|
||||
)
|
||||
time.sleep(POLL_INTERVAL_S)
|
||||
return get_bytes(f"/v1/{resource}/{job_id}/content", timeout=job_timeout)
|
||||
|
||||
|
||||
def progress_bar(total: int):
|
||||
try:
|
||||
from comfy.utils import ProgressBar
|
||||
|
||||
return ProgressBar(total)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# --- Image conversions ------------------------------------------------------
|
||||
|
||||
def tensor_to_pil(image: torch.Tensor) -> Image.Image:
|
||||
if image.dim() == 4: # [B,H,W,C] -> first frame
|
||||
image = image[0]
|
||||
arr = (image.cpu().numpy() * 255.0).clip(0, 255).astype(np.uint8)
|
||||
return Image.fromarray(arr)
|
||||
|
||||
|
||||
def pil_to_tensor(img: Image.Image) -> torch.Tensor:
|
||||
arr = np.array(img.convert("RGB")).astype(np.float32) / 255.0
|
||||
return torch.from_numpy(arr)[None,] # [1,H,W,C]
|
||||
|
||||
|
||||
def pil_to_data_url(img: Image.Image) -> str:
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
def tensor_to_data_url(image: torch.Tensor) -> str:
|
||||
return pil_to_data_url(tensor_to_pil(image))
|
||||
|
||||
|
||||
def _decode_image_item(item: dict) -> Image.Image:
|
||||
b64 = item.get("b64_json")
|
||||
if b64:
|
||||
raw = base64.b64decode(b64)
|
||||
else:
|
||||
url = item.get("url")
|
||||
if not url:
|
||||
raise EngineError("Hanzo Engine image item has neither 'b64_json' nor 'url'.")
|
||||
raw = base64.b64decode(url.split(",", 1)[1]) if url.startswith("data:") else get_bytes(url)
|
||||
return Image.open(io.BytesIO(raw)).convert("RGB")
|
||||
|
||||
|
||||
def images_to_batch(items: list) -> torch.Tensor:
|
||||
if not items:
|
||||
raise EngineError("Hanzo Engine returned no image data.")
|
||||
tensors = [pil_to_tensor(_decode_image_item(it)) for it in items]
|
||||
if len({t.shape for t in tensors}) == 1:
|
||||
return torch.cat(tensors, dim=0)
|
||||
return tensors[0]
|
||||
|
||||
|
||||
def mp4_bytes_to_image_batch(data: bytes) -> torch.Tensor:
|
||||
import av
|
||||
|
||||
frames = []
|
||||
with av.open(io.BytesIO(data)) as container:
|
||||
for frame in container.decode(video=0):
|
||||
frames.append(frame.to_ndarray(format="rgb24"))
|
||||
if not frames:
|
||||
raise EngineError("Hanzo Engine video job returned a video with no frames.")
|
||||
arr = np.stack(frames).astype(np.float32) / 255.0
|
||||
return torch.from_numpy(arr)
|
||||
|
||||
|
||||
# --- Audio conversions (Studio AUDIO == {waveform:[B,C,T], sample_rate}) -----
|
||||
|
||||
def _f32_pcm(wav: torch.Tensor) -> torch.Tensor:
|
||||
if wav.dtype.is_floating_point:
|
||||
return wav
|
||||
if wav.dtype == torch.int16:
|
||||
return wav.float() / (2 ** 15)
|
||||
if wav.dtype == torch.int32:
|
||||
return wav.float() / (2 ** 31)
|
||||
return wav.float()
|
||||
|
||||
|
||||
def audio_bytes_to_studio(data: bytes) -> dict:
|
||||
"""Decode engine speech bytes (any container) to a Studio AUDIO dict."""
|
||||
import av
|
||||
|
||||
chunks, sr, n_ch = [], 24000, 1
|
||||
with av.open(io.BytesIO(data)) as af:
|
||||
if not af.streams.audio:
|
||||
raise EngineError("Hanzo Engine speech response has no audio stream.")
|
||||
stream = af.streams.audio[0]
|
||||
sr = stream.codec_context.sample_rate or sr
|
||||
n_ch = stream.channels or 1
|
||||
for frame in af.decode(streams=stream.index):
|
||||
buf = torch.from_numpy(frame.to_ndarray())
|
||||
if buf.shape[0] != n_ch:
|
||||
buf = buf.view(-1, n_ch).t()
|
||||
chunks.append(buf)
|
||||
wav = torch.cat(chunks, dim=1) if chunks else torch.zeros(n_ch, 0)
|
||||
return {"waveform": _f32_pcm(wav).unsqueeze(0), "sample_rate": int(sr)}
|
||||
|
||||
|
||||
def studio_audio_to_wav_bytes(audio: dict) -> bytes:
|
||||
"""Encode a Studio AUDIO dict ({waveform:[B,C,T], sample_rate}) to 16-bit PCM
|
||||
WAV bytes for multipart upload (ASR) or a data URL (dub). Stdlib `wave` only."""
|
||||
import wave
|
||||
|
||||
wav = audio["waveform"]
|
||||
if wav.dim() == 3: # [B,C,T] -> [C,T]
|
||||
wav = wav[0]
|
||||
arr = wav.detach().cpu().float().clamp_(-1.0, 1.0).numpy() # [C,T]
|
||||
pcm16 = (arr * 32767.0).astype(np.int16)
|
||||
interleaved = pcm16.T.reshape(-1).tobytes() # [T,C] interleaved
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as w:
|
||||
w.setnchannels(arr.shape[0])
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(int(audio["sample_rate"]))
|
||||
w.writeframes(interleaved)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def studio_audio_to_data_url(audio: dict) -> str:
|
||||
"""Encode a Studio AUDIO dict to a `data:audio/wav` URL (driving audio for
|
||||
/v1/animate, which takes audio as an http/file/data URL)."""
|
||||
return "data:audio/wav;base64," + base64.b64encode(studio_audio_to_wav_bytes(audio)).decode("ascii")
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Contract test: drive every Hanzo Engine node through REAL HTTP against a stub
|
||||
engine that enforces the exact request fields the Rust handlers deserialize
|
||||
(ThreeDGenerationRequest, VideoGenerationRequest, AnimateRequest, Speech/Music
|
||||
requests) and the async-job protocol (submit -> poll -> content). This is a real
|
||||
request/response cycle over sockets + the requests lib + client.run_job polling,
|
||||
not a monkeypatched mock. Run: python contract_check.py
|
||||
"""
|
||||
# ruff: noqa: T201 (a CLI harness prints its results)
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
# Stub out the ComfyUI-side modules the node pack imports so nodes.py loads.
|
||||
fp = types.ModuleType("folder_paths")
|
||||
fp.get_output_directory = lambda: "/tmp"
|
||||
fp.get_save_image_path = lambda pre, d: (d, pre, 1, "", "")
|
||||
sys.modules["folder_paths"] = fp
|
||||
|
||||
# Import the pack as a package so its `from . import client` resolves.
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from hanzo_engine import nodes # noqa: E402
|
||||
|
||||
RECORDED = {} # path -> last JSON/body seen
|
||||
|
||||
|
||||
def _wav_bytes(seconds=0.1, sr=44100, ch=2):
|
||||
import wave
|
||||
n = int(seconds * sr)
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as w:
|
||||
w.setnchannels(ch)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(sr)
|
||||
w.writeframes(b"\x00\x00" * n * ch)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _png_b64():
|
||||
from PIL import Image
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (8, 8), (128, 64, 32)).save(buf, format="PNG")
|
||||
return base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
def _mp4_bytes(frames=3, w=16, h=16):
|
||||
import av
|
||||
buf = io.BytesIO()
|
||||
container = av.open(buf, mode="w", format="mp4")
|
||||
stream = container.add_stream("libx264", rate=25)
|
||||
stream.width, stream.height, stream.pix_fmt = w, h, "yuv420p"
|
||||
for i in range(frames):
|
||||
arr = np.full((h, w, 3), i * 20 % 255, dtype=np.uint8)
|
||||
frame = av.VideoFrame.from_ndarray(arr, format="rgb24")
|
||||
for pkt in stream.encode(frame):
|
||||
container.mux(pkt)
|
||||
for pkt in stream.encode():
|
||||
container.mux(pkt)
|
||||
container.close()
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def _send(self, code, body, ctype="application/json"):
|
||||
if isinstance(body, (dict, list)):
|
||||
body = json.dumps(body).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _body_json(self):
|
||||
n = int(self.headers.get("Content-Length", 0))
|
||||
raw = self.rfile.read(n) if n else b"{}"
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except ValueError:
|
||||
return {"_raw": raw}
|
||||
|
||||
def do_GET(self):
|
||||
p = self.path
|
||||
if p == "/v1/models":
|
||||
return self._send(200, {"data": [{"id": "stub-model"}]})
|
||||
if p.startswith("/v1/3d/") and p.endswith("/content"):
|
||||
return self._send(200, base64.b64decode(_PLACEHOLDER_GLB_B64), "model/gltf-binary")
|
||||
if p.startswith("/v1/videos/") and p.endswith("/content"):
|
||||
return self._send(200, _mp4_bytes(), "video/mp4")
|
||||
if p.startswith("/v1/3d/") or p.startswith("/v1/videos/"):
|
||||
# poll: report completed immediately
|
||||
return self._send(200, {"id": p.split("/")[3], "status": "completed", "progress": 1.0})
|
||||
return self._send(404, {"message": "no"})
|
||||
|
||||
def do_POST(self):
|
||||
try:
|
||||
self._do_POST()
|
||||
except Exception as exc: # surface stub-side errors to the client
|
||||
self._send(500, {"stub_error": repr(exc)})
|
||||
|
||||
def _do_POST(self):
|
||||
p = self.path
|
||||
# multipart (ASR) is not JSON
|
||||
if p == "/v1/audio/transcriptions":
|
||||
n = int(self.headers.get("Content-Length", 0))
|
||||
_ = self.rfile.read(n)
|
||||
RECORDED[p] = {"multipart": True}
|
||||
return self._send(200, {"text": "stub transcript"})
|
||||
body = self._body_json()
|
||||
RECORDED[p] = body
|
||||
if p == "/v1/chat/completions":
|
||||
return self._send(200, {"choices": [{"message": {"content": "expanded prompt"}}]})
|
||||
if p == "/v1/images/generations":
|
||||
return self._send(200, {"data": [{"b64_json": _png_b64()}]})
|
||||
if p == "/v1/audio/speech":
|
||||
assert "input" in body and "model" in body, "speech needs input+model"
|
||||
return self._send(200, _wav_bytes(ch=1, sr=24000), "audio/wav")
|
||||
if p == "/v1/audio/music":
|
||||
assert "input" in body and "model" in body, "music needs input+model"
|
||||
return self._send(200, _wav_bytes(ch=2, sr=44100), "audio/wav")
|
||||
if p == "/v1/animate":
|
||||
assert set(body) >= {"model", "audio", "visual"}, "animate needs model+audio+visual"
|
||||
assert body["visual"].startswith("data:image/"), "visual must be an image data url"
|
||||
assert body["audio"].startswith("data:audio/"), "audio must be an audio data url"
|
||||
return self._send(200, _mp4_bytes(), "video/mp4")
|
||||
if p == "/v1/videos":
|
||||
assert "prompt" in body, "video needs prompt"
|
||||
return self._send(202, {"id": "vid-1", "status": "queued"})
|
||||
if p == "/v1/3d":
|
||||
assert "image" in body, "3d needs image"
|
||||
return self._send(202, {"id": "threed-1", "status": "queued", "progress": 0.0})
|
||||
return self._send(404, {"message": "no"})
|
||||
|
||||
|
||||
# A 1-triangle binary glTF is overkill; the node just writes bytes to disk, so any
|
||||
# non-empty payload proves the content fetch + save path. Use a tiny placeholder.
|
||||
_PLACEHOLDER_GLB_B64 = base64.b64encode(b"glTF\x02\x00\x00\x00stub-mesh").decode("ascii")
|
||||
|
||||
|
||||
def main():
|
||||
srv = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
port = srv.server_address[1]
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
import os
|
||||
os.environ["HANZO_ENGINE_URL"] = f"http://127.0.0.1:{port}"
|
||||
|
||||
img = torch.rand(1, 8, 8, 3)
|
||||
audio = {"waveform": torch.zeros(1, 2, 4410), "sample_rate": 44100}
|
||||
passed = []
|
||||
|
||||
# Chat (non-stream path)
|
||||
(text,) = nodes.HanzoChat().generate(model="default", prompt="a red dragon", stream=False)["result"]
|
||||
assert text == "expanded prompt", text
|
||||
assert RECORDED["/v1/chat/completions"]["messages"][-1]["content"] == "a red dragon"
|
||||
passed.append("HanzoChat -> /v1/chat/completions")
|
||||
|
||||
# ImageGen
|
||||
(imgs,) = nodes.HanzoImageGen().generate("a red dragon", 64, 64, n=1)
|
||||
assert imgs.shape[-1] == 3
|
||||
passed.append("HanzoImageGen -> /v1/images/generations")
|
||||
|
||||
# TTS
|
||||
(aud,) = nodes.HanzoTTS().speak("hello world", response_format="wav")
|
||||
assert "waveform" in aud and aud["sample_rate"] == 24000
|
||||
passed.append("HanzoTTS -> /v1/audio/speech")
|
||||
|
||||
# Music (NEW)
|
||||
(music,) = nodes.HanzoMusic().compose("epic orchestral", response_format="wav")
|
||||
assert music["sample_rate"] == 44100 and music["waveform"].shape[1] == 2, music["waveform"].shape
|
||||
assert RECORDED["/v1/audio/music"]["input"] == "epic orchestral"
|
||||
passed.append("HanzoMusic -> /v1/audio/music (input field, stereo 44.1k)")
|
||||
|
||||
# ASR
|
||||
(tr,) = nodes.HanzoASR().transcribe(audio)
|
||||
assert tr == "stub transcript"
|
||||
passed.append("HanzoASR -> /v1/audio/transcriptions")
|
||||
|
||||
# ImageTo3D (texture flag fix) -- async job submit+poll+content
|
||||
out = nodes.HanzoImageTo3D().generate(img, "glb", True, 0, 4)
|
||||
assert RECORDED["/v1/3d"]["texture"] is True, "texture flag not sent"
|
||||
assert RECORDED["/v1/3d"]["format"] == "glb"
|
||||
assert out["result"][0].endswith(".glb")
|
||||
passed.append("HanzoImageTo3D -> /v1/3d (texture=true sent; job submit->poll->content)")
|
||||
|
||||
# TextToVideo -- async job
|
||||
(frames,) = nodes.HanzoTextToVideo().generate("a red dragon flying", 3, 16, 16, 4)
|
||||
assert frames.dim() == 4 and frames.shape[0] >= 1
|
||||
assert RECORDED["/v1/videos"]["num_frames"] == 3
|
||||
passed.append("HanzoTextToVideo -> /v1/videos (job submit->poll->content)")
|
||||
|
||||
# Dub (NEW) -- synchronous mp4
|
||||
(dframes, daudio) = nodes.HanzoDub().dub(img, audio, fps=25.0)
|
||||
assert dframes.dim() == 4
|
||||
assert daudio is audio
|
||||
passed.append("HanzoDub -> /v1/animate (image+audio data urls; frames+audio out)")
|
||||
|
||||
srv.shutdown()
|
||||
print("\n".join(f"PASS {p}" for p in passed))
|
||||
print(f"\nALL {len(passed)} NODE CONTRACT CHECKS PASSED against a live HTTP engine stub.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+347
-143
@@ -1,197 +1,236 @@
|
||||
"""Hanzo Engine nodes for Hanzo Studio.
|
||||
|
||||
These nodes call a local Hanzo Engine — the native-Rust, all-modality inference
|
||||
and training backend — over its OpenAI-compatible HTTP API. The base URL comes
|
||||
from the HANZO_ENGINE_URL environment variable (default http://127.0.0.1:1234/v1).
|
||||
Each node is one endpoint of the native-Rust Hanzo Engine, over the shared
|
||||
client in `client.py`. Config is one way: the engine base URL (default
|
||||
http://127.0.0.1:1234, override with HANZO_ENGINE_URL). Paths are always
|
||||
`/v1/*` — never `/api/*`.
|
||||
|
||||
Connection and protocol errors surface as clear node errors (raised
|
||||
RuntimeError), which Hanzo Studio reports on the failing node rather than
|
||||
crashing the server.
|
||||
HanzoChat -> POST /v1/chat/completions (prompt -> text)
|
||||
HanzoImageGen -> POST /v1/images/generations (prompt -> IMAGE)
|
||||
HanzoTTS -> POST /v1/audio/speech (text -> AUDIO)
|
||||
HanzoMusic -> POST /v1/audio/music (prompt -> AUDIO)
|
||||
HanzoASR -> POST /v1/audio/transcriptions (AUDIO -> text)
|
||||
HanzoEngineRequest -> POST|GET <any /v1 path> (JSON -> response)
|
||||
HanzoVisionCaption -> POST /v1/chat/completions (vision) (IMAGE -> text)
|
||||
HanzoTextToVideo -> async /v1/videos (prompt -> frames)
|
||||
HanzoImageTo3D -> async /v1/3d (IMAGE -> mesh path)
|
||||
HanzoDub -> POST /v1/animate (IMAGE+AUDIO -> frames)
|
||||
HanzoSaveText -> writes a STRING to output/*.txt
|
||||
|
||||
The generic HanzoEngineRequest is the forward-compatible escape hatch: a new
|
||||
engine endpoint (music, dub, ...) is reachable the day it ships, and promoting
|
||||
it to a first-class node is a few lines here.
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
import folder_paths
|
||||
|
||||
DEFAULT_BASE_URL = "http://127.0.0.1:1234/v1"
|
||||
from . import client
|
||||
|
||||
CATEGORY = "Hanzo/Engine"
|
||||
|
||||
|
||||
def _base_url() -> str:
|
||||
return os.environ.get("HANZO_ENGINE_URL", DEFAULT_BASE_URL).rstrip("/")
|
||||
|
||||
|
||||
def _post(path: str, payload: dict, timeout: int = 120) -> dict:
|
||||
url = f"{_base_url()}{path}"
|
||||
try:
|
||||
resp = requests.post(url, json=payload, timeout=timeout)
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise RuntimeError(
|
||||
f"Hanzo Engine request to {url} failed: {exc}. Is the engine running? "
|
||||
f"Set HANZO_ENGINE_URL (default {DEFAULT_BASE_URL})."
|
||||
) from exc
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(
|
||||
f"Hanzo Engine {url} returned HTTP {resp.status_code}: {resp.text[:500]}"
|
||||
)
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(
|
||||
f"Hanzo Engine {url} returned a non-JSON response: {resp.text[:200]}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _get_bytes(url: str, timeout: int = 120) -> bytes:
|
||||
try:
|
||||
resp = requests.get(url, timeout=timeout)
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise RuntimeError(f"Hanzo Engine image fetch from {url} failed: {exc}.") from exc
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"Hanzo Engine image fetch {url} returned HTTP {resp.status_code}.")
|
||||
return resp.content
|
||||
|
||||
|
||||
def _extract_message_text(data: dict) -> str:
|
||||
try:
|
||||
content = (data["choices"][0].get("message") or {}).get("content")
|
||||
except (KeyError, IndexError, TypeError) as exc:
|
||||
raise RuntimeError(
|
||||
f"Unexpected Hanzo Engine response shape: {json.dumps(data)[:300]}"
|
||||
) from exc
|
||||
if isinstance(content, list): # OpenAI structured content parts
|
||||
parts = [p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"]
|
||||
return "".join(parts).strip()
|
||||
return (content or "").strip()
|
||||
|
||||
|
||||
def _tensor_to_pil(image: torch.Tensor) -> Image.Image:
|
||||
if image.dim() == 4: # [B,H,W,C] -> first frame
|
||||
image = image[0]
|
||||
arr = (image.cpu().numpy() * 255.0).clip(0, 255).astype(np.uint8)
|
||||
return Image.fromarray(arr)
|
||||
|
||||
|
||||
def _pil_to_tensor(img: Image.Image) -> torch.Tensor:
|
||||
arr = np.array(img.convert("RGB")).astype(np.float32) / 255.0
|
||||
return torch.from_numpy(arr)[None,] # [1,H,W,C]
|
||||
|
||||
|
||||
def _pil_to_data_url(img: Image.Image) -> str:
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
return f"data:image/png;base64,{b64}"
|
||||
|
||||
|
||||
def _decode_image_item(item: dict) -> Image.Image:
|
||||
b64 = item.get("b64_json")
|
||||
if b64:
|
||||
return Image.open(io.BytesIO(base64.b64decode(b64))).convert("RGB")
|
||||
url = item.get("url")
|
||||
if url:
|
||||
if url.startswith("data:"):
|
||||
raw = base64.b64decode(url.split(",", 1)[1])
|
||||
else:
|
||||
raw = _get_bytes(url)
|
||||
return Image.open(io.BytesIO(raw)).convert("RGB")
|
||||
raise RuntimeError("Hanzo Engine image item has neither 'b64_json' nor 'url'.")
|
||||
def _model_combo() -> list:
|
||||
"""["default", <ids from /v1/models>] — 'default' always routes to the
|
||||
loaded model, so saved graphs stay portable across engines."""
|
||||
combo, seen = [], set()
|
||||
for name in ["default", *client.list_models()]:
|
||||
if name not in seen:
|
||||
seen.add(name)
|
||||
combo.append(name)
|
||||
return combo
|
||||
|
||||
|
||||
class HanzoChat:
|
||||
"""Text chat via Hanzo Engine /v1/chat/completions. Use for prompt
|
||||
engineering, caption cleanup, and text chains inside a graph."""
|
||||
"""LLM chat/completion. Streams tokens (live progress) and returns the
|
||||
full text; feeds prompt chains and image prompts inside a graph."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"model": ("STRING", {"default": "default"}),
|
||||
"model": (_model_combo(),),
|
||||
"prompt": ("STRING", {"multiline": True, "default": ""}),
|
||||
},
|
||||
"optional": {
|
||||
"system": ("STRING", {"multiline": True, "default": ""}),
|
||||
"temperature": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 2.0, "step": 0.05}),
|
||||
"max_tokens": ("INT", {"default": 512, "min": 1, "max": 32768}),
|
||||
"stream": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("text",)
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "Hanzo/Engine"
|
||||
CATEGORY = CATEGORY
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def generate(self, model, prompt, system="", temperature=0.7, max_tokens=512):
|
||||
def generate(self, model, prompt, system="", temperature=0.7, max_tokens=512, stream=True):
|
||||
messages = []
|
||||
if system.strip():
|
||||
messages.append({"role": "system", "content": system})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
data = _post(
|
||||
"/chat/completions",
|
||||
{"model": model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens},
|
||||
)
|
||||
return (_extract_message_text(data),)
|
||||
payload = {"model": model, "messages": messages,
|
||||
"temperature": temperature, "max_tokens": max_tokens}
|
||||
if stream:
|
||||
pbar = client.progress_bar(max_tokens)
|
||||
parts = []
|
||||
for i, delta in enumerate(client.stream_chat(payload)):
|
||||
parts.append(delta)
|
||||
if pbar:
|
||||
pbar.update_absolute(min(i + 1, max_tokens))
|
||||
text = "".join(parts).strip()
|
||||
else:
|
||||
text = client.extract_message_text(client.post_json("/v1/chat/completions", payload))
|
||||
return {"ui": {"text": [text]}, "result": (text,)}
|
||||
|
||||
|
||||
class HanzoImageGen:
|
||||
"""Text-to-image via Hanzo Engine /v1/images/generations. Returns a
|
||||
Hanzo Studio IMAGE tensor."""
|
||||
"""Text-to-image via /v1/images/generations (native Rust). Returns a
|
||||
Studio IMAGE tensor."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"prompt": ("STRING", {"multiline": True, "default": ""}),
|
||||
"size": (["1024x1024", "1024x1536", "1536x1024", "768x768", "512x512"], {"default": "1024x1024"}),
|
||||
"width": ("INT", {"default": 1024, "min": 64, "max": 4096, "step": 8}),
|
||||
"height": ("INT", {"default": 1024, "min": 64, "max": 4096, "step": 8}),
|
||||
},
|
||||
"optional": {
|
||||
"model": ("STRING", {"default": "default"}),
|
||||
"steps": ("INT", {"default": 20, "min": 1, "max": 150}),
|
||||
"model": (_model_combo(),),
|
||||
"n": ("INT", {"default": 1, "min": 1, "max": 8}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "Hanzo/Engine"
|
||||
CATEGORY = CATEGORY
|
||||
|
||||
def generate(self, prompt, size, model="default", steps=20, n=1):
|
||||
data = _post(
|
||||
"/images/generations",
|
||||
{"model": model, "prompt": prompt, "size": size, "n": n, "steps": steps, "response_format": "b64_json"},
|
||||
def generate(self, prompt, width, height, model="default", n=1):
|
||||
data = client.post_json(
|
||||
"/v1/images/generations",
|
||||
{"model": model, "prompt": prompt, "width": width, "height": height,
|
||||
"n": n, "response_format": "b64_json"},
|
||||
timeout=600,
|
||||
)
|
||||
items = data.get("data") or []
|
||||
if not items:
|
||||
raise RuntimeError("Hanzo Engine /images/generations returned no image data.")
|
||||
tensors = [_pil_to_tensor(_decode_image_item(it)) for it in items]
|
||||
if len(tensors) == 1:
|
||||
batch = tensors[0]
|
||||
return (client.images_to_batch(data.get("data") or []),)
|
||||
|
||||
|
||||
class HanzoTTS:
|
||||
"""Text-to-speech via /v1/audio/speech (native Rust). Returns a Studio
|
||||
AUDIO you can save/preview with the built-in audio nodes."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING", {"multiline": True, "default": ""}),
|
||||
},
|
||||
"optional": {
|
||||
"model": (_model_combo(),),
|
||||
"response_format": (["wav", "pcm"], {"default": "wav"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("AUDIO",)
|
||||
RETURN_NAMES = ("audio",)
|
||||
FUNCTION = "speak"
|
||||
CATEGORY = CATEGORY
|
||||
|
||||
def speak(self, text, model="default", response_format="wav"):
|
||||
raw = client.post_bytes(
|
||||
"/v1/audio/speech",
|
||||
{"model": model, "input": text, "response_format": response_format},
|
||||
)
|
||||
return (client.audio_bytes_to_studio(raw),)
|
||||
|
||||
|
||||
class HanzoASR:
|
||||
"""Speech-to-text via /v1/audio/transcriptions (OpenAI multipart). Returns
|
||||
the transcript text."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"audio": ("AUDIO",),
|
||||
},
|
||||
"optional": {
|
||||
"model": (_model_combo(),),
|
||||
"language": ("STRING", {"default": ""}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("text",)
|
||||
FUNCTION = "transcribe"
|
||||
CATEGORY = CATEGORY
|
||||
|
||||
def transcribe(self, audio, model="default", language=""):
|
||||
wav = client.studio_audio_to_wav_bytes(audio)
|
||||
data = {"model": model, "response_format": "json"}
|
||||
if language.strip():
|
||||
data["language"] = language.strip()
|
||||
resp = client.post_multipart(
|
||||
"/v1/audio/transcriptions",
|
||||
files={"file": ("audio.wav", wav, "audio/wav")},
|
||||
data=data,
|
||||
)
|
||||
return ((resp.get("text") or "").strip(),)
|
||||
|
||||
|
||||
class HanzoEngineRequest:
|
||||
"""Generic engine call: any `/v1` path with a JSON body -> the raw JSON
|
||||
response as STRING. The forward-compatible escape hatch for endpoints that
|
||||
do not have a dedicated node yet (music, dub, ...)."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"method": (["POST", "GET"], {"default": "POST"}),
|
||||
"path": ("STRING", {"default": "/v1/chat/completions"}),
|
||||
"json_body": ("STRING", {"multiline": True, "default": "{}"}),
|
||||
},
|
||||
"optional": {
|
||||
"timeout": ("INT", {"default": 120, "min": 1, "max": 3600}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("response",)
|
||||
FUNCTION = "call"
|
||||
CATEGORY = CATEGORY
|
||||
|
||||
def call(self, method, path, json_body, timeout=120):
|
||||
try:
|
||||
payload = json.loads(json_body or "{}")
|
||||
except ValueError as exc:
|
||||
raise client.EngineError(f"HanzoEngineRequest json_body is not valid JSON: {exc}") from exc
|
||||
if method == "GET":
|
||||
data = client.get_json(path, timeout=timeout)
|
||||
else:
|
||||
same = len({t.shape for t in tensors}) == 1
|
||||
batch = torch.cat(tensors, dim=0) if same else tensors[0]
|
||||
return (batch,)
|
||||
data = client.post_json(path, payload, timeout=timeout)
|
||||
return (json.dumps(data, indent=2),)
|
||||
|
||||
|
||||
class HanzoVisionCaption:
|
||||
"""Caption an IMAGE via Hanzo Engine vision chat (OpenAI image_url format).
|
||||
Feeds LoRA/dataset prep loops; pair with a vision model such as Qwen3-VL."""
|
||||
"""Caption an IMAGE via vision chat (OpenAI image_url). Pair with a vision
|
||||
model (e.g. Qwen3-VL) for LoRA/dataset prep."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"prompt": ("STRING", {"multiline": True, "default": "Describe this image in detail for a training caption."}),
|
||||
"prompt": ("STRING", {"multiline": True,
|
||||
"default": "Describe this image in detail for a training caption."}),
|
||||
},
|
||||
"optional": {
|
||||
"model": ("STRING", {"default": "default"}),
|
||||
"model": (_model_combo(),),
|
||||
"system": ("STRING", {"multiline": True, "default": ""}),
|
||||
"max_tokens": ("INT", {"default": 512, "min": 1, "max": 32768}),
|
||||
},
|
||||
@@ -200,27 +239,178 @@ class HanzoVisionCaption:
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("caption",)
|
||||
FUNCTION = "caption"
|
||||
CATEGORY = "Hanzo/Engine"
|
||||
CATEGORY = CATEGORY
|
||||
|
||||
def caption(self, image, prompt, model="default", system="", max_tokens=512):
|
||||
data_url = _pil_to_data_url(_tensor_to_pil(image))
|
||||
content = [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "image_url", "image_url": {"url": client.tensor_to_data_url(image)}},
|
||||
]
|
||||
messages = []
|
||||
if system.strip():
|
||||
messages.append({"role": "system", "content": system})
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "image_url", "image_url": {"url": data_url}},
|
||||
],
|
||||
})
|
||||
data = _post("/chat/completions", {"model": model, "messages": messages, "max_tokens": max_tokens}, timeout=300)
|
||||
return (_extract_message_text(data),)
|
||||
messages.append({"role": "user", "content": content})
|
||||
data = client.post_json(
|
||||
"/v1/chat/completions",
|
||||
{"model": model, "messages": messages, "max_tokens": max_tokens},
|
||||
timeout=300,
|
||||
)
|
||||
return (client.extract_message_text(data),)
|
||||
|
||||
|
||||
class HanzoTextToVideo:
|
||||
"""Text-to-video via the async job /v1/videos (native Rust). Returns the
|
||||
decoded frames as an IMAGE batch so it composes with video-save nodes."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"prompt": ("STRING", {"multiline": True, "default": ""}),
|
||||
"num_frames": ("INT", {"default": 81, "min": 1, "max": 1024}),
|
||||
"width": ("INT", {"default": 832, "min": 64, "max": 2048, "step": 8}),
|
||||
"height": ("INT", {"default": 480, "min": 64, "max": 2048, "step": 8}),
|
||||
"steps": ("INT", {"default": 30, "min": 1, "max": 150}),
|
||||
},
|
||||
"optional": {
|
||||
"model": (_model_combo(),),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("frames",)
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = CATEGORY
|
||||
|
||||
def generate(self, prompt, num_frames, width, height, steps, model="default"):
|
||||
pbar = client.progress_bar(100)
|
||||
|
||||
def report(p):
|
||||
if pbar is not None and p is not None:
|
||||
try:
|
||||
pct = float(p)
|
||||
pbar.update_absolute(max(0, min(100, int(pct * (100 if pct <= 1.0 else 1)))))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
raw = client.run_job(
|
||||
"videos",
|
||||
{"model": model, "prompt": prompt, "num_frames": num_frames,
|
||||
"width": width, "height": height, "steps": steps},
|
||||
job_timeout=1800,
|
||||
progress=report,
|
||||
)
|
||||
return (client.mp4_bytes_to_image_batch(raw),)
|
||||
|
||||
|
||||
class HanzoImageTo3D:
|
||||
"""Image-to-3D via the async job /v1/3d (native Rust TRELLIS/Pixal3D). Saves
|
||||
the mesh to the output dir and returns its path for a 3D-viewer node. Enable
|
||||
`texture` for the full SLAT + FlexiCubes stage (textured GLB vs coarse mesh)."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"format": (["glb", "ply", "obj"], {"default": "glb"}),
|
||||
"texture": ("BOOLEAN", {"default": True}),
|
||||
"seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF}),
|
||||
"steps": ("INT", {"default": 30, "min": 1, "max": 150}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("mesh_path",)
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = CATEGORY
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def generate(self, image, format, texture, seed, steps):
|
||||
raw = client.run_job(
|
||||
"3d",
|
||||
{"image": client.tensor_to_data_url(image), "format": format,
|
||||
"texture": texture, "seed": seed, "steps": steps},
|
||||
job_timeout=900,
|
||||
)
|
||||
full_output_folder, filename, counter, _sub, _pre = folder_paths.get_save_image_path(
|
||||
"hanzo3d", folder_paths.get_output_directory()
|
||||
)
|
||||
path = os.path.join(full_output_folder, f"{filename}_{counter:05}_.{format}")
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(raw)
|
||||
return {"ui": {"text": [path]}, "result": (path,)}
|
||||
|
||||
|
||||
class HanzoMusic:
|
||||
"""Text/tag/lyric prompt -> music via /v1/audio/music (native Rust ACE-Step).
|
||||
Returns a Studio AUDIO you can save/preview with the built-in audio nodes."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"prompt": ("STRING", {"multiline": True,
|
||||
"default": "epic cinematic orchestral, driving percussion, brass swells"}),
|
||||
},
|
||||
"optional": {
|
||||
"model": (_model_combo(),),
|
||||
"response_format": (["wav", "pcm"], {"default": "wav"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("AUDIO",)
|
||||
RETURN_NAMES = ("audio",)
|
||||
FUNCTION = "compose"
|
||||
CATEGORY = CATEGORY
|
||||
|
||||
def compose(self, prompt, model="default", response_format="wav"):
|
||||
raw = client.post_bytes(
|
||||
"/v1/audio/music",
|
||||
{"model": model, "input": prompt, "response_format": response_format},
|
||||
timeout=900,
|
||||
)
|
||||
return (client.audio_bytes_to_studio(raw),)
|
||||
|
||||
|
||||
class HanzoDub:
|
||||
"""Lip-sync / avatar dub via /v1/animate (native Rust MuseTalk). Drives a
|
||||
portrait IMAGE (or the first video frame) with an AUDIO track and returns the
|
||||
animated frames as an IMAGE batch plus the driving audio, so a video-combine
|
||||
node can mux them."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"audio": ("AUDIO",),
|
||||
},
|
||||
"optional": {
|
||||
"model": (_model_combo(),),
|
||||
"fps": ("FLOAT", {"default": 25.0, "min": 1.0, "max": 60.0, "step": 1.0}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "AUDIO")
|
||||
RETURN_NAMES = ("frames", "audio")
|
||||
FUNCTION = "dub"
|
||||
CATEGORY = CATEGORY
|
||||
|
||||
def dub(self, image, audio, model="default", fps=25.0):
|
||||
payload = {
|
||||
"model": model,
|
||||
"audio": client.studio_audio_to_data_url(audio),
|
||||
"visual": client.tensor_to_data_url(image),
|
||||
"fps": fps,
|
||||
}
|
||||
raw = client.post_bytes("/v1/animate", payload, timeout=1800)
|
||||
return (client.mp4_bytes_to_image_batch(raw), audio)
|
||||
|
||||
|
||||
class HanzoSaveText:
|
||||
"""Write a STRING to a .txt file in the output directory (image/caption
|
||||
pairs for dataset prep) and preview it in the node."""
|
||||
"""Write a STRING to output/*.txt (image/caption pairs for dataset prep)
|
||||
and preview it in the node."""
|
||||
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
@@ -230,17 +420,17 @@ class HanzoSaveText:
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING", {"forceInput": True}),
|
||||
"filename_prefix": ("STRING", {"default": "caption"}),
|
||||
"filename_prefix": ("STRING", {"default": "hanzo"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "save"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "Hanzo/Engine"
|
||||
CATEGORY = CATEGORY
|
||||
|
||||
def save(self, text, filename_prefix="caption"):
|
||||
full_output_folder, filename, counter, _subfolder, _prefix = folder_paths.get_save_image_path(
|
||||
def save(self, text, filename_prefix="hanzo"):
|
||||
full_output_folder, filename, counter, _sub, _pre = folder_paths.get_save_image_path(
|
||||
filename_prefix, self.output_dir
|
||||
)
|
||||
path = os.path.join(full_output_folder, f"{filename}_{counter:05}_.txt")
|
||||
@@ -252,13 +442,27 @@ class HanzoSaveText:
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"HanzoChat": HanzoChat,
|
||||
"HanzoImageGen": HanzoImageGen,
|
||||
"HanzoTTS": HanzoTTS,
|
||||
"HanzoMusic": HanzoMusic,
|
||||
"HanzoASR": HanzoASR,
|
||||
"HanzoEngineRequest": HanzoEngineRequest,
|
||||
"HanzoVisionCaption": HanzoVisionCaption,
|
||||
"HanzoTextToVideo": HanzoTextToVideo,
|
||||
"HanzoImageTo3D": HanzoImageTo3D,
|
||||
"HanzoDub": HanzoDub,
|
||||
"HanzoSaveText": HanzoSaveText,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"HanzoChat": "Hanzo Chat (Engine)",
|
||||
"HanzoImageGen": "Hanzo Image Gen (Engine)",
|
||||
"HanzoTTS": "Hanzo TTS (Engine)",
|
||||
"HanzoMusic": "Hanzo Music (Engine)",
|
||||
"HanzoASR": "Hanzo ASR (Engine)",
|
||||
"HanzoEngineRequest": "Hanzo Engine Request",
|
||||
"HanzoVisionCaption": "Hanzo Vision Caption (Engine)",
|
||||
"HanzoTextToVideo": "Hanzo Text to Video (Engine)",
|
||||
"HanzoImageTo3D": "Hanzo Image to 3D (Engine)",
|
||||
"HanzoDub": "Hanzo Dub / Lip-Sync (Engine)",
|
||||
"HanzoSaveText": "Hanzo Save Text",
|
||||
}
|
||||
|
||||
+117
-130
@@ -1,168 +1,155 @@
|
||||
# Hanzo Studio — GPU Federation
|
||||
|
||||
How `studio.hanzo.ai` schedules generation jobs across a heterogeneous GPU pool:
|
||||
in-cluster cloud pods **and** local boxes (e.g. a GB10) that join over an
|
||||
**outbound-only** connection. No inbound tunnel to the local box is ever
|
||||
required.
|
||||
How `studio.hanzo.ai` runs generation jobs durably and spreads them across a
|
||||
heterogeneous GPU pool: in-cluster cloud pods **and** local boxes (e.g. a GB10)
|
||||
that join over an **outbound-only** connection. No inbound tunnel to a local box
|
||||
is ever required.
|
||||
|
||||
This document is the contract. Sections are marked **[implemented]** (code in
|
||||
this repo today) or **[specced]** (agreed design, not yet wired). Nothing here is
|
||||
a stub in code — specced pieces live in this doc until they are built.
|
||||
Sections are marked **[implemented]** (code in this repo today), **[fast-path]**
|
||||
(retained in-cluster optimization), or **[future]**.
|
||||
|
||||
## 1. Roles
|
||||
|
||||
- **Coordinator** — the `studio.hanzo.ai` deployment. Full UI, IAM auth,
|
||||
multi-tenant storage. Owns the per-org worker registry and the job schedule.
|
||||
Runs a small CPU footprint (it routes; it does not need a GPU).
|
||||
- **Worker** — a headless Studio started with `--worker-mode`. Reports its
|
||||
device (GPU model, VRAM), registers with the coordinator, and executes prompts
|
||||
it is given. A worker belongs to exactly one org (`STUDIO_ORG_ID`).
|
||||
multi-tenant storage. Accepts `/prompt`, owns the durable render queue and the
|
||||
per-org worker registry + engine selection.
|
||||
- **Worker** — a Studio process that executes prompts. Either the same box as the
|
||||
coordinator (local render), an in-cluster pod reached by push (§2a), or a box
|
||||
sharing the coordinator's render queue.
|
||||
|
||||
A worker is the *same binary* as the coordinator; `--worker-mode` only drops the
|
||||
UI/websocket surface and turns on registration + the execute endpoint. This is
|
||||
the ComfyUI-Distributed lineage (workers are full ComfyUI instances the control
|
||||
plane drives) narrowed to **job-level** dispatch and hardened for multi-tenant +
|
||||
NAT traversal.
|
||||
A worker is the *same binary* as the coordinator.
|
||||
|
||||
## 2. Two dispatch models
|
||||
## 2. Durable render queue **[implemented]**
|
||||
|
||||
Reachability, not preference, decides which model a worker uses.
|
||||
The render queue is crash-durable: pending and in-flight prompts survive a
|
||||
process crash, and a killed render is re-run instead of being lost. Storage is a
|
||||
single **SQLite** file (stdlib `sqlite3`, WAL) — **zero external processes**, per
|
||||
the house rule (SQLite embedded by default; Postgres only for prod multi-instance).
|
||||
|
||||
### 2a. Push — for in-cluster workers **[implemented]**
|
||||
Enable with `STUDIO_QUEUE_DB=<path>`. `server.py` then swaps `PromptQueue` for
|
||||
`SqlitePromptQueue`; the existing prompt-executor thread is the claim loop
|
||||
unchanged. The `PromptQueue` seam maps onto a durable job state machine:
|
||||
|
||||
The coordinator can open a connection to the worker (both are pods in
|
||||
`hanzo-k8s`, or the worker has a routable `WORKER_EXTERNAL_URL`).
|
||||
```
|
||||
put(item) → INSERT job (pending) # idempotent on prompt_id
|
||||
get(timeout) → claim next pending (claimed, leased) # BEGIN IMMEDIATE
|
||||
task_done(success) → job → done
|
||||
task_done(error) → job → pending (retry) | failed # per STUDIO_TASKS_MAX_ATTEMPTS
|
||||
```
|
||||
|
||||
Guarantees:
|
||||
|
||||
- **Exactly-once submit** — the row is keyed by `prompt_id` (`INSERT OR IGNORE`);
|
||||
a resubmit of the same prompt is a no-op.
|
||||
- **Exactly-once claim** — `get()` claims under `BEGIN IMMEDIATE`, so with several
|
||||
Studio processes on the same `STUDIO_QUEUE_DB` exactly one claims each job.
|
||||
- **Crash-recovery** — a claim carries a lease. A background heartbeat renews it
|
||||
while the render runs; if the process dies, the lease expires and the reap step
|
||||
(run on every `get()`, in the heartbeat thread, and on boot) returns the job to
|
||||
`pending` for another claim. A render retry is idempotent — SaveImage suffixes
|
||||
increment.
|
||||
|
||||
Files: `middleware/tasks_queue.py` (`SqlitePromptQueue`), `server.py` (queue
|
||||
selection). Precedence:
|
||||
|
||||
| Precedence | Trigger | Backend | Durability |
|
||||
|---|---|---|---|
|
||||
| 1 | `STUDIO_QUEUE_DB` set | `SqlitePromptQueue` (one file) | Crash-durable, exactly-once, retry, multi-process |
|
||||
| 2 | `STUDIO_PERSIST_QUEUE=1` | `PromptQueue` + JSON snapshot | Single-box crash-survival (re-queue on boot) |
|
||||
| 3 | (default) | `PromptQueue` (in-memory) | None — same as upstream ComfyUI |
|
||||
|
||||
Env: `STUDIO_QUEUE_DB` (path), `STUDIO_TASKS_QUEUE` (default `studio-render`),
|
||||
`STUDIO_TASKS_MAX_ATTEMPTS` (default 3), `STUDIO_TASKS_LEASE_MS` (default 60000,
|
||||
heartbeat renews at lease/3), `STUDIO_WORKER_ID` (default `<host>-<pid>`).
|
||||
|
||||
### 2a. Push — in-cluster fast path **[fast-path]**
|
||||
|
||||
When the coordinator can already reach a worker (both pods in `hanzo-k8s`, or a
|
||||
routable `WORKER_EXTERNAL_URL`), it may forward a prompt directly:
|
||||
|
||||
```
|
||||
client → POST /prompt (coordinator)
|
||||
prompt_router.route_prompt(org, body)
|
||||
→ get_available_gpu_worker(org) # compute_config registry
|
||||
→ POST {worker.url}/v1/worker/execute # forward_to_worker
|
||||
worker queues + executes locally, returns {prompt_id, ...}
|
||||
```
|
||||
|
||||
Files: `middleware/prompt_router.py`, `middleware/worker_client.py`
|
||||
(`add_worker_routes` → `/v1/worker/execute`), `server.py` (`/prompt` calls
|
||||
`route_prompt`).
|
||||
Files: `middleware/prompt_router.py`, `middleware/worker_client.py`. This path is
|
||||
not durable on its own (a forwarded prompt lost to a worker crash is not retried);
|
||||
keep it for latency-sensitive in-cluster dispatch. It requires the coordinator to
|
||||
reach the worker, so it cannot serve a NAT'd box.
|
||||
|
||||
Limitation: requires the coordinator to reach the worker. A GB10 behind NAT is
|
||||
**not** reachable, so push cannot serve it.
|
||||
## 3. Engine selector **[implemented]**
|
||||
|
||||
### 2b. Pull — for NAT'd local boxes (the GB10 pattern) **[specced]**
|
||||
An org chooses which execution target ("engine") its prompts run on. Engines are
|
||||
derived from what already exists in the per-org compute registry:
|
||||
|
||||
The worker dials **out** and keeps the connection; the coordinator pushes jobs
|
||||
down that already-open channel. No inbound port on the box.
|
||||
- `local` — this Studio instance (always present), and
|
||||
- each registered org GPU worker (from `compute.json`'s worker registry).
|
||||
|
||||
Primary transport is a WebSocket the worker opens to the coordinator:
|
||||
Endpoints (coordinator):
|
||||
|
||||
```
|
||||
worker → WSS {coordinator}/v1/workers/connect (Authorization: worker token)
|
||||
← {type:"job", job_id, prompt, extra_data, org_id}
|
||||
→ {type:"progress", job_id, value, max}
|
||||
→ {type:"result", job_id, status, outputs:[{name, s3_url}...]}
|
||||
```
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/v1/engines` | List engines for the org + the current default. |
|
||||
| PUT | `/v1/engines/default` | Set the org's default engine. Body `{"engine": "<id>"}`. |
|
||||
|
||||
HTTP long-poll fallback for environments without WSS:
|
||||
The default is stored on the org's compute profile (`ComputeProfile.default_engine`).
|
||||
`prompt_router.route_prompt` honors it: if the default names a live worker, the
|
||||
prompt is forwarded there; `local` (the default) leaves current behavior
|
||||
unchanged. A stale default (worker deregistered) falls back to `local`, never 500.
|
||||
|
||||
```
|
||||
worker → POST /v1/jobs/claim {worker_id, org_id} # long-poll, ≤30s
|
||||
← 204 (no work) | 200 {job_id, prompt, extra_data}
|
||||
worker → POST /v1/jobs/{job_id}/result {status, outputs}
|
||||
```
|
||||
Files: `middleware/engine_selector.py`, `middleware/compute_config.py`,
|
||||
`middleware/prompt_router.py`. **Frontend TODO:** a queue-panel/settings dropdown
|
||||
to pick the engine — the API + per-org default ship now; the UI control is a
|
||||
follow-up.
|
||||
|
||||
The coordinator keeps a per-org, per-worker job queue (a natural extension of
|
||||
`compute_config`), enqueues on `/prompt` when the target worker is pull-mode, and
|
||||
hands the job to whichever channel the worker holds open. `route_prompt` gains a
|
||||
third branch (`{"action":"queued"}` → `202`) alongside the existing
|
||||
forward/provisioning/unavailable branches.
|
||||
## 4. Security **[implemented]**
|
||||
|
||||
Not implemented because it does not "drop out" of ComfyUI's `--listen`
|
||||
architecture — it needs a real coordinator-side queue and a WS control channel.
|
||||
Building it does not change the endpoints below.
|
||||
- **User auth** — all user-facing routes require an IAM JWT (JWKS-verified); org
|
||||
comes from the `owner` claim. See `middleware/iam_auth_middleware.py`.
|
||||
- **Worker trust** — the IAM-exempt `/v1/worker/execute` and `/v1/workers/register`
|
||||
require `X-Worker-Token == STUDIO_WORKER_TOKEN` (`worker_client.verify_worker_token`).
|
||||
KMS-sourced in cloud; empty in a single-trust-domain local dev, where the check
|
||||
is skipped.
|
||||
- **Org isolation** — a render carries its `org_id` in `extra_data`; the worker
|
||||
binds it as the execution org so outputs land in that org's namespace (§5). A
|
||||
worker only serves its own `STUDIO_ORG_ID`.
|
||||
|
||||
## 3. Endpoints
|
||||
## 5. Output persistence **[future]**
|
||||
|
||||
All under `/v1/` (also served under the `/api/v1/` alias ComfyUI adds for the
|
||||
frontend proxy). Worker↔coordinator endpoints are exempt from user IAM and
|
||||
carry the coordinator shared secret instead (`X-Worker-Token`, §5).
|
||||
Workers execute against their own output dir (org-scoped via
|
||||
`folder_paths.set_execution_org`). For the coordinator UI to serve results across
|
||||
boxes, workers upload outputs to `s3.lux.cloud` (hanzo bucket), key
|
||||
`studio/{org_id}/{prompt_id}/{filename}`, and the coordinator serves via `/view`
|
||||
(or a signed redirect). Until then, results remain on the executing worker —
|
||||
fine for the single-box and in-cluster cases.
|
||||
|
||||
| Method | Path | Dir | Status | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| POST | `/v1/workers/register` | worker→coord | **impl** | Register / heartbeat. Body: `{worker_id, url, org_id, device, gpu_model, vram_gb, status}`. |
|
||||
| GET | `/v1/workers` | client→coord | **impl** | List the org's workers + liveness. |
|
||||
| POST | `/v1/worker/execute` | coord→worker | **impl** | Push a prompt to a reachable worker. Same body as `/prompt`. |
|
||||
| WSS | `/v1/workers/connect` | worker→coord | **spec** | Persistent pull channel for NAT'd workers. |
|
||||
| POST | `/v1/jobs/claim` | worker→coord | **spec** | HTTP long-poll fallback to claim a job. |
|
||||
| POST | `/v1/jobs/{id}/result` | worker→coord | **spec** | Report result + artifact URLs. |
|
||||
| GET | `/v1/compute/config` | client→coord | **impl** | Read the org's compute profile. |
|
||||
| PUT | `/v1/compute/config` | client→coord | **impl** | Update profile (GPU tier, auto-provision). |
|
||||
| POST | `/v1/compute/provision` | client→coord | **impl** | Autoscale a cloud GPU worker via Visor. |
|
||||
## 6. Future direction
|
||||
|
||||
(Compute-profile + Visor autoscale routes are still under `/compute/*` in
|
||||
`server.py`; the `/v1/compute/*` labels above are the canonical names to fold to
|
||||
when those handlers are next touched. Worker-registry + execute already moved.)
|
||||
|
||||
## 4. Registration & scheduling **[implemented]**
|
||||
|
||||
- Worker sends a heartbeat every 30s (`WorkerClient._heartbeat_loop`).
|
||||
- Coordinator upserts it into the org's `compute.json`
|
||||
(`compute_config.register_worker`), pruning workers silent >300s.
|
||||
Liveness for scheduling is 90s (`WorkerInfo.is_alive`).
|
||||
- `get_available_gpu_worker(org)` returns the first ready, alive CUDA worker.
|
||||
- `prompt_router.route_prompt` chooses local vs worker from the prompt's
|
||||
`device_preference` (`auto`|`cpu`|`gpu`) and the org's `gpu_enabled`.
|
||||
- If no worker and the org has `auto_provision`, Visor launches a cloud GPU VM
|
||||
that boots a worker (`middleware/visor_client.py`).
|
||||
|
||||
## 5. Security
|
||||
|
||||
- **User auth** — all user-facing routes require an IAM JWT (JWKS-verified);
|
||||
org comes from the `owner` claim. See `middleware/iam_auth_middleware.py`.
|
||||
- **Worker trust** — worker↔coordinator endpoints are IAM-exempt (they carry no
|
||||
user) and instead require `X-Worker-Token == STUDIO_WORKER_TOKEN`
|
||||
(`worker_client.verify_worker_token`). The token is KMS-sourced in cloud;
|
||||
empty in a single-trust-domain local dev, where the check is skipped.
|
||||
**[specced]** forward direction: replace the shared secret with an IAM
|
||||
service-account token (`hanzo-studio` client-credentials grant) so each worker
|
||||
is individually attributable and revocable.
|
||||
- **Org isolation** — a job carries its `org_id`; the worker binds it as the
|
||||
execution org so outputs land in that org's namespace (§6). A worker only ever
|
||||
claims jobs for its own `STUDIO_ORG_ID`.
|
||||
- **No inbound** — pull-mode boxes expose nothing; the coordinator is reached
|
||||
over TLS via `hanzoai/ingress`.
|
||||
|
||||
## 6. Output persistence **[specced]**
|
||||
|
||||
Push/pull both execute on the worker, which writes to *its own* output dir
|
||||
(org-scoped via `folder_paths.set_execution_org`). For the coordinator UI to
|
||||
show results, workers must ship artifacts to shared storage:
|
||||
|
||||
- On completion, upload outputs to `s3.lux.cloud` (hanzo bucket),
|
||||
key `studio/{org_id}/{prompt_id}/{filename}`.
|
||||
- Report the object URLs in the `result` message; the coordinator records them
|
||||
in history so `/view` (or a signed redirect) serves them.
|
||||
|
||||
Until this lands, push-mode results remain on the worker and are addressable
|
||||
only if the worker is reachable — acceptable for the in-cluster pool, blocking
|
||||
for the NAT pull pool. This is the top build item after the pull channel.
|
||||
The durable queue, engine selection, and dispatch are deliberately kept in a thin
|
||||
Python layer at the `PromptQueue`/`prompt_router` seams so the backend can be
|
||||
swapped without touching the render path. Two moves are planned: (a) a **remote
|
||||
queue backend** — the same job state machine served by **Hanzo Tasks**
|
||||
(`github.com/hanzoai/tasks`) over its HTTP surface, so multiple coordinators and
|
||||
NAT'd `--worker`-style claimers share one durable queue instead of a local file;
|
||||
and (b) folding that scheduling/queue layer into a **Go subsystem inside the
|
||||
`hanzoai/cloud` unified binary** (HIP-0106), leaving Studio's Python as a pure
|
||||
execution worker. The engine selector will likewise grow a third class,
|
||||
**leased cloud machines** provisioned via the platform's Visor compute surface
|
||||
(`GET {cloud}/v1/machines`) — the `/v1/engines` shape already accommodates the
|
||||
extra entries; the client interface is stubbed in `engine_selector.py` and wired
|
||||
when the console lands.
|
||||
|
||||
## 7. Joining a local box (GB10)
|
||||
|
||||
```bash
|
||||
python main.py \
|
||||
--worker-mode \
|
||||
--coordinator-url https://studio.hanzo.ai \
|
||||
--worker-id gb10-1
|
||||
# env: STUDIO_ORG_ID=<org> STUDIO_WORKER_TOKEN=<from KMS>
|
||||
```
|
||||
Two paths today:
|
||||
|
||||
Today (push) this works only if the coordinator can reach the box (set
|
||||
`WORKER_EXTERNAL_URL`). Once §2b lands, the box needs **no** inbound access: it
|
||||
registers, opens the WSS channel, and pulls jobs for its org.
|
||||
- **Shared queue** (durable): point the box at the same `STUDIO_QUEUE_DB` (shared
|
||||
volume) as the coordinator; its prompt-executor thread claims jobs directly.
|
||||
Exactly-once claim + crash-recovery hold across processes.
|
||||
- **Push** (in-cluster, fast): `python main.py --worker-mode --coordinator-url … --worker-id gb10-1`
|
||||
with `STUDIO_WORKER_TOKEN`; requires the coordinator to reach the box.
|
||||
|
||||
## 8. Build order
|
||||
|
||||
1. Pull WSS channel `/v1/workers/connect` + coordinator per-worker queue (§2b).
|
||||
2. Artifact upload to `s3.lux.cloud` + result recording (§6).
|
||||
3. IAM service-account worker identity replacing the shared secret (§5).
|
||||
4. Fold `/compute/*` profile routes to `/v1/compute/*` (§3).
|
||||
The remote-queue backend (§6) generalizes the shared-queue path to boxes that
|
||||
cannot share a filesystem, outbound-only.
|
||||
|
||||
+5
-1
@@ -149,7 +149,11 @@ class CacheSet:
|
||||
}
|
||||
return result
|
||||
|
||||
SENSITIVE_EXTRA_DATA_KEYS = ("auth_token_hanzo", "api_key_hanzo")
|
||||
# Secrets that ride with a queued prompt but must never be persisted into history.
|
||||
# `iam_token` is the requesting user's own verified IAM access token: the prompt
|
||||
# worker records the finished render in the content lane as that user
|
||||
# (middleware/content_publish.py).
|
||||
SENSITIVE_EXTRA_DATA_KEYS = ("auth_token_hanzo", "api_key_hanzo", "iam_token")
|
||||
|
||||
def get_input_data(inputs, class_def, unique_id, execution_list=None, dynprompt=None, extra_data={}):
|
||||
is_v3 = issubclass(class_def, _StudioNodeInternal)
|
||||
|
||||
+15
-1
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import mimetypes
|
||||
import logging
|
||||
@@ -94,6 +95,19 @@ def get_org_id() -> str | None:
|
||||
return _org_id
|
||||
|
||||
|
||||
_ORG_ID_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
|
||||
|
||||
|
||||
def _safe_org_id(oid: str) -> str:
|
||||
"""The single choke point where an org value becomes a filesystem path — so it
|
||||
is also the one place to reject traversal. An org id must be a plain slug; `.`,
|
||||
`..`, `/`, `\\`, or any other character is refused so a malformed/hostile claim
|
||||
can never escape the orgs/ tree (defense-in-depth; org id is IAM-derived today)."""
|
||||
if oid in (".", "..") or not _ORG_ID_RE.match(oid):
|
||||
raise ValueError(f"invalid org_id for path scoping: {oid!r}")
|
||||
return oid
|
||||
|
||||
|
||||
def get_org_base_path(org_id: str | None = None) -> str:
|
||||
"""
|
||||
Get the org-scoped base path. If multi-tenant is enabled and org_id is set,
|
||||
@@ -101,7 +115,7 @@ def get_org_base_path(org_id: str | None = None) -> str:
|
||||
"""
|
||||
oid = org_id or _org_id
|
||||
if _multi_tenant and oid:
|
||||
org_path = os.path.join(base_path, "orgs", oid)
|
||||
org_path = os.path.join(base_path, "orgs", _safe_org_id(oid))
|
||||
os.makedirs(org_path, exist_ok=True)
|
||||
return org_path
|
||||
return base_path
|
||||
|
||||
@@ -15,6 +15,8 @@ images:
|
||||
test:
|
||||
- name: unit
|
||||
run: |
|
||||
command -v uv >/dev/null || curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
uv venv --python 3.11
|
||||
uv pip install -r requirements.txt pytest pytest-aiohttp pytest-asyncio
|
||||
uv run pytest -q \
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import studio_compat # noqa: F401 aliases comfy.* -> studio.* for upstream custom nodes
|
||||
import studio.options
|
||||
studio.options.enable_args_parsing()
|
||||
|
||||
@@ -244,6 +245,7 @@ def prompt_worker(q, server_instance):
|
||||
# Import metrics + billing for prompt tracking
|
||||
from middleware import metrics_middleware
|
||||
from middleware import billing_middleware
|
||||
from middleware import content_publish
|
||||
enable_billing = getattr(args, "enable_billing", False)
|
||||
enable_metrics = getattr(args, "enable_metrics", False)
|
||||
|
||||
@@ -292,23 +294,38 @@ def prompt_worker(q, server_instance):
|
||||
if enable_metrics:
|
||||
metrics_middleware.record_prompt_end(execution_time, e.success)
|
||||
|
||||
# Record billing usage (fire-and-forget via event loop)
|
||||
if enable_billing:
|
||||
# Get user/org from extra_data if available
|
||||
client_id = extra_data.get("client_id", "anonymous")
|
||||
node_count = len(item[2]) if item[2] else 0
|
||||
import asyncio
|
||||
loop = server_instance.loop
|
||||
# Billing: emit ONE metered usage event per completed render
|
||||
# (fire-and-forget). org_id is the IAM tenant; prompt_id is the
|
||||
# idempotency key; quantity is the number of artifacts produced.
|
||||
# Never blocks or fails a render; no-op unless STUDIO_BILLING_* is set.
|
||||
if enable_billing and e.success:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
billing_middleware.record_usage(
|
||||
user=client_id,
|
||||
org_id=os.environ.get("STUDIO_ORG_ID", "default"),
|
||||
billing_middleware.record_render(
|
||||
org_id=extra_data.get("org_id") or "default",
|
||||
prompt_id=prompt_id,
|
||||
execution_time=execution_time,
|
||||
success=e.success,
|
||||
node_count=node_count,
|
||||
n_outputs=billing_middleware.count_outputs(e.history_result),
|
||||
),
|
||||
loop,
|
||||
server_instance.loop,
|
||||
)
|
||||
|
||||
# Content: record each output artifact as a draft Asset in the content
|
||||
# lane (clients/content, module "marketing") — the SAME DocType that
|
||||
# lane writes when it drives a render, so a studio-initiated render lands
|
||||
# in the same place. Written as the requesting user via their IAM token
|
||||
# from `sensitive`, so it lands in their org. Assets land draft; a human
|
||||
# moves them through the lifecycle, and published is what a storefront
|
||||
# shows. Fire-and-forget: a render is never failed if the lane is down.
|
||||
if content_publish.enabled() and e.success:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
content_publish.publish_render(
|
||||
org_id=extra_data.get("org_id") or "default",
|
||||
prompt_id=prompt_id,
|
||||
workflow=item[2],
|
||||
history_result=e.history_result,
|
||||
iam_token=extra_data.get("iam_token"),
|
||||
extra_data=extra_data,
|
||||
),
|
||||
server_instance.loop,
|
||||
)
|
||||
|
||||
# Log Time in a more readable way after 10 minutes
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
"""
|
||||
Commerce billing integration for Hanzo Studio.
|
||||
|
||||
Records prompt execution usage to the Commerce billing API.
|
||||
Optionally checks balance before accepting expensive operations.
|
||||
record_render — emits ONE metered usage event per completed render to the
|
||||
Commerce meter-events API. Fire-and-forget: a billing failure never blocks or
|
||||
fails a render. Env-gated (all three required; absent locally = no-op):
|
||||
|
||||
Commerce API: POST /api/v1/billing/usage
|
||||
Commerce API: GET /api/v1/billing/balance
|
||||
STUDIO_BILLING_URL full URL of POST /v1/billing/meter-events
|
||||
STUDIO_COMMERCE_TOKEN commerce admin bearer (KMS /studio commerce-token)
|
||||
STUDIO_BILLING_METER id of the "studio render" meter (created in Commerce)
|
||||
|
||||
Commerce contract — POST /v1/billing/meter-events:
|
||||
{"events":[{"meterId","userId","value","idempotency","timestamp","dimensions"}]}
|
||||
Pricing lives on the meter in Commerce; Studio only reports raw quantity.
|
||||
|
||||
check_balance — pre-existing optional pre-flight balance gate.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import aiohttp
|
||||
|
||||
@@ -35,49 +44,76 @@ async def _get_session() -> aiohttp.ClientSession:
|
||||
return _shared_session
|
||||
|
||||
|
||||
async def record_usage(
|
||||
user: str,
|
||||
org_id: str,
|
||||
prompt_id: str,
|
||||
execution_time: float,
|
||||
success: bool,
|
||||
node_count: int = 0,
|
||||
) -> None:
|
||||
"""
|
||||
Record prompt execution usage to Commerce billing API.
|
||||
Fire-and-forget — errors are logged but do not block the response.
|
||||
def _cfg() -> tuple[str, str, str] | None:
|
||||
"""Return (url, token, meter_id) if render billing is fully configured, else None."""
|
||||
url = os.environ.get("STUDIO_BILLING_URL", "").strip()
|
||||
token = os.environ.get("STUDIO_COMMERCE_TOKEN", "").strip()
|
||||
meter = os.environ.get("STUDIO_BILLING_METER", "").strip()
|
||||
if url and token and meter:
|
||||
return url, token, meter
|
||||
return None
|
||||
|
||||
Pricing: 1 credit per prompt execution second (rounded up).
|
||||
Minimum 1 credit per execution.
|
||||
|
||||
def count_outputs(history_result: dict | None) -> int:
|
||||
"""Count media artifacts (images, gifs, audio, ...) a completed render produced."""
|
||||
n = 0
|
||||
for node_output in (history_result or {}).get("outputs", {}).values():
|
||||
if isinstance(node_output, dict):
|
||||
for v in node_output.values():
|
||||
if isinstance(v, list):
|
||||
n += len(v)
|
||||
return n
|
||||
|
||||
|
||||
async def record_render(org_id: str, prompt_id: str, n_outputs: int) -> None:
|
||||
"""
|
||||
if not COMMERCE_TOKEN:
|
||||
Emit ONE metered usage event for a completed render to Commerce meter-events.
|
||||
|
||||
Fire-and-forget with a single retry (the idempotency key makes the retry safe
|
||||
against double-counting). Never raises and never blocks: a billing failure
|
||||
only logs, a render is never failed for it. No-op unless STUDIO_BILLING_URL,
|
||||
STUDIO_COMMERCE_TOKEN and STUDIO_BILLING_METER are all set — so local dev is
|
||||
unaffected.
|
||||
"""
|
||||
cfg = _cfg()
|
||||
if cfg is None:
|
||||
return
|
||||
url, token, meter = cfg
|
||||
|
||||
# Calculate cost: 1 cent per second of execution, min 1 cent
|
||||
amount = max(1, int(execution_time + 0.5))
|
||||
|
||||
url = f"{COMMERCE_URL.rstrip('/')}/api/v1/billing/usage"
|
||||
payload = {
|
||||
"user": f"{org_id}/{user}" if org_id else user,
|
||||
"currency": "usd",
|
||||
"amount": amount,
|
||||
"model": "studio",
|
||||
"provider": "hanzo-studio",
|
||||
"requestId": prompt_id,
|
||||
"status": "success" if success else "error",
|
||||
"promptTokens": node_count,
|
||||
"completionTokens": 0,
|
||||
"totalTokens": node_count,
|
||||
"events": [
|
||||
{
|
||||
"meterId": meter,
|
||||
"userId": org_id or "default",
|
||||
"value": max(0, int(n_outputs)),
|
||||
"idempotency": prompt_id,
|
||||
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"dimensions": {"service": "studio"},
|
||||
}
|
||||
]
|
||||
}
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
timeout = aiohttp.ClientTimeout(
|
||||
total=float(os.environ.get("STUDIO_BILLING_TIMEOUT", "5"))
|
||||
)
|
||||
|
||||
try:
|
||||
session = await _get_session()
|
||||
async with session.post(url, json=payload) as resp:
|
||||
if resp.status not in (200, 201):
|
||||
body = await resp.text()
|
||||
logging.warning(f"Commerce billing error {resp.status}: {body}")
|
||||
except Exception as e:
|
||||
logging.warning(f"Commerce billing request failed: {e}")
|
||||
for attempt in (1, 2):
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(url, json=payload, headers=headers) as resp:
|
||||
if resp.status in (200, 201):
|
||||
return
|
||||
body = await resp.text()
|
||||
logging.warning(
|
||||
"billing: meter-events returned %s: %s", resp.status, body[:200]
|
||||
)
|
||||
if resp.status < 500:
|
||||
return # deterministic client error — a retry won't help
|
||||
except Exception as e: # timeout / connection reset — retry once, then drop
|
||||
logging.warning(
|
||||
"billing: render usage post failed (attempt %d/2): %s", attempt, e
|
||||
)
|
||||
# Both attempts failed. Drop it — a render is never failed for billing.
|
||||
|
||||
|
||||
async def check_balance(user: str, org_id: str) -> tuple[bool, int]:
|
||||
|
||||
@@ -63,6 +63,7 @@ class ComputeProfile:
|
||||
workers: list = field(default_factory=list)
|
||||
auto_provision: bool = False
|
||||
max_concurrent: int = 1
|
||||
default_engine: str = "local" # engine id renders route to (see engine_selector)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
@@ -139,6 +140,9 @@ def update_config(org_id: str, updates: dict) -> ComputeProfile:
|
||||
mc = int(updates["max_concurrent"])
|
||||
profile.max_concurrent = max(1, min(mc, 32))
|
||||
|
||||
if "default_engine" in updates:
|
||||
profile.default_engine = str(updates["default_engine"])
|
||||
|
||||
# Derive gpu_enabled from profile if not explicitly set
|
||||
if "active_profile" in updates and "gpu_enabled" not in updates:
|
||||
profile.gpu_enabled = profile.active_profile in ("gpu-basic", "gpu-pro", "custom")
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Publish a completed Studio render into the Hanzo content lane.
|
||||
|
||||
THE one writer, and it writes NOTHING new. A finished render becomes an `Asset`
|
||||
document — the DocType hanzoai/cloud `clients/content` already declares (framework
|
||||
module "marketing") and already writes itself when IT drives a render
|
||||
(clients/content/studio_render.go). Studio-initiated renders were the only ones
|
||||
with no path into that record; this closes exactly that gap and adds no second
|
||||
schema, no second lifecycle, and no second storage plane.
|
||||
|
||||
POST /v1/framework/Asset (the already-live generic framework surface)
|
||||
|
||||
Same shape `clients/content` writes (studio_render.go `draftAsset`):
|
||||
|
||||
title, kind, design, prompt, file, generator, workflow,
|
||||
source_prompt_id, render_params, project, tags
|
||||
|
||||
`file` is the object key under the org's studio output prefix —
|
||||
``orgs/<org>/output/<subfolder>/<filename>`` — byte-identical to the key
|
||||
`persistOrLink` produces, and the same key Studio's own output mirror already
|
||||
lands. So the bytes are ALREADY persisted by Studio: this module uploads nothing
|
||||
and holds no storage credential. The record points at what exists.
|
||||
|
||||
Curation is the content lane's ONE state machine (clients/content/lifecycle.go):
|
||||
|
||||
draft → in_review → approved → queued → published
|
||||
|
||||
An asset lands in `draft` (the DocType's default) and a human moves it. Publishing
|
||||
is what puts an image in front of customers, so an unattended render must never do
|
||||
it. The storefront reads `published`.
|
||||
|
||||
Auth is the requesting user's OWN verified IAM access token, carried to the prompt
|
||||
worker on the queue's existing `sensitive` channel (execution.SENSITIVE_EXTRA_DATA_KEYS)
|
||||
so it is never persisted into history. The Asset is written AS that user, into that
|
||||
user's org — the framework derives the tenant from the token's `owner` claim. No
|
||||
service account, no impersonation, no cross-org write path.
|
||||
|
||||
Fire-and-forget: a publish failure only logs. A render is NEVER failed for it.
|
||||
Env-gated; absent locally = no-op.
|
||||
|
||||
STUDIO_CLOUD_API_URL Hanzo Cloud base URL (default https://api.hanzo.ai)
|
||||
STUDIO_CONTENT_PUBLISH "1"/"true" to enable
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
|
||||
import aiohttp
|
||||
|
||||
CLOUD_URL = os.environ.get("STUDIO_CLOUD_API_URL", "https://api.hanzo.ai")
|
||||
|
||||
# One Asset row per artifact; small JSON writes.
|
||||
_TIMEOUT = aiohttp.ClientTimeout(total=float(os.environ.get("STUDIO_CONTENT_TIMEOUT", "15")))
|
||||
|
||||
# clients/content declares Asset.kind as a Select; anything else would 422.
|
||||
KINDS = ("ecom", "product", "lifestyle", "hover", "hero", "thumbnail")
|
||||
DEFAULT_KIND = "product"
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
"""True when render publishing is switched on. Absent locally = no-op."""
|
||||
return os.environ.get("STUDIO_CONTENT_PUBLISH", "").strip().lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def artifacts(history_result: dict | None) -> list[dict]:
|
||||
"""
|
||||
The media artifacts a completed render produced, as Studio's own output refs
|
||||
({filename, subfolder, type}). Mirrors billing_middleware.count_outputs — the
|
||||
same shape Studio already returns to the UI, so there is no second notion of
|
||||
"what a render produced". Previews (type != "output") are not artifacts.
|
||||
"""
|
||||
out: list[dict] = []
|
||||
for node_output in (history_result or {}).get("outputs", {}).values():
|
||||
if not isinstance(node_output, dict):
|
||||
continue
|
||||
for values in node_output.values():
|
||||
if not isinstance(values, list):
|
||||
continue
|
||||
for v in values:
|
||||
if isinstance(v, dict) and v.get("filename") and v.get("type") == "output":
|
||||
out.append(v)
|
||||
return out
|
||||
|
||||
|
||||
def object_key(org_id: str, ref: dict) -> str:
|
||||
"""
|
||||
The asset's key under the org's studio output prefix. Byte-identical to the key
|
||||
clients/content builds in persistOrLink (orgs/<org>/output/...), which is also
|
||||
where Studio's own output mirror lands — ONE key convention, so the record and
|
||||
the bytes can never disagree.
|
||||
"""
|
||||
return posixpath.join(
|
||||
"orgs", org_id, "output", (ref.get("subfolder") or "").strip("/"), ref["filename"]
|
||||
).replace("//", "/")
|
||||
|
||||
|
||||
def kind_of(extra_data: dict) -> str:
|
||||
"""The Asset.kind Select value. Anything off-list would be refused by the engine."""
|
||||
k = str(extra_data.get("kind") or "").strip().lower()
|
||||
return k if k in KINDS else DEFAULT_KIND
|
||||
|
||||
|
||||
def _model(workflow: dict | None) -> str:
|
||||
"""Best-effort model name off the graph's first checkpoint/UNet loader."""
|
||||
for node in (workflow or {}).values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
if "Loader" not in str(node.get("class_type", "")):
|
||||
continue
|
||||
for v in (node.get("inputs") or {}).values():
|
||||
if isinstance(v, str) and v.endswith((".safetensors", ".ckpt", ".gguf", ".sft")):
|
||||
return v
|
||||
return ""
|
||||
|
||||
|
||||
def asset_doc(*, org_id: str, prompt_id: str, workflow: dict, ref: dict, extra_data: dict) -> dict:
|
||||
"""
|
||||
One artifact as an Asset document — the same field set clients/content writes.
|
||||
`status` is omitted on purpose: the DocType defaults it to draft, and the
|
||||
lifecycle hook owns every transition from there.
|
||||
"""
|
||||
model = _model(workflow)
|
||||
return {
|
||||
"title": ref["filename"],
|
||||
"kind": kind_of(extra_data),
|
||||
"design": str(extra_data.get("design") or "").strip(),
|
||||
"role": str(extra_data.get("role") or "").strip(),
|
||||
"prompt": str(extra_data.get("prompt") or "").strip(),
|
||||
"file": object_key(org_id, ref),
|
||||
"generator": model or "studio",
|
||||
"workflow": model or "studio",
|
||||
"source_prompt_id": prompt_id,
|
||||
# The graph that reproduces this artifact — the reproducibility record.
|
||||
"render_params": workflow or {},
|
||||
"project": str(extra_data.get("project") or "").strip(),
|
||||
"tags": str(extra_data.get("tags") or "").strip(),
|
||||
}
|
||||
|
||||
|
||||
async def publish_render(
|
||||
*,
|
||||
org_id: str,
|
||||
prompt_id: str,
|
||||
workflow: dict,
|
||||
history_result: dict | None,
|
||||
iam_token: str | None,
|
||||
extra_data: dict | None = None,
|
||||
) -> int:
|
||||
"""
|
||||
Publish a completed render's artifacts as draft Assets. Returns how many landed.
|
||||
|
||||
Never raises: the caller is the prompt worker, and a render must not fail
|
||||
because the content lane was unreachable.
|
||||
"""
|
||||
if not enabled():
|
||||
return 0
|
||||
if not iam_token:
|
||||
# No verified user principal on this execution. We refuse to invent one:
|
||||
# writing via a service account would be a cross-org write path.
|
||||
logging.warning("content_publish: no IAM token on prompt %s — not published", prompt_id)
|
||||
return 0
|
||||
|
||||
refs = artifacts(history_result)
|
||||
if not refs:
|
||||
return 0
|
||||
extra = extra_data or {}
|
||||
url = f"{CLOUD_URL.rstrip('/')}/v1/framework/Asset"
|
||||
headers = {"Authorization": f"Bearer {iam_token}"}
|
||||
landed = 0
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=_TIMEOUT) as session:
|
||||
for ref in refs:
|
||||
doc = asset_doc(
|
||||
org_id=org_id, prompt_id=prompt_id, workflow=workflow,
|
||||
ref=ref, extra_data=extra,
|
||||
)
|
||||
async with session.post(url, json=doc, headers=headers) as resp:
|
||||
if resp.status in (200, 201):
|
||||
landed += 1
|
||||
else:
|
||||
logging.warning(
|
||||
"content_publish: POST /v1/framework/Asset -> %s: %s",
|
||||
resp.status, (await resp.text())[:200],
|
||||
)
|
||||
except Exception as e: # unreachable cloud / timeout — a render is never failed for this
|
||||
logging.warning("content_publish: prompt %s not published: %s", prompt_id, e)
|
||||
|
||||
return landed
|
||||
@@ -0,0 +1,303 @@
|
||||
"""
|
||||
Chat copilot backend for Hanzo Studio.
|
||||
|
||||
POST /v1/copilot/chat turns a natural-language message about the workflow into
|
||||
graph operations the FRONTEND applies against app.graph (LiteGraph). The backend
|
||||
only decides ops — it never touches the graph itself.
|
||||
|
||||
One tool-calling round trip:
|
||||
|
||||
request {messages, graph_json, catalog?}
|
||||
-> proxy to an OpenAI-compatible chat endpoint with a system prompt that
|
||||
describes the graph + the ops tool
|
||||
-> {reply, ops} (add_node ops validated against the live node catalog
|
||||
here so the model can't invent node types)
|
||||
|
||||
Env (all optional):
|
||||
STUDIO_COPILOT_URL chat-completions URL. Default: the local engine at
|
||||
http://127.0.0.1:1234/v1/chat/completions if that port
|
||||
is open, else https://api.hanzo.ai/v1/chat/completions.
|
||||
STUDIO_COPILOT_MODEL model id (default "zen").
|
||||
STUDIO_COPILOT_TOKEN bearer for the gateway. Falls back to
|
||||
STUDIO_COMMERCE_TOKEN, then any bearer on the request.
|
||||
|
||||
The op schema is small and explicit — the frontend applier mirrors it exactly:
|
||||
|
||||
{op:"add_node", type, id?, pos?} id = a label you reuse to reference
|
||||
this new node in later ops
|
||||
{op:"set_widget", node_id, name, value}
|
||||
{op:"connect", from_node, from_slot, to_node, to_slot} slots by name or index
|
||||
{op:"move_node", node_id, pos:[x,y]}
|
||||
{op:"delete_node", node_id}
|
||||
{op:"set_prompt", node_id, text} sets a node's primary text widget
|
||||
{op:"layout"} left-to-right columnar tidy
|
||||
{op:"queue"} run the workflow
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
from typing import Optional
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Ops the frontend applier understands, and their required fields. layout/queue
|
||||
# take no args. slots default to 0 when omitted.
|
||||
_OP_REQUIRED = {
|
||||
"add_node": ("type",),
|
||||
"set_widget": ("node_id", "name"),
|
||||
"connect": ("from_node", "to_node"),
|
||||
"move_node": ("node_id", "pos"),
|
||||
"delete_node": ("node_id",),
|
||||
"set_prompt": ("node_id", "text"),
|
||||
"layout": (),
|
||||
"queue": (),
|
||||
}
|
||||
|
||||
_GATEWAY_URL = "https://api.hanzo.ai/v1/chat/completions"
|
||||
_LOCAL_URL = "http://127.0.0.1:1234/v1/chat/completions"
|
||||
|
||||
# Single tool: the model calls it to both reply and edit the graph.
|
||||
_EDIT_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "edit_graph",
|
||||
"description": (
|
||||
"Reply to the user and, when they ask to change the workflow, return "
|
||||
"the ordered graph operations to apply."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reply": {
|
||||
"type": "string",
|
||||
"description": "Short natural-language reply describing what you did or answering the question.",
|
||||
},
|
||||
"ops": {
|
||||
"type": "array",
|
||||
"description": "Ordered graph operations. Empty when no change is needed.",
|
||||
"items": {"type": "object"},
|
||||
},
|
||||
},
|
||||
"required": ["reply", "ops"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_SYSTEM = """You are the Hanzo Studio Copilot. Hanzo Studio is a node-graph image/video generator (a ComfyUI fork). You edit the user's live workflow graph by returning graph operations through the edit_graph tool.
|
||||
|
||||
Op schema (return ops in the order they should apply):
|
||||
{"op":"add_node","type":"<NodeType>","id":"<label>","pos":[x,y]} id is a label you invent; reuse it in later ops to reference this new node
|
||||
{"op":"set_widget","node_id":<id>,"name":"<widget>","value":<v>}
|
||||
{"op":"connect","from_node":<id>,"from_slot":<name|index>,"to_node":<id>,"to_slot":<name|index>}
|
||||
{"op":"set_prompt","node_id":<id>,"text":"<prompt text>"}
|
||||
{"op":"move_node","node_id":<id>,"pos":[x,y]}
|
||||
{"op":"delete_node","node_id":<id>}
|
||||
{"op":"layout"} tidy the whole graph left-to-right
|
||||
{"op":"queue"} run the workflow
|
||||
|
||||
Rules:
|
||||
- Reference existing nodes by their numeric id from CURRENT GRAPH. Reference nodes you add by the id label you gave them in add_node.
|
||||
- Prefer connecting slots by NAME (e.g. from_slot "IMAGE", to_slot "image"); the client resolves names to indices. Use index 0 if unsure.
|
||||
- Only use node types that exist. Pick from NODE TYPES; if a type is not listed, do not invent it.
|
||||
- Keep ops minimal — do exactly what the user asked, nothing more.
|
||||
- Always call edit_graph. If the user is only chatting/asking, reply with an empty ops array."""
|
||||
|
||||
|
||||
def _default_url() -> str:
|
||||
"""Local engine if its port is open, else the Hanzo gateway. Probed once at import."""
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", 1234), timeout=0.2):
|
||||
return _LOCAL_URL
|
||||
except OSError:
|
||||
return _GATEWAY_URL
|
||||
|
||||
|
||||
_DEFAULT_URL = _default_url()
|
||||
|
||||
_session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
|
||||
async def _get_session() -> aiohttp.ClientSession:
|
||||
global _session
|
||||
if _session is None or _session.closed:
|
||||
_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=90))
|
||||
return _session
|
||||
|
||||
|
||||
async def close_session() -> None:
|
||||
global _session
|
||||
if _session and not _session.closed:
|
||||
await _session.close()
|
||||
_session = None
|
||||
|
||||
|
||||
class CopilotError(Exception):
|
||||
"""Upstream LLM call failed."""
|
||||
|
||||
|
||||
def _resolve_target(request) -> tuple[str, str, str]:
|
||||
"""(url, model, token) for this request."""
|
||||
url = os.environ.get("STUDIO_COPILOT_URL", "").strip() or _DEFAULT_URL
|
||||
model = os.environ.get("STUDIO_COPILOT_MODEL", "").strip() or "zen"
|
||||
token = (
|
||||
os.environ.get("STUDIO_COPILOT_TOKEN", "").strip()
|
||||
or os.environ.get("STUDIO_COMMERCE_TOKEN", "").strip()
|
||||
)
|
||||
if not token:
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
token = auth[7:]
|
||||
return url, model, token
|
||||
|
||||
|
||||
def validate_ops(ops, valid_types: set) -> tuple[list, list]:
|
||||
"""Drop malformed ops and add_node ops naming unknown node types.
|
||||
|
||||
Returns (clean_ops, warnings). The frontend guards again, but validating
|
||||
node types here is the one place the model is prevented from inventing nodes.
|
||||
"""
|
||||
clean, warnings = [], []
|
||||
if not isinstance(ops, list):
|
||||
return clean, warnings
|
||||
for op in ops:
|
||||
if not isinstance(op, dict):
|
||||
continue
|
||||
name = op.get("op")
|
||||
if name not in _OP_REQUIRED:
|
||||
warnings.append(f"unknown op {name!r}")
|
||||
continue
|
||||
missing = [f for f in _OP_REQUIRED[name] if op.get(f) in (None, "")]
|
||||
if missing:
|
||||
warnings.append(f"{name} missing {', '.join(missing)}")
|
||||
continue
|
||||
if name == "add_node" and op["type"] not in valid_types:
|
||||
warnings.append(f"unknown node type {op['type']!r}")
|
||||
continue
|
||||
clean.append(op)
|
||||
return clean, warnings
|
||||
|
||||
|
||||
def _build_messages(body: dict) -> list:
|
||||
graph_json = body.get("graph_json")
|
||||
catalog = body.get("catalog") or {}
|
||||
history = body.get("messages") or []
|
||||
|
||||
types = catalog.get("types") or []
|
||||
detail = catalog.get("nodes") or {}
|
||||
ctx = (
|
||||
_SYSTEM
|
||||
+ "\n\nNODE TYPES (you may only add these):\n"
|
||||
+ ", ".join(sorted(types)[:1200])
|
||||
+ "\n\nSLOT DETAIL for node types currently in the graph:\n"
|
||||
+ json.dumps(detail)[:12000]
|
||||
+ "\n\nCURRENT GRAPH (node id -> {class_type, inputs}):\n"
|
||||
+ json.dumps(graph_json)[:24000]
|
||||
)
|
||||
msgs = [{"role": "system", "content": ctx}]
|
||||
# Only user/assistant turns from the client; cap history.
|
||||
for m in history[-20:]:
|
||||
if isinstance(m, dict) and m.get("role") in ("user", "assistant") and m.get("content"):
|
||||
msgs.append({"role": m["role"], "content": str(m["content"])})
|
||||
return msgs
|
||||
|
||||
|
||||
def _parse_completion(data: dict) -> tuple[str, list]:
|
||||
"""Pull (reply, ops) from an OpenAI-style completion, tolerating either a
|
||||
tool_call or a JSON/plain-text content reply."""
|
||||
try:
|
||||
msg = data["choices"][0]["message"]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return "", []
|
||||
|
||||
for call in msg.get("tool_calls") or []:
|
||||
fn = call.get("function") or {}
|
||||
if fn.get("name") != "edit_graph":
|
||||
continue
|
||||
try:
|
||||
args = json.loads(fn.get("arguments") or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
return str(args.get("reply", "")), args.get("ops") or []
|
||||
|
||||
content = msg.get("content") or ""
|
||||
if isinstance(content, list): # some gateways return content parts
|
||||
content = "".join(p.get("text", "") for p in content if isinstance(p, dict))
|
||||
content = content.strip()
|
||||
|
||||
# Fallback: a bare JSON object embedded in the prose.
|
||||
start, end = content.find("{"), content.rfind("}")
|
||||
if start != -1 and end > start:
|
||||
try:
|
||||
obj = json.loads(content[start : end + 1])
|
||||
if isinstance(obj, dict) and "ops" in obj:
|
||||
return str(obj.get("reply", content)), obj.get("ops") or []
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return content, []
|
||||
|
||||
|
||||
async def _post_chat(url: str, token: str, payload: dict) -> dict:
|
||||
"""POST an OpenAI-compatible chat completion. Raises CopilotError on failure."""
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
try:
|
||||
session = await _get_session()
|
||||
async with session.post(url, json=payload, headers=headers) as resp:
|
||||
text = await resp.text()
|
||||
if resp.status != 200:
|
||||
raise CopilotError(f"LLM {resp.status}: {text[:300]}")
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
raise CopilotError(f"bad LLM JSON: {e}") from e
|
||||
except aiohttp.ClientError as e:
|
||||
raise CopilotError(f"LLM unreachable: {e}") from e
|
||||
|
||||
|
||||
async def run_copilot(body: dict, valid_types: set, *, url: str, model: str, token: str) -> dict:
|
||||
"""One tool-calling round trip. Returns {reply, ops} with ops validated."""
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": _build_messages(body),
|
||||
"tools": [_EDIT_TOOL],
|
||||
"tool_choice": "auto",
|
||||
"temperature": 0.2,
|
||||
}
|
||||
data = await _post_chat(url, token, payload)
|
||||
reply, ops = _parse_completion(data)
|
||||
clean, warnings = validate_ops(ops, valid_types)
|
||||
if warnings:
|
||||
note = "Skipped: " + "; ".join(warnings)
|
||||
reply = f"{reply}\n\n{note}" if reply else note
|
||||
return {"reply": reply, "ops": clean}
|
||||
|
||||
|
||||
def add_copilot_routes(routes: web.RouteTableDef, server) -> None:
|
||||
"""Register POST /v1/copilot/chat."""
|
||||
|
||||
@routes.post("/v1/copilot/chat")
|
||||
async def copilot_chat(request):
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "invalid JSON"}, status=400)
|
||||
if not isinstance(body, dict) or not body.get("messages"):
|
||||
return web.json_response({"error": "messages is required"}, status=400)
|
||||
|
||||
import nodes # loaded by the time routes are added; kept out of module import for light tests
|
||||
valid_types = set(nodes.NODE_CLASS_MAPPINGS.keys())
|
||||
|
||||
url, model, token = _resolve_target(request)
|
||||
try:
|
||||
result = await run_copilot(body, valid_types, url=url, model=model, token=token)
|
||||
except CopilotError as e:
|
||||
logger.warning("copilot: %s", e)
|
||||
return web.json_response({"error": str(e), "reply": "", "ops": []}, status=502)
|
||||
return web.json_response(result)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Engine selector for Hanzo Studio.
|
||||
|
||||
Lists the execution targets ("engines") available to an org and records the
|
||||
org's default. Thin wrapper over the existing per-org compute registry
|
||||
(middleware/compute_config.py): an engine is either
|
||||
|
||||
- `local` — this Studio instance, always present, and
|
||||
- a registered org GPU worker (from compute.json's worker registry).
|
||||
|
||||
Selecting an engine routes prompts to it; prompt_router.py is the existing
|
||||
routing seam and already forwards to a worker by URL, so the default engine
|
||||
just names which target a prompt without an explicit device_preference lands on.
|
||||
|
||||
Leased cloud machines (via the platform's Visor compute surface,
|
||||
`GET {cloud}/v1/machines`) are a future engine class — the client interface is
|
||||
noted below but not built this iteration; the API shape here already
|
||||
accommodates the extra entries.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from middleware import compute_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Future: leased-machine engines. A VisorMachinesClient would implement
|
||||
# list_machines(org_id) -> list[dict] hitting GET {STUDIO_CLOUD_API_URL}/v1/machines
|
||||
# and its rows would be appended to list_engines() as {"type": "leased", ...}.
|
||||
# Console wiring + the client land in a later iteration; not built now.
|
||||
|
||||
|
||||
def list_engines(org_id: str) -> dict:
|
||||
"""Available engines for org_id plus the current default."""
|
||||
profile = compute_config.load_config(org_id)
|
||||
engines = [{
|
||||
"id": "local",
|
||||
"type": "local",
|
||||
"name": "This instance",
|
||||
"status": "ready",
|
||||
"device": "local",
|
||||
}]
|
||||
for w in profile.workers:
|
||||
engines.append({
|
||||
"id": w.worker_id,
|
||||
"type": "worker",
|
||||
"name": w.gpu_model or w.worker_id,
|
||||
"status": "ready" if (w.status == "ready" and w.is_alive()) else "offline",
|
||||
"device": w.device,
|
||||
"vram_gb": w.vram_gb,
|
||||
"url": w.url,
|
||||
})
|
||||
default = profile.default_engine or "local"
|
||||
if default != "local" and not any(e["id"] == default for e in engines):
|
||||
# The default engine deregistered — fall back to local, don't 500.
|
||||
default = "local"
|
||||
return {"engines": engines, "default": default}
|
||||
|
||||
|
||||
def resolve_engine_worker(org_id: str):
|
||||
"""If the org's default engine is a live worker, return it; else None (local)."""
|
||||
profile = compute_config.load_config(org_id)
|
||||
default = profile.default_engine or "local"
|
||||
if default == "local":
|
||||
return None
|
||||
for w in profile.workers:
|
||||
if w.worker_id == default and w.is_alive():
|
||||
return w
|
||||
return None
|
||||
|
||||
|
||||
def add_engine_routes(routes: web.RouteTableDef, server):
|
||||
"""Register /v1/engines on the coordinator."""
|
||||
|
||||
@routes.get("/v1/engines")
|
||||
async def get_engines(request):
|
||||
org_id = server._get_org_id(request)
|
||||
return web.json_response(list_engines(org_id))
|
||||
|
||||
@routes.put("/v1/engines/default")
|
||||
async def set_default_engine(request):
|
||||
org_id = server._get_org_id(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||
engine_id = body.get("engine")
|
||||
if not engine_id:
|
||||
return web.json_response({"error": "engine is required"}, status=400)
|
||||
valid = {e["id"] for e in list_engines(org_id)["engines"]}
|
||||
if engine_id not in valid:
|
||||
return web.json_response(
|
||||
{"error": f"unknown engine {engine_id!r}", "engines": sorted(valid)}, status=400
|
||||
)
|
||||
compute_config.update_config(org_id, {"default_engine": engine_id})
|
||||
return web.json_response(list_engines(org_id))
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Dispatch a render to the org's connected GPU node via the cloud gpu-jobs queue.
|
||||
|
||||
When the caller's IAM org has a live compute worker (a box running `hanzo gpu
|
||||
connect` / hanzod, registered in the cloud fleet), the render runs THERE instead
|
||||
of in this GPU-less pod. The user's own IAM bearer token is forwarded, so the
|
||||
cloud derives org + project + linked billing account from it — one auth path, no
|
||||
shared keys. Falls back to the in-pod queue when there is no live worker.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
|
||||
import folder_paths
|
||||
|
||||
CLOUD = os.environ.get("STUDIO_CLOUD_API_URL", "https://api.hanzo.ai").rstrip("/")
|
||||
JOBS_NS = os.environ.get("STUDIO_GPU_JOBS_NS", "gpu-jobs")
|
||||
# Public base of THIS studio deployment — where the BYO-GPU worker returns finished
|
||||
# outputs (POST /upload/output → orgs/{org}/output → gallery). The worker uploads
|
||||
# here with the user's IAM token; no S3/rclone credentials ever touch the box.
|
||||
STUDIO_PUBLIC_URL = os.environ.get("STUDIO_PUBLIC_URL", "https://studio.hanzo.ai").rstrip("/")
|
||||
|
||||
# Node classes whose `image`/`mask` widget names a file in the org input dir. The
|
||||
# render runs on the worker's LOCAL disk, so any file a user UPLOADED here (it lands
|
||||
# in orgs/{org}/input, which the worker cannot read) must travel WITH the job.
|
||||
_IMAGE_INPUT_CLASSES = ("LoadImage", "LoadImageMask", "LoadImageOutput")
|
||||
# Cap the inlined payload so a pathological graph can't blow the tasks-API body.
|
||||
_MAX_INLINE_BYTES = 48 * 1024 * 1024
|
||||
|
||||
|
||||
def _collect_input_images(prompt: dict, org_id: str) -> list[dict]:
|
||||
"""Read every LoadImage-referenced file that exists under this org's input
|
||||
directory and return them base64-encoded for transport to the worker. Files
|
||||
already resident on the worker (e.g. staged garment refs) are harmless to
|
||||
resend — the worker overwrites byte-identical copies. Best-effort: an absent
|
||||
or oversized file is skipped, never fatal."""
|
||||
in_dir = folder_paths.get_org_input_directory(org_id)
|
||||
seen: set[str] = set()
|
||||
out: list[dict] = []
|
||||
total = 0
|
||||
for node in prompt.values():
|
||||
if not isinstance(node, dict) or node.get("class_type") not in _IMAGE_INPUT_CLASSES:
|
||||
continue
|
||||
name = (node.get("inputs") or {}).get("image")
|
||||
if not isinstance(name, str) or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
# Honor a "sub/dir/file.png" reference; reject traversal.
|
||||
rel = os.path.normpath(name)
|
||||
if rel.startswith("..") or os.path.isabs(rel):
|
||||
continue
|
||||
path = os.path.join(in_dir, rel)
|
||||
try:
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
size = os.path.getsize(path)
|
||||
if size == 0 or total + size > _MAX_INLINE_BYTES:
|
||||
continue
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
except OSError:
|
||||
continue
|
||||
total += len(data)
|
||||
sub, base = os.path.split(rel)
|
||||
out.append({"name": base, "subfolder": sub, "data": base64.b64encode(data).decode()})
|
||||
return out
|
||||
|
||||
|
||||
def _bearer(request) -> str:
|
||||
"""The caller's IAM token, exactly as iam_auth_middleware accepts it:
|
||||
Authorization header first, then the browser session cookies."""
|
||||
h = request.headers.get("Authorization", "")
|
||||
if h.lower().startswith("bearer "):
|
||||
return h
|
||||
tok = request.cookies.get("hanzo_token") or request.cookies.get("access_token")
|
||||
return f"Bearer {tok}" if tok else ""
|
||||
|
||||
|
||||
# Identity keys a fleet machine might carry, most human-friendly first. We READ
|
||||
# whatever the gpu-jobs lane publishes (home-lab labels like spark/dbc/evo land in
|
||||
# `name`/`hostname`); we never invent a parallel registry.
|
||||
_NODE_KEYS = ("name", "hostname", "label", "node", "worker_id", "machine_id", "id", "gpu_model")
|
||||
|
||||
|
||||
def _node_label(m: dict) -> str:
|
||||
for k in _NODE_KEYS:
|
||||
v = m.get(k)
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
return "gpu"
|
||||
|
||||
|
||||
def _fleet_machines(tok: str) -> list:
|
||||
"""The caller-org's machines from the cloud fleet. The token scopes the fleet to
|
||||
the caller's org, so this is inherently tenant-isolated."""
|
||||
req = urllib.request.Request(f"{CLOUD}/v1/machines", headers={"Authorization": tok})
|
||||
d = json.loads(urllib.request.urlopen(req, timeout=10).read())
|
||||
ms = d.get("machines", [])
|
||||
return ms if isinstance(ms, list) else []
|
||||
|
||||
|
||||
def _online_gpu_nodes(tok: str) -> list:
|
||||
return [m for m in _fleet_machines(tok) if m.get("status") == "online" and m.get("gpu")]
|
||||
|
||||
|
||||
def _has_online_gpu(tok: str) -> bool:
|
||||
"""True if the caller's org has an online GPU machine in the cloud fleet."""
|
||||
return bool(_online_gpu_nodes(tok))
|
||||
|
||||
|
||||
def dispatch_if_worker(request, org_id: str, prompt_id: str, prompt: dict) -> bool:
|
||||
"""If the caller's org has an online GPU node in the fleet, enqueue a
|
||||
studio.render job to gpu-jobs and return True. Return False to run in-pod.
|
||||
Never raises — any failure falls back to local."""
|
||||
try:
|
||||
tok = _bearer(request)
|
||||
online = _online_gpu_nodes(tok) if tok else []
|
||||
if not online:
|
||||
return False
|
||||
# Which node runs it: the single online GPU is definitive; with several the
|
||||
# claimer is decided at pick-up time, so record the ambiguous "gpu".
|
||||
node = _node_label(online[0]) if len(online) == 1 else "gpu"
|
||||
body = json.dumps({
|
||||
"activityId": prompt_id,
|
||||
"runId": prompt_id,
|
||||
"activityType": {"name": "studio.render"},
|
||||
"taskQueue": JOBS_NS,
|
||||
# 1200s: a heavy Qwen edit on a cold GB10 (model reload + full 30-step
|
||||
# sample) can exceed 600s; the worker heartbeats each step, so this is
|
||||
# the ceiling for a genuinely long single render, not idle slack.
|
||||
"heartbeatTimeout": "1200s",
|
||||
# Liveness is the heartbeat above; this cap only bounds a live-but-stuck
|
||||
# render. Unset, the tasks default (~1h) reaped real renders mid-run and
|
||||
# the queue re-ran them for hours.
|
||||
"startToCloseTimeout": "14400s",
|
||||
"input": {
|
||||
"prompt": prompt,
|
||||
"org": org_id,
|
||||
"uploadUrl": STUDIO_PUBLIC_URL,
|
||||
# Uploaded inputs live in orgs/{org}/input on THIS pod; the worker
|
||||
# renders on its own disk and cannot read them — ship them along so
|
||||
# LoadImage resolves there.
|
||||
"inputs": _collect_input_images(prompt, org_id),
|
||||
},
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{CLOUD}/v1/tasks/namespaces/{JOBS_NS}/activities",
|
||||
data=body,
|
||||
headers={"Authorization": tok, "Content-Type": "application/json"},
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=15).read()
|
||||
_track_dispatched(org_id, prompt_id, prompt, node=node)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _track_dispatched(org_id: str, prompt_id: str, prompt: dict, node: str = "gpu") -> None:
|
||||
"""Record a BYO-GPU render dispatched to gpu-jobs so the studio UI can SHOW it
|
||||
in the queue. BYO-GPU jobs render on the worker (gpu-jobs), not the pod's local
|
||||
/queue — so without this the user's fix/compose 'never appears in the queue'.
|
||||
A tiny per-org JSON the /v1/render-queue endpoint reads; entries drop off when
|
||||
their output lands in the library or they age out. Never raises."""
|
||||
try:
|
||||
import time
|
||||
import folder_paths
|
||||
# human title from the SaveImage prefix; refs from LoadImage for a thumbnail
|
||||
full_prefix, refs = "render", []
|
||||
for n in prompt.values():
|
||||
if not isinstance(n, dict):
|
||||
continue
|
||||
ct = n.get("class_type", "")
|
||||
if ct == "SaveImage":
|
||||
full_prefix = n.get("inputs", {}).get("filename_prefix") or "render"
|
||||
elif "LoadImage" in ct:
|
||||
im = n.get("inputs", {}).get("image")
|
||||
if im:
|
||||
refs.append(im)
|
||||
out_dir = folder_paths.get_org_output_directory(org_id)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
jf = os.path.join(out_dir, "render_jobs.json")
|
||||
try:
|
||||
jobs = json.loads(open(jf).read())
|
||||
except Exception:
|
||||
jobs = []
|
||||
jobs = [j for j in jobs if j.get("id") != prompt_id][-40:] # dedupe + cap
|
||||
# store the FULL output prefix (e.g. "fixes/model01_..._00001_") so the queue
|
||||
# endpoint can check for the ACTUAL output file, not a fuzzy base match that
|
||||
# collides with the source image and makes the job vanish after 30s.
|
||||
jobs.append({"id": prompt_id, "prefix": full_prefix.split("/")[-1],
|
||||
"outPrefix": full_prefix, "refs": refs[:1], "node": node,
|
||||
"ts": int(time.time()), "status": "rendering"})
|
||||
tmp = jf + ".tmp"
|
||||
open(tmp, "w").write(json.dumps(jobs))
|
||||
os.replace(tmp, jf)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -45,9 +45,11 @@ PUBLIC_PATH_PREFIXES = (
|
||||
PUBLIC_PATHS_EXACT = {
|
||||
"/",
|
||||
"/callback",
|
||||
"/login",
|
||||
"/health",
|
||||
"/ready",
|
||||
"/metrics",
|
||||
"/logout",
|
||||
"/api/health",
|
||||
"/api/ready",
|
||||
"/api/features",
|
||||
@@ -65,6 +67,7 @@ PUBLIC_PATHS_EXACT = {
|
||||
_OIDC_SCOPES = "openid profile email"
|
||||
_STATE_COOKIE = "studio_oauth"
|
||||
_SESSION_COOKIE = "hanzo_token"
|
||||
_ACTIVE_ORG_COOKIE = "studio_active_org" # mirrors middleware.session.ACTIVE_ORG_COOKIE
|
||||
_STATE_TTL = 600 # seconds a pending login may take
|
||||
|
||||
# Decoded-claims cache: hash(token) -> (user_info, expires_at_monotonic)
|
||||
@@ -169,20 +172,61 @@ def _verify_state(blob: str, secret: str) -> dict | None:
|
||||
_ALGS = ["RS256", "RS512", "ES256", "ES384", "ES512"]
|
||||
|
||||
|
||||
def _orgs_from_claims(claims: dict) -> list[str]:
|
||||
"""Every org the token authorizes, home org first. IAM may carry the set as
|
||||
``organizations`` / ``orgs`` / ``groups`` (list or comma string); the home
|
||||
org (``owner`` / ``organization``) is always included so the switcher has at
|
||||
least one real entry."""
|
||||
home = claims.get("owner") or claims.get("organization") or "default"
|
||||
out = [home]
|
||||
for key in ("organizations", "orgs", "groups"):
|
||||
val = claims.get(key)
|
||||
if isinstance(val, str):
|
||||
val = [v.strip() for v in val.split(",") if v.strip()]
|
||||
if isinstance(val, list):
|
||||
for v in val:
|
||||
v = v.get("name") if isinstance(v, dict) else v
|
||||
if isinstance(v, str) and v and v not in out:
|
||||
out.append(v)
|
||||
return out
|
||||
|
||||
|
||||
def _claims_to_user(claims: dict) -> dict:
|
||||
orgs = _orgs_from_claims(claims)
|
||||
return {
|
||||
"sub": claims.get("sub", ""),
|
||||
"name": claims.get("name") or claims.get("preferred_username", ""),
|
||||
"email": claims.get("email", ""),
|
||||
"org_id": claims.get("owner") or claims.get("organization") or "default",
|
||||
"org_id": orgs[0],
|
||||
"orgs": orgs,
|
||||
"avatar": claims.get("picture", ""),
|
||||
}
|
||||
|
||||
|
||||
def verify_jwt(token: str, key, issuer: str, audience: str | None) -> dict | None:
|
||||
def _with_active_org(user: dict, active_org_cookie: str | None) -> dict:
|
||||
"""Return a shallow copy of ``user`` whose ``org_id`` reflects a validated
|
||||
active-org selection. A copy (never a mutation) keeps the token cache from
|
||||
being poisoned across requests that carry different selection cookies."""
|
||||
from middleware.session import resolve_org
|
||||
orgs = user.get("orgs") or [user.get("org_id", "default")]
|
||||
resolved = resolve_org(user.get("org_id", "default"), orgs, active_org_cookie)
|
||||
if resolved == user.get("org_id"):
|
||||
return user
|
||||
return {**user, "org_id": resolved}
|
||||
|
||||
|
||||
def verify_jwt(token: str, key, issuer: str, audience: "str | list[str] | None") -> dict | None:
|
||||
"""
|
||||
Pure JWT verification: signature (via the resolved JWKS key), exp, iss and
|
||||
aud. Returns normalized user info or None if the token is invalid. No I/O.
|
||||
|
||||
``audience`` may be a single string or a list of accepted audiences. A list
|
||||
passes if the token's ``aud`` intersects it — this is how a first-party
|
||||
Hanzo user token (minted for e.g. hanzo-console / hanzo-desktop) is accepted
|
||||
by studio without being re-minted specifically for hanzo-studio. Org is
|
||||
always derived from the ``owner``/``groups`` claims, independent of aud, so
|
||||
widening the accepted audience set to the trusted first-party clients does
|
||||
not change who can act as which org.
|
||||
"""
|
||||
import jwt
|
||||
try:
|
||||
@@ -211,6 +255,16 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
|
||||
iam_url = iam_url.rstrip("/")
|
||||
client_id = os.environ.get("STUDIO_IAM_CLIENT_ID", "hanzo-studio")
|
||||
issuer = os.environ.get("STUDIO_IAM_ISSUER", iam_url)
|
||||
# Accept the studio client_id plus any other first-party audiences (comma-
|
||||
# separated STUDIO_IAM_ALLOWED_AUDIENCES). Lets a Hanzo user token minted for
|
||||
# the console / desktop / CLI authorize an org-scoped upload here without a
|
||||
# per-service re-mint. client_id is always included.
|
||||
_extra_aud = [
|
||||
a.strip()
|
||||
for a in os.environ.get("STUDIO_IAM_ALLOWED_AUDIENCES", "").split(",")
|
||||
if a.strip()
|
||||
]
|
||||
allowed_audiences = list(dict.fromkeys([client_id, *_extra_aud]))
|
||||
jwks_uri = f"{iam_url}/v1/iam/.well-known/jwks"
|
||||
jwks = _JwksCache(jwks_uri)
|
||||
|
||||
@@ -237,7 +291,7 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
|
||||
if key is None:
|
||||
return None
|
||||
|
||||
user = verify_jwt(token, key, issuer, client_id)
|
||||
user = verify_jwt(token, key, issuer, allowed_audiences)
|
||||
if user is None:
|
||||
_token_cache.pop(ck, None)
|
||||
return None
|
||||
@@ -250,7 +304,7 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
|
||||
_token_cache.pop(k, None)
|
||||
return user
|
||||
|
||||
def _authorize_redirect(request: web.Request) -> web.Response:
|
||||
def _authorize_redirect(request: web.Request, target: str | None = None) -> web.Response:
|
||||
base = _external_base(request)
|
||||
verifier = _b64url(secrets.token_bytes(32))
|
||||
challenge = _b64url(hashlib.sha256(verifier.encode()).digest())
|
||||
@@ -258,7 +312,7 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
|
||||
state_payload = {
|
||||
"n": nonce,
|
||||
"v": verifier,
|
||||
"t": str(request.rel_url),
|
||||
"t": target or str(request.rel_url),
|
||||
"exp": int(_time.time()) + _STATE_TTL,
|
||||
}
|
||||
state = _sign_state(state_payload, client_id) # HMAC key; secret optional
|
||||
@@ -278,6 +332,29 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
|
||||
)
|
||||
return resp
|
||||
|
||||
async def handle_login(request: web.Request) -> web.Response:
|
||||
"""Begin sign-in: the ONE server-owned entry point the login page links to.
|
||||
|
||||
The SPA's "Sign in with Hanzo" button targets this route so the OIDC
|
||||
Authorization Code flow (PKCE + signed state + ``/callback``) is built
|
||||
server-side — there is exactly one way to start a login, and the client
|
||||
never hand-rolls an authorize URL. Already-authenticated callers skip
|
||||
straight to the app; everyone else is bounced to IAM and returns to ``/``.
|
||||
"""
|
||||
token = None
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
if not token:
|
||||
token = request.cookies.get(_SESSION_COOKIE) or request.cookies.get("access_token")
|
||||
if token:
|
||||
try:
|
||||
if await _validate(token):
|
||||
return web.HTTPFound("/")
|
||||
except Exception:
|
||||
pass # IAM/JWKS hiccup — fall through and re-authenticate cleanly.
|
||||
return _authorize_redirect(request, target="/")
|
||||
|
||||
async def handle_callback(request: web.Request) -> web.Response:
|
||||
"""Exchange the authorization code for a token and set the session cookie."""
|
||||
code = request.query.get("code")
|
||||
@@ -334,10 +411,12 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
|
||||
return await handler(request)
|
||||
|
||||
if localhost_bypass and _is_loopback_request(request):
|
||||
request["iam_user"] = {
|
||||
local_org = os.environ.get("STUDIO_ORG_ID", "default")
|
||||
local_user = {
|
||||
"sub": "local", "name": "Local User", "email": "",
|
||||
"org_id": os.environ.get("STUDIO_ORG_ID", "default"), "avatar": "",
|
||||
"org_id": local_org, "orgs": [local_org], "avatar": "",
|
||||
}
|
||||
request["iam_user"] = _with_active_org(local_user, request.cookies.get(_ACTIVE_ORG_COOKIE))
|
||||
return await handler(request)
|
||||
|
||||
token = None
|
||||
@@ -366,9 +445,16 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
|
||||
return _authorize_redirect(request)
|
||||
return web.json_response({"error": "Invalid or expired token"}, status=401)
|
||||
|
||||
request["iam_user"] = user
|
||||
request["iam_user"] = _with_active_org(user, request.cookies.get(_ACTIVE_ORG_COOKIE))
|
||||
# The verified raw access token. Studio records a completed render in the
|
||||
# content lane AS THE REQUESTING USER (middleware/content_publish.py), so it
|
||||
# lands in that user's org with that user's rights — no service account, no
|
||||
# impersonation. Carried to the worker over the queue's `sensitive` channel,
|
||||
# so it is never persisted into history.
|
||||
request["iam_token"] = token
|
||||
return await handler(request)
|
||||
|
||||
# Expose the callback handler so server.py can register the /callback route.
|
||||
# Expose the login + callback handlers so server.py can register the routes.
|
||||
iam_auth_middleware.handle_login = handle_login
|
||||
iam_auth_middleware.handle_callback = handle_callback
|
||||
return iam_auth_middleware
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""MCP-over-HTTP for Hanzo Studio — the studio render pipeline as a Model Context
|
||||
Protocol server so the cloud chat `create` capability can drive it.
|
||||
|
||||
POST /v1/mcp speaks JSON-RPC 2.0 (the MCP wire format) and exposes the SAME render
|
||||
dispatch the /v1/library/{fix,compose} routes use — ``studio_home.dispatch_fix`` /
|
||||
``studio_home.dispatch_compose`` — as MCP tools. So a fix/compose an LLM issues
|
||||
behaves identically to one the Studio UI issues: one dispatch path, no second
|
||||
pipeline. Auth and org ride the existing IAM middleware exactly like every other
|
||||
/v1/* route (org = ``studio_home._org_of(request)``); nothing is registered here
|
||||
that the global auth layer does not already gate.
|
||||
|
||||
Methods (JSON-RPC 2.0 envelope: {jsonrpc,id,result|error}):
|
||||
initialize -> {protocolVersion, serverInfo, capabilities:{tools:{}}}
|
||||
tools/list -> the fix / compose / ask tools + JSON schemas
|
||||
tools/call -> dispatch fix|compose|ask and return MCP tool-result content
|
||||
(the render prompt_id / output_prefix). A DispatchError
|
||||
becomes an isError tool-result, never a 500. An unknown
|
||||
METHOD is a JSON-RPC method-not-found error.
|
||||
|
||||
── Registration: the ONE recipe ──────────────────────────────────────────────
|
||||
An operator/admin registers THIS studio MCP server per-org, once, through the
|
||||
cloud tool registry — the studio public URL with the /v1/mcp path:
|
||||
|
||||
POST https://api.hanzo.ai/v1/tools/servers
|
||||
{ "name": "studio",
|
||||
"url": "https://studio.hanzo.ai/v1/mcp",
|
||||
"org": "<org>" }
|
||||
|
||||
Cloud then calls tools/list here and exposes the returned tools to /v1/chat's
|
||||
`create` capability as SourceMCP tools. There is no per-tool wiring in cloud: the
|
||||
tool set is whatever tools/list returns, so adding a studio tool is a one-line
|
||||
change to _TOOLS here — it flows to chat with no cloud deploy.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from middleware import studio_home
|
||||
from studio_version import __version__
|
||||
|
||||
# The MCP revision this server implements. Reported in `initialize`.
|
||||
_PROTOCOL_VERSION = "2025-06-18"
|
||||
|
||||
# JSON-RPC 2.0 error codes (only the ones we emit).
|
||||
_PARSE_ERROR = -32700
|
||||
_INVALID_REQUEST = -32600
|
||||
_METHOD_NOT_FOUND = -32601
|
||||
|
||||
# The studio render tools. `inputSchema` is the JSON Schema cloud advertises to
|
||||
# the model; the dispatch functions validate again (instruction length, asset
|
||||
# existence), so the schema is a hint, not the trust boundary.
|
||||
_TOOLS = [
|
||||
{
|
||||
"name": "fix",
|
||||
"description": (
|
||||
"Edit one library asset with an instruction (Qwen image-edit). "
|
||||
"Returns the render prompt_id and output_prefix."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "library-relative path of the asset to edit"},
|
||||
"instruction": {"type": "string", "description": "the edit to apply (3-500 chars)"},
|
||||
},
|
||||
"required": ["path", "instruction"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "compose",
|
||||
"description": (
|
||||
"Combine 2-5 library assets into a new output per an instruction. "
|
||||
"Returns the render prompt_id and output_prefix."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"paths": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"minItems": 2,
|
||||
"maxItems": 5,
|
||||
"description": "2-5 library-relative asset paths to combine",
|
||||
},
|
||||
"instruction": {"type": "string", "description": "how to combine them (3-500 chars)"},
|
||||
},
|
||||
"required": ["paths", "instruction"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "ask",
|
||||
"description": (
|
||||
"Clarifier no-op: echo a question back to the user when the request is "
|
||||
"ambiguous. Produces no render."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"question": {"type": "string", "description": "the clarifying question to ask"},
|
||||
},
|
||||
"required": ["question"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _result(id_, result: dict) -> dict:
|
||||
return {"jsonrpc": "2.0", "id": id_, "result": result}
|
||||
|
||||
|
||||
def _error(id_, code: int, message: str) -> dict:
|
||||
return {"jsonrpc": "2.0", "id": id_, "error": {"code": code, "message": message}}
|
||||
|
||||
|
||||
def _content(payload: dict, is_error: bool = False) -> dict:
|
||||
"""An MCP tool result: text content parts + optional isError flag. The text is
|
||||
the JSON dispatch payload (prompt_id / output_prefix) or the error message."""
|
||||
r = {"content": [{"type": "text", "text": json.dumps(payload)}]}
|
||||
if is_error:
|
||||
r["isError"] = True
|
||||
return r
|
||||
|
||||
|
||||
async def _call_tool(request: web.Request, server, name: str, arguments: dict):
|
||||
"""Dispatch one tool call to the shared render path. Returns the MCP
|
||||
tool-result, or None for an unknown tool name (caller maps that to isError)."""
|
||||
org = studio_home._org_of(request)
|
||||
if name == "fix":
|
||||
out = await studio_home.dispatch_fix(
|
||||
request, server, org, arguments.get("path", ""), arguments.get("instruction", ""))
|
||||
return _content(out)
|
||||
if name == "compose":
|
||||
out = await studio_home.dispatch_compose(
|
||||
request, server, org, arguments.get("paths") or [], arguments.get("instruction", ""))
|
||||
return _content(out)
|
||||
if name == "ask":
|
||||
return _content({"ok": True, "question": str(arguments.get("question", "")).strip()})
|
||||
return None
|
||||
|
||||
|
||||
async def _handle(request: web.Request, server, msg) -> dict | None:
|
||||
"""Process one JSON-RPC 2.0 message. Returns the response envelope, or None
|
||||
for a notification (a message with no ``id`` — no response is sent)."""
|
||||
if not isinstance(msg, dict) or msg.get("jsonrpc") != "2.0":
|
||||
return _error(None, _INVALID_REQUEST, "invalid JSON-RPC 2.0 request")
|
||||
is_notification = "id" not in msg
|
||||
id_ = msg.get("id")
|
||||
method = msg.get("method")
|
||||
params = msg.get("params") or {}
|
||||
|
||||
if method == "initialize":
|
||||
result = {
|
||||
"protocolVersion": _PROTOCOL_VERSION,
|
||||
"serverInfo": {"name": "hanzo-studio", "version": __version__},
|
||||
"capabilities": {"tools": {"listChanged": False}},
|
||||
}
|
||||
elif method == "tools/list":
|
||||
result = {"tools": _TOOLS}
|
||||
elif method == "tools/call":
|
||||
name = params.get("name")
|
||||
arguments = params.get("arguments") or {}
|
||||
try:
|
||||
result = await _call_tool(request, server, name, arguments)
|
||||
except studio_home.DispatchError as e:
|
||||
result = _content({"error": str(e)}, is_error=True)
|
||||
if result is None:
|
||||
result = _content({"error": f"unknown tool: {name}"}, is_error=True)
|
||||
else:
|
||||
return None if is_notification else _error(id_, _METHOD_NOT_FOUND, f"unknown method: {method}")
|
||||
|
||||
return None if is_notification else _result(id_, result)
|
||||
|
||||
|
||||
def add_mcp_routes(routes: web.RouteTableDef, server) -> None:
|
||||
"""Register POST /v1/mcp — the studio MCP-over-HTTP endpoint."""
|
||||
|
||||
@routes.post("/v1/mcp")
|
||||
async def mcp_endpoint(request: web.Request):
|
||||
try:
|
||||
msg = await request.json()
|
||||
except Exception:
|
||||
return web.json_response(_error(None, _PARSE_ERROR, "invalid JSON"), status=400)
|
||||
resp = await _handle(request, server, msg)
|
||||
if resp is None: # notification: acknowledge with no body
|
||||
return web.Response(status=202)
|
||||
return web.json_response(resp)
|
||||
@@ -59,6 +59,17 @@ async def route_prompt(
|
||||
if device_pref == "cpu":
|
||||
return None
|
||||
|
||||
# Engine selector: if the org picked a specific worker as its default
|
||||
# engine, route there. `local` (the default) leaves current behavior
|
||||
# unchanged. See middleware/engine_selector.py.
|
||||
from middleware.engine_selector import resolve_engine_worker
|
||||
engine_worker = resolve_engine_worker(org_id)
|
||||
if engine_worker is not None:
|
||||
result = await forward_to_worker(engine_worker, json_data)
|
||||
if result is not None:
|
||||
return {"action": "forward", "worker": engine_worker, "response": result}
|
||||
logger.warning("Default engine %s failed, falling through", engine_worker.worker_id)
|
||||
|
||||
config = load_config(org_id)
|
||||
|
||||
# No GPU enabled in profile — execute locally
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Identity / session API for the Hanzo Studio user menu.
|
||||
|
||||
Thin ``/v1`` endpoints over the IAM-validated context the auth middleware
|
||||
already produces (``request["iam_user"]``). One module owns the session shape
|
||||
the frontend menu reads and the active-org / active-project selection; the auth
|
||||
middleware owns token validation and imports :func:`resolve_org` so the switched
|
||||
org drives the *same* ``iam_user["org_id"]`` the tenancy / output paths use.
|
||||
|
||||
GET /v1/session -> {user,email,org,orgs[],project,projects[],...}
|
||||
POST /v1/session/org -> set active org (must be one the user belongs to)
|
||||
POST /v1/session/project -> set active project (a workflow-workspace folder)
|
||||
GET /logout -> clear the session + active-org/project cookies,
|
||||
bounce to the IAM end-session then back to /login
|
||||
|
||||
Works with auth on (real IAM user) or off (local single-user): with auth off the
|
||||
request carries no ``iam_user`` so the local user is synthesized from env +
|
||||
cookies, and the menu still renders and switches for local development.
|
||||
"""
|
||||
import os
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
import folder_paths
|
||||
|
||||
# Cookies. The session (auth) cookie name mirrors the auth middleware.
|
||||
SESSION_COOKIE = "hanzo_token"
|
||||
STATE_COOKIE = "studio_oauth"
|
||||
ACTIVE_ORG_COOKIE = "studio_active_org"
|
||||
ACTIVE_PROJECT_COOKIE = "studio_active_project"
|
||||
|
||||
DEFAULT_PROJECT = "default"
|
||||
|
||||
|
||||
def _cfg(attr: str, env: str, default: str) -> str:
|
||||
"""One config read: the CLI arg (which itself defaults to the env var) wins,
|
||||
with a plain env / literal fallback so unit tests need no arg parsing."""
|
||||
try:
|
||||
from studio.cli_args import args
|
||||
val = getattr(args, attr, None)
|
||||
if val:
|
||||
return val
|
||||
except Exception:
|
||||
pass
|
||||
return os.environ.get(env, default)
|
||||
|
||||
|
||||
def resolve_org(base_org: str, orgs: list[str], cookie_val: str | None) -> str:
|
||||
"""Return the effective org: the cookie-selected one iff the user belongs to
|
||||
it, else the token's own org. This is the *single* rule the auth middleware
|
||||
and the session API both apply, so a switch can never escape membership."""
|
||||
if cookie_val and cookie_val in orgs:
|
||||
return cookie_val
|
||||
return base_org
|
||||
|
||||
|
||||
def _external_base(request: web.Request) -> str:
|
||||
proto = request.headers.get("X-Forwarded-Proto", request.scheme)
|
||||
host = request.headers.get("X-Forwarded-Host", request.host)
|
||||
return f"{proto}://{host}"
|
||||
|
||||
|
||||
def _local_user() -> dict:
|
||||
org = os.environ.get("STUDIO_ORG_ID") or "default"
|
||||
return {
|
||||
"sub": "local", "name": "Local User", "email": "",
|
||||
"org_id": org, "avatar": "", "orgs": [org],
|
||||
}
|
||||
|
||||
|
||||
def _projects(org_id: str) -> list[str]:
|
||||
"""Projects = the workflow-workspace subfolders under the org's user dir
|
||||
(user/[orgs/<org>/]user/<uid>/workflows/<project>). Best-effort; the default
|
||||
project is always present."""
|
||||
names = {DEFAULT_PROJECT}
|
||||
try:
|
||||
base = folder_paths.get_org_user_directory(org_id)
|
||||
for uid in os.listdir(base):
|
||||
wf = os.path.join(base, uid, "workflows")
|
||||
if not os.path.isdir(wf):
|
||||
continue
|
||||
for p in os.listdir(wf):
|
||||
if os.path.isdir(os.path.join(wf, p)):
|
||||
names.add(p)
|
||||
except (OSError, FileNotFoundError):
|
||||
pass
|
||||
return sorted(names)
|
||||
|
||||
|
||||
def _session(request: web.Request) -> dict:
|
||||
user = request.get("iam_user") or _local_user()
|
||||
orgs = user.get("orgs") or [user.get("org_id", "default")]
|
||||
# When auth is on the middleware already applied the override; applying the
|
||||
# same validated rule here is idempotent and covers the auth-off local path.
|
||||
org = resolve_org(user.get("org_id", "default"), orgs, request.cookies.get(ACTIVE_ORG_COOKIE))
|
||||
project = request.cookies.get(ACTIVE_PROJECT_COOKIE) or DEFAULT_PROJECT
|
||||
return {
|
||||
"authenticated": bool(request.get("iam_user")),
|
||||
"sub": user.get("sub", ""),
|
||||
"user": user.get("name") or user.get("email") or "User",
|
||||
"email": user.get("email", ""),
|
||||
"avatar": user.get("avatar", ""),
|
||||
"org": org,
|
||||
"orgs": orgs,
|
||||
"project": project,
|
||||
"projects": _projects(org),
|
||||
"iam_url": _cfg("iam_url", "STUDIO_IAM_URL", "https://hanzo.id"),
|
||||
"console_url": _cfg("console_url", "STUDIO_CONSOLE_URL", "https://console.hanzo.ai"),
|
||||
}
|
||||
|
||||
|
||||
def _is_https(request: web.Request) -> bool:
|
||||
return _external_base(request).startswith("https")
|
||||
|
||||
|
||||
def add_session_routes(routes: web.RouteTableDef, server) -> None:
|
||||
"""Register the session / identity endpoints on the app route table."""
|
||||
|
||||
iam_url = _cfg("iam_url", "STUDIO_IAM_URL", "https://hanzo.id").rstrip("/")
|
||||
|
||||
@routes.get("/v1/session")
|
||||
async def get_session(request):
|
||||
return web.json_response(_session(request))
|
||||
|
||||
@routes.post("/v1/session/org")
|
||||
async def set_org(request):
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "invalid JSON"}, status=400)
|
||||
org = (body or {}).get("org")
|
||||
if not org or not isinstance(org, str):
|
||||
return web.json_response({"error": "org is required"}, status=400)
|
||||
user = request.get("iam_user") or _local_user()
|
||||
orgs = user.get("orgs") or [user.get("org_id", "default")]
|
||||
if org not in orgs:
|
||||
return web.json_response({"error": "not a member of that org", "orgs": orgs}, status=403)
|
||||
resp = web.json_response({"ok": True, "org": org})
|
||||
resp.set_cookie(
|
||||
ACTIVE_ORG_COOKIE, org, max_age=31536000,
|
||||
httponly=False, secure=_is_https(request), samesite="Lax", path="/",
|
||||
)
|
||||
# Switching org resets the project scope to the org's default.
|
||||
resp.del_cookie(ACTIVE_PROJECT_COOKIE, path="/")
|
||||
return resp
|
||||
|
||||
@routes.post("/v1/session/project")
|
||||
async def set_project(request):
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "invalid JSON"}, status=400)
|
||||
project = (body or {}).get("project")
|
||||
if not project or not isinstance(project, str) or "/" in project or "\\" in project:
|
||||
return web.json_response({"error": "valid project is required"}, status=400)
|
||||
resp = web.json_response({"ok": True, "project": project})
|
||||
resp.set_cookie(
|
||||
ACTIVE_PROJECT_COOKIE, project, max_age=31536000,
|
||||
httponly=False, secure=_is_https(request), samesite="Lax", path="/",
|
||||
)
|
||||
return resp
|
||||
|
||||
async def logout(request):
|
||||
"""Clear the local session + selection cookies and bounce to the IAM
|
||||
end-session endpoint (so SSO is really dropped, not silently re-issued),
|
||||
which returns the browser to ``/login`` for a fresh sign-in."""
|
||||
base = _external_base(request)
|
||||
logout_url = os.environ.get("STUDIO_IAM_LOGOUT_URL", f"{iam_url}/v1/iam/oauth/logout")
|
||||
target = f"{logout_url}?{urlencode({'post_logout_redirect_uri': base + '/login', 'redirect_uri': base + '/login'})}"
|
||||
resp = web.HTTPFound(target)
|
||||
for name in (SESSION_COOKIE, "access_token", ACTIVE_ORG_COOKIE, ACTIVE_PROJECT_COOKIE, STATE_COOKIE):
|
||||
resp.del_cookie(name, path="/")
|
||||
return resp
|
||||
|
||||
routes.get("/logout")(logout)
|
||||
routes.post("/logout")(logout)
|
||||
@@ -0,0 +1,852 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Hanzo Studio</title>
|
||||
<style>
|
||||
:root{--bg:#0a0a0b;--panel:#131317;--panel2:#17171c;--line:#232329;--txt:#ececf1;--dim:#8a8a95;
|
||||
--brand:#f4f4f6;--ok:#3ddc84;--warn:#ffb648;--pub:#6aa5ff;font-size:15px}
|
||||
*{box-sizing:border-box;margin:0}
|
||||
body{background:var(--bg);color:var(--txt);font:400 1rem/1.5 Inter,system-ui,-apple-system,sans-serif}
|
||||
.serif{font-family:"Iowan Old Style","Palatino Linotype",Georgia,serif}
|
||||
a{color:inherit}
|
||||
header{display:flex;align-items:center;gap:12px;padding:14px 26px;border-bottom:1px solid var(--line);
|
||||
position:fixed;top:0;left:0;right:0;background:rgba(10,10,11,.82);backdrop-filter:blur(14px);z-index:30;
|
||||
transition:transform .28s ease,opacity .28s ease}
|
||||
/* immersive: header hides; a top hover-zone or mouse-near-top reveals it */
|
||||
body.immersive header{transform:translateY(-100%);opacity:0;border-bottom-color:transparent}
|
||||
body.immersive header.reveal{transform:translateY(0);opacity:1}
|
||||
#tophover{position:fixed;top:0;left:0;right:0;height:24px;z-index:29}
|
||||
header .logo{width:26px;height:26px;border-radius:7px;display:block;flex:0 0 auto}
|
||||
header h1{font-size:1.02rem;font-weight:600;letter-spacing:.2px}
|
||||
header .beta{font-size:.62rem;color:var(--dim);border:1px solid var(--line);border-radius:5px;padding:1px 5px;text-transform:uppercase;letter-spacing:.08em}
|
||||
header .sp{flex:1}
|
||||
header .lnk{color:var(--dim);font-size:.85rem;text-decoration:none}
|
||||
header .user{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:99px;padding:3px 11px 3px 4px;cursor:pointer}
|
||||
header .user .av{width:24px;height:24px;border-radius:50%;background:#26262e;display:flex;align-items:center;justify-content:center;font-size:.72rem;font-weight:600}
|
||||
header .user .nm{font-size:.82rem;color:var(--dim)}
|
||||
.btn{border:1px solid var(--line);background:var(--panel);color:var(--txt);border-radius:8px;padding:7px 13px;font-size:.85rem;cursor:pointer}
|
||||
.btn:hover{border-color:var(--dim)} .btn.brand{background:var(--brand);border-color:var(--brand);color:#111;font-weight:600}
|
||||
main{max-width:1120px;margin:0 auto;padding:12px 24px 80px}
|
||||
body{padding-top:56px} /* clear the fixed header */
|
||||
body.immersive{padding-top:0}
|
||||
/* persistent live-queue bar — always visible so you can see what's generating */
|
||||
#livebar{position:sticky;top:56px;z-index:19;max-width:1120px;margin:0 auto 8px;display:flex;align-items:center;gap:14px;
|
||||
background:var(--panel);border:1px solid var(--line);border-radius:11px;padding:9px 15px;font-size:.84rem}
|
||||
#livebar .lb-run{color:var(--ok);display:flex;align-items:center;gap:7px}
|
||||
#livebar .lb-run .spin{width:9px;height:9px;border-radius:50%;background:var(--ok);animation:pulse 1.1s infinite}
|
||||
@keyframes pulse{0%,100%{opacity:.35}50%{opacity:1}}
|
||||
#livebar .lb-q{color:var(--dim)} #livebar .lb-eta{color:var(--warn)} #livebar .sp{flex:1}
|
||||
#livebar a.lb-more{color:var(--dim);text-decoration:none;font-size:.8rem;border:1px solid var(--line);border-radius:7px;padding:3px 10px;cursor:pointer}
|
||||
#livebar.idle{opacity:.72}
|
||||
.hero{text-align:center;font-size:3rem;font-weight:500;margin:56px 0 26px;letter-spacing:-.01em}
|
||||
/* prompt box */
|
||||
.prompt{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:16px 16px 12px;max-width:820px;margin:0 auto}
|
||||
.prompt textarea{width:100%;background:none;border:none;color:var(--txt);font:inherit;font-size:1.02rem;resize:none;outline:none;min-height:26px;max-height:180px}
|
||||
.prompt textarea::placeholder{color:var(--dim)}
|
||||
.pctl{display:flex;align-items:center;gap:8px;margin-top:12px}
|
||||
.pchip{display:flex;flex-direction:column;line-height:1.1;border:1px solid var(--line);border-radius:9px;padding:5px 11px;background:var(--panel2);cursor:pointer;font-size:.8rem}
|
||||
.pchip .k{color:var(--dim);font-size:.64rem;text-transform:uppercase;letter-spacing:.05em}
|
||||
.pchip .v{color:var(--txt);font-weight:500}
|
||||
.pico{border:1px solid var(--line);border-radius:9px;padding:8px 10px;background:var(--panel2);cursor:pointer;color:var(--dim);font-size:.9rem}
|
||||
.psend{margin-left:auto;width:38px;height:38px;border-radius:10px;border:none;background:var(--brand);color:#111;font-size:1.1rem;cursor:pointer}
|
||||
/* templates */
|
||||
.sec-l{color:var(--dim);font-size:.82rem;margin:40px 0 12px}
|
||||
.tgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px}
|
||||
.tcard{background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden;cursor:pointer;transition:.12s}
|
||||
.tcard:hover{border-color:#3a3a48;transform:translateY(-2px)}
|
||||
.tcard .ic{aspect-ratio:16/10;display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:1.7rem;background:var(--panel2)}
|
||||
.tcard .tl{padding:9px 11px;font-size:.83rem;font-weight:500}
|
||||
.tcard .td{padding:0 11px 10px;font-size:.7rem;color:var(--dim)}
|
||||
/* tabs */
|
||||
.tabs{display:flex;gap:6px;align-items:center;margin:44px 0 16px;border-bottom:1px solid var(--line);padding-bottom:0}
|
||||
.tabs button{background:none;border:none;color:var(--dim);font-size:.9rem;padding:8px 12px;cursor:pointer;border-radius:8px 8px 0 0;border-bottom:2px solid transparent}
|
||||
.tabs button.on{color:var(--txt);border-bottom-color:var(--brand)}
|
||||
.tabs .sp{flex:1}
|
||||
.search{background:var(--panel);border:1px solid var(--line);border-radius:9px;padding:6px 12px;color:var(--txt);font:inherit;font-size:.85rem;width:220px}
|
||||
.chips{display:flex;gap:8px;flex-wrap:wrap;margin:4px 0 18px}
|
||||
.chip{border:1px solid var(--line);border-radius:99px;padding:4px 12px;font-size:.8rem;color:var(--dim);cursor:pointer}
|
||||
.chip.on{color:#111;background:var(--brand);border-color:var(--brand);font-weight:600}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(215px,1fr));gap:15px}
|
||||
.card{background:var(--panel);border:1px solid var(--line);border-radius:13px;overflow:hidden;display:flex;flex-direction:column}
|
||||
.card .im{aspect-ratio:2/3;background:#0f0f12 center/cover no-repeat;cursor:zoom-in}
|
||||
.card .doc{aspect-ratio:2/3;display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:.8rem;text-align:center;padding:12px}
|
||||
.card .meta{padding:10px 12px;display:flex;flex-direction:column;gap:7px}
|
||||
.card .t{font-size:.82rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.card .sub{font-size:.7rem;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.row{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
|
||||
.st{font-size:.66rem;font-weight:600;border-radius:99px;padding:2px 8px;text-transform:uppercase;letter-spacing:.4px}
|
||||
.st.draft{background:#2a2a31;color:var(--dim)}.st.approved{background:rgba(61,220,132,.15);color:var(--ok)}
|
||||
.st.queued{background:rgba(255,182,72,.15);color:var(--warn)}.st.published{background:rgba(106,165,255,.18);color:var(--pub)}
|
||||
.mini{border:1px solid var(--line);background:none;color:var(--dim);border-radius:7px;padding:4px 9px;font-size:.72rem;cursor:pointer}
|
||||
.mini:hover{color:var(--txt);border-color:var(--dim)} .mini.hot{color:#111;background:var(--brand);border-color:var(--brand);font-weight:600}
|
||||
.empty{color:var(--dim);padding:44px;text-align:center}
|
||||
#toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:#1c1c22;border:1px solid var(--line);border-radius:10px;padding:10px 18px;font-size:.85rem;opacity:0;transition:.25s;pointer-events:none;z-index:50}
|
||||
#toast.show{opacity:1}
|
||||
#zoom{position:fixed;inset:0;background:rgba(0,0,0,.9);display:none;align-items:center;justify-content:center;z-index:40;cursor:zoom-out}
|
||||
#zoom img{max-width:94vw;max-height:94vh;border-radius:8px}
|
||||
dialog{background:var(--panel);color:var(--txt);border:1px solid var(--line);border-radius:14px;padding:22px;max-width:460px;width:92vw}
|
||||
dialog::backdrop{background:rgba(0,0,0,.6)} dialog textarea{width:100%;background:#0f0f12;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:10px;font:inherit;font-size:.9rem;min-height:90px;margin:12px 0}
|
||||
#bulkbar{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1a1a24;border:1px solid #34344a;border-radius:12px;padding:9px 14px;display:none;gap:9px;align-items:center;z-index:45;box-shadow:0 10px 32px #000a}
|
||||
#bulkbar.on{display:flex} #bulkn{font-size:.82rem;color:#cfcfe0;margin-right:4px}
|
||||
.card.sel-mode{cursor:pointer} .card.picked{outline:2px solid var(--brand);outline-offset:-2px}
|
||||
/* cleaner grid: tools hidden until hover */
|
||||
.card .tools{max-height:0;overflow:hidden;opacity:0;transition:.15s}
|
||||
.card:hover .tools{max-height:60px;opacity:1;margin-top:2px}
|
||||
.card .meta{gap:5px}
|
||||
.card .pick{position:absolute;top:8px;left:9px;width:20px;height:20px;border-radius:5px;border:1.5px solid #fff;background:rgba(0,0,0,.5);display:none;align-items:center;justify-content:center;font-size:.8rem;color:#111;z-index:3}
|
||||
.card{position:relative} .sel-mode .pick{display:flex} .picked .pick{background:var(--brand)}
|
||||
/* YouTube-style queue + history */
|
||||
.qcol{margin-bottom:26px} .qh{font-size:.9rem;font-weight:600;margin-bottom:10px;color:var(--txt);display:flex;align-items:center;gap:8px}
|
||||
.qlist{display:flex;flex-direction:column;gap:8px}
|
||||
.qrow{display:flex;align-items:center;gap:13px;background:var(--panel);border:1px solid var(--line);border-radius:11px;padding:9px 13px}
|
||||
.qrow:hover{border-color:#333340}
|
||||
.qrow .qthumb{width:60px;height:80px;border-radius:7px;background:#0f0f12 center/cover;flex:0 0 auto;font-size:1.1rem}
|
||||
.qrow .qtitle{flex:1;min-width:0;font-size:.88rem;font-weight:500}
|
||||
.qrow .qdesc{font-size:.76rem;color:#b3b3bd;margin-top:3px;line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.qrow .qsub{font-size:.72rem;color:var(--dim);margin-top:4px}
|
||||
.stars{display:inline-flex;gap:1px;cursor:pointer} .stars span{font-size:1rem;color:#3a3a44;transition:.1s} .stars span.on{color:#ffcf4a}
|
||||
.notebox{width:100%;background:#0f0f12;color:var(--txt);border:1px solid var(--line);border-radius:7px;padding:6px 9px;font:inherit;font-size:.76rem;margin-top:6px;min-height:34px;resize:vertical}
|
||||
.fav{cursor:pointer;font-size:1rem;color:#3a3a44} .fav.on{color:#ffcf4a}
|
||||
dialog .drop{border:1.5px dashed var(--line);border-radius:9px;padding:14px;text-align:center;color:var(--dim);font-size:.82rem;margin:8px 0;cursor:pointer}
|
||||
.drop.drag{border-color:var(--brand);color:var(--txt)}
|
||||
.prompt.drag{outline:2px dashed var(--brand);outline-offset:2px}
|
||||
.dlgerr{display:none;color:#ff8f8f;background:rgba(192,57,43,.12);border:1px solid rgba(192,57,43,.4);border-radius:8px;padding:8px 11px;font-size:.8rem;margin:8px 0 0;white-space:pre-wrap}
|
||||
.ctxlabel{font-size:.66rem;text-transform:uppercase;letter-spacing:.05em;color:var(--dim);margin:14px 0 6px}
|
||||
.wst{font-size:.66rem;font-weight:600;border-radius:99px;padding:1px 7px}
|
||||
.wst.queued{color:var(--warn)}.wst.running{color:var(--ok)}.wst.done{color:var(--pub)}.wst.failed{color:#ff8f8f}.wst.cancelled{color:var(--dim)}
|
||||
.wmeta{font-size:.72rem;color:var(--dim);margin-top:3px;display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
||||
.wnode{border:1px solid var(--line);border-radius:6px;padding:0 6px;color:var(--txt)}
|
||||
.qhead{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:0 0 14px;font-size:.8rem;color:var(--dim)}
|
||||
.nodebadge{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:99px;padding:3px 11px;background:var(--panel)}
|
||||
.nodebadge .dot{width:7px;height:7px;border-radius:50%;background:#555}
|
||||
.nodebadge.on .dot{background:var(--ok)} .nodebadge.off{opacity:.6}
|
||||
.qhead .mstat{margin-left:auto;color:var(--dim)}
|
||||
#ctxdlg{max-width:560px}
|
||||
.card .addref{position:absolute;top:8px;right:9px;width:26px;height:26px;border-radius:50%;border:none;background:rgba(0,0,0,.6);color:#fff;font-size:1rem;cursor:pointer;z-index:4;line-height:1}
|
||||
.card .addref:hover{background:var(--brand);color:#111} .card .addref.on{background:var(--brand);color:#111}
|
||||
.card .delx{position:absolute;top:8px;left:9px;width:26px;height:26px;border-radius:50%;border:none;background:rgba(0,0,0,.6);color:#fff;font-size:.85rem;cursor:pointer;z-index:4;line-height:1;opacity:.8}
|
||||
.card .delx:hover{background:#c0392b;color:#fff;opacity:1}
|
||||
#reftray{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1a1a24;border:1px solid #34344a;border-radius:12px;padding:10px 14px;display:none;gap:11px;align-items:center;z-index:44;box-shadow:0 10px 32px #000a}
|
||||
#reftray.on{display:flex} #reftray .rt{width:38px;height:56px;object-fit:cover;border-radius:5px;border:1px solid var(--line)}
|
||||
/* ── Immersive full-viewport stage (editorial hero) ────────────────────────── */
|
||||
#stage{position:relative;width:100vw;height:100vh;overflow:hidden;background:#000;display:none;user-select:none}
|
||||
body.immersive #stage{display:block}
|
||||
#stage .slide{position:absolute;inset:0;background:#000 center/cover no-repeat;opacity:0;transition:opacity .6s ease}
|
||||
#stage .slide.on{opacity:1}
|
||||
#stage .cap{position:absolute;left:48px;bottom:56px;z-index:6;max-width:60vw;text-shadow:0 2px 20px rgba(0,0,0,.6)}
|
||||
#stage .cap .k{font-size:.72rem;letter-spacing:.18em;text-transform:uppercase;color:rgba(255,255,255,.75)}
|
||||
#stage .cap .t{font-size:2.2rem;font-weight:500;color:#fff;margin-top:6px;font-family:"Iowan Old Style",Georgia,serif}
|
||||
#stage .cap .tools{display:flex;gap:8px;margin-top:14px;opacity:0;transition:.2s} #stage:hover .cap .tools{opacity:1}
|
||||
#stage .nav{position:absolute;top:0;bottom:0;width:16vw;z-index:5;cursor:pointer;display:flex;align-items:center;color:rgba(255,255,255,.0);font-size:2rem;transition:.2s}
|
||||
#stage .nav:hover{color:rgba(255,255,255,.9)} #stage .nav.prev{left:0;justify-content:flex-start;padding-left:28px} #stage .nav.next{right:0;justify-content:flex-end;padding-right:28px}
|
||||
#stage .dots{position:absolute;bottom:26px;left:50%;transform:translateX(-50%);display:flex;gap:7px;z-index:6}
|
||||
#stage .dots span{width:6px;height:6px;border-radius:50%;background:rgba(255,255,255,.35);cursor:pointer} #stage .dots span.on{background:#fff;width:20px;border-radius:4px}
|
||||
#stage .exit{position:absolute;top:18px;right:22px;z-index:7;color:#fff;background:rgba(0,0,0,.4);border:1px solid rgba(255,255,255,.25);border-radius:8px;padding:6px 12px;font-size:.8rem;cursor:pointer;opacity:0;transition:.2s} #stage:hover .exit{opacity:1}
|
||||
.viewbtn{border:1px solid var(--line);background:var(--panel);color:var(--txt);border-radius:8px;padding:7px 13px;font-size:.85rem;cursor:pointer}
|
||||
/* ── Hanzo AI chat widget ──────────────────────────────────────────────────── */
|
||||
#chatfab{position:fixed;bottom:22px;right:22px;width:52px;height:52px;border-radius:50%;background:var(--brand);color:#111;border:none;font-size:1.4rem;cursor:pointer;z-index:48;box-shadow:0 8px 24px #0008;display:flex;align-items:center;justify-content:center}
|
||||
#chatpanel{position:fixed;bottom:22px;right:22px;width:380px;max-width:92vw;height:560px;max-height:82vh;background:#0e0e12;border:1px solid var(--line);border-radius:16px;z-index:49;display:none;flex-direction:column;overflow:hidden;box-shadow:0 20px 60px #000b}
|
||||
#chatpanel.on{display:flex} #chatpanel .ch{display:flex;align-items:center;gap:9px;padding:14px 16px;border-bottom:1px solid var(--line)}
|
||||
#chatpanel .ch .av{width:26px;height:26px;border-radius:7px;background:#111;display:flex;align-items:center;justify-content:center}
|
||||
#chatpanel .ch b{font-size:.9rem} #chatpanel .ch .x{margin-left:auto;cursor:pointer;color:var(--dim);font-size:1.2rem}
|
||||
#chatmsgs{flex:1;overflow:auto;padding:14px;display:flex;flex-direction:column;gap:10px}
|
||||
.cmsg{max-width:86%;padding:9px 12px;border-radius:12px;font-size:.86rem;line-height:1.45;white-space:pre-wrap}
|
||||
.cmsg.u{align-self:flex-end;background:var(--brand);color:#111;border-bottom-right-radius:3px}
|
||||
.cmsg.a{align-self:flex-start;background:#1a1a20;border:1px solid var(--line);border-bottom-left-radius:3px}
|
||||
#chatbar{display:flex;gap:8px;padding:12px;border-top:1px solid var(--line)}
|
||||
#chatbar input{flex:1;background:#0a0a0d;border:1px solid var(--line);border-radius:9px;color:var(--txt);padding:9px 11px;font:inherit;font-size:.85rem}
|
||||
/* ── Responsive: tablet ≤900px, phone ≤600px ─────────────────────────────── */
|
||||
@media (max-width:900px){
|
||||
main{padding:12px 16px 90px}
|
||||
.hero{font-size:2.1rem;margin:34px 0 20px}
|
||||
.grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:11px}
|
||||
.tgrid{grid-template-columns:repeat(auto-fill,minmax(120px,1fr))}
|
||||
#livebar{margin-left:16px;margin-right:16px}
|
||||
#chatpanel{width:calc(100vw - 24px);height:70vh}
|
||||
}
|
||||
@media (max-width:600px){
|
||||
header{padding:11px 14px;gap:9px}
|
||||
header h1{font-size:.95rem} header .beta{display:none} header .lnk{display:none}
|
||||
header .user .nm{display:none} /* avatar only on phone */
|
||||
.hero{font-size:1.6rem;margin:22px 0 16px}
|
||||
.prompt{padding:12px 12px 10px;border-radius:13px}
|
||||
.prompt textarea{font-size:.95rem}
|
||||
.pctl{flex-wrap:wrap;gap:6px} .psend{margin-left:0}
|
||||
.grid{grid-template-columns:repeat(auto-fill,minmax(46vw,1fr));gap:9px}
|
||||
.tgrid{grid-template-columns:repeat(auto-fill,minmax(44vw,1fr))}
|
||||
.sec-l{margin:26px 0 10px}
|
||||
.tabs{overflow-x:auto;white-space:nowrap;-webkit-overflow-scrolling:touch}
|
||||
.tabs button{padding:8px 10px;font-size:.84rem}
|
||||
.search{width:120px} .tabs .sp{display:none}
|
||||
#livebar{flex-wrap:wrap;font-size:.78rem}
|
||||
#bulkbar,#reftray{left:8px;right:8px;transform:none;flex-wrap:wrap;justify-content:center}
|
||||
#chatfab{bottom:16px;right:16px}
|
||||
#chatpanel{bottom:0;right:0;left:0;width:100vw;height:82vh;max-height:82vh;border-radius:16px 16px 0 0}
|
||||
.card .delx,.card .addref{width:30px;height:30px} /* bigger tap targets */
|
||||
}
|
||||
/* touch: no hover-reveal — always show card tools on touch devices */
|
||||
@media (hover:none){
|
||||
.card .tools{max-height:none;opacity:1;margin-top:2px}
|
||||
}
|
||||
</style>
|
||||
<header>
|
||||
<svg class="logo" viewBox="0 0 520 520" xmlns="http://www.w3.org/2000/svg"><rect width="520" height="520" rx="120" fill="#111"/><g transform="translate(100,100) scale(4.78)" fill="#fff"><path d="M22.21 67V44.6369H0V67H22.21Z"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z"/><path d="M22.21 0H0V22.3184H22.21V0Z"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z"/></g></svg>
|
||||
<h1>Hanzo Studio</h1><span class="beta">Beta</span><span class="sp"></span>
|
||||
<a class="lnk" href="/?advanced=1">Advanced mode</a>
|
||||
<button class="btn" onclick="load()">Refresh</button>
|
||||
<div class="user" id="user" title="Account"><span class="av" id="av">·</span><span class="nm" id="org">…</span></div>
|
||||
</header>
|
||||
<div id="tophover"></div>
|
||||
<div id="livebar" class="idle">
|
||||
<span id="lbstatus" class="lb-q">Checking queue…</span>
|
||||
<span class="sp"></span>
|
||||
<a class="lb-more" onclick="tab('runs');document.getElementById('runs').scrollIntoView({behavior:'smooth'})">Queue & History →</a>
|
||||
</div>
|
||||
<main>
|
||||
<h2 class="hero serif">What should we create?</h2>
|
||||
<div class="prompt">
|
||||
<textarea id="pin" rows="1" placeholder="Describe what you want to create…"></textarea>
|
||||
<div class="pctl">
|
||||
<span class="pico" title="Attach a photo" onclick="attachPick()">+</span>
|
||||
<input id="attachfile" type="file" accept="image/*" multiple hidden>
|
||||
<span class="pchip" onclick="pickPreset()"><span class="k">Design system</span><span class="v" id="ds">None</span></span>
|
||||
<span class="pchip" onclick="tab('templates')"><span class="k">Template</span><span class="v" id="tmpl">None</span></span>
|
||||
<span class="pico" title="Code"></></span>
|
||||
<span class="pchip" style="margin-left:auto"><span class="k">Model</span><span class="v">Opus 4.8</span></span>
|
||||
<button class="psend" onclick="create()" title="Create">↑</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sec-l">Use a template</div>
|
||||
<div class="tgrid" id="tcards"></div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="on" data-tab="assets" onclick="tab('assets')">Assets</button>
|
||||
<button data-tab="templates" onclick="tab('templates')">Templates</button>
|
||||
<button data-tab="pipelines" onclick="tab('pipelines')">Workflows</button>
|
||||
<button data-tab="runs" onclick="tab('runs')">Queue & History</button>
|
||||
<span class="sp"></span>
|
||||
<button class="btn" id="selbtn" onclick="toggleSel()" style="padding:6px 12px">Select</button>
|
||||
<input class="search" id="q" placeholder="Search…" oninput="render()">
|
||||
</div>
|
||||
<section id="assets"><div class="chips" id="filters"></div><div class="grid" id="agrid"></div></section>
|
||||
<div id="bulkbar"><span id="bulkn">0 selected</span>
|
||||
<button class="mini hot" onclick="openCompose()" title="Combine selected assets into a new one">✦ Compose new</button>
|
||||
<button class="mini" onclick="bulk('approved')">Approve</button>
|
||||
<button class="mini" onclick="bulk('published')">Publish</button>
|
||||
<button class="mini" onclick="bulkFork()">Fork / redo</button>
|
||||
<button class="mini" onclick="bulk('deleted')">Delete</button>
|
||||
<button class="mini" onclick="clearSel()">Cancel</button>
|
||||
</div>
|
||||
<section id="templates" hidden><div class="grid" id="tgall"></div></section>
|
||||
<section id="pipelines" hidden><div class="grid" id="wfgrid"></div></section>
|
||||
<section id="runs" hidden>
|
||||
<div id="qhead" class="qhead"></div>
|
||||
<div class="qcol"><h3 class="qh">🗂 Your work
|
||||
<input class="search" id="wlq" placeholder="Search prompts…" oninput="worklog()" style="margin-left:10px"></h3>
|
||||
<div class="qlist" id="wllist"></div></div>
|
||||
<div class="qcol"><h3 class="qh">▶ Now running <span id="qrunN" class="cnt">0</span></h3><div class="qlist" id="qrun"></div></div>
|
||||
<div class="qcol"><h3 class="qh">⏳ Up next <span id="qpendN" class="cnt">0</span> <button class="mini" onclick="clearQueue()" style="margin-left:8px">Clear all</button></h3><div class="qlist" id="qpend"></div></div>
|
||||
<div class="qcol"><h3 class="qh">✓ History — rate & note for better future generations</h3><div class="grid" id="rgrid"></div></div>
|
||||
</section>
|
||||
</main>
|
||||
<div id="reftray">
|
||||
<div style="font-size:.72rem;color:var(--dim);text-transform:uppercase;letter-spacing:.05em">Next generation</div>
|
||||
<div id="refthumbs" style="display:flex;gap:6px;align-items:center"></div>
|
||||
<input id="refprompt" placeholder="Describe what to make from these…" style="background:#0f0f12;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:7px 11px;font:inherit;font-size:.82rem;width:280px">
|
||||
<button class="mini hot" onclick="genFromTray()">✦ Generate</button>
|
||||
<button class="mini" onclick="clearTray()">Clear</button>
|
||||
</div>
|
||||
<div id="toast"></div>
|
||||
<div id="zoom" onclick="this.style.display='none'"><img id="zoomimg"></div>
|
||||
<dialog id="fixdlg">
|
||||
<h3 id="fixtitle" style="font-size:1rem">Fix with chat</h3>
|
||||
<p id="fixdesc" style="color:var(--dim);font-size:.83rem;margin-top:6px">Describe the change — a new render is queued from this image. Add reference photos (drag in, or pick from your history) to guide it.</p>
|
||||
<div class="row" id="fixrefs" style="margin:12px 0 0;flex-wrap:wrap"></div>
|
||||
<div class="drop" id="fixdrop">Drag images here, or <b>click to upload</b> references</div>
|
||||
<input id="fixfile" type="file" accept="image/*" multiple hidden>
|
||||
<textarea id="fixtext" placeholder="e.g. make the straps thicker, smooth the skin"></textarea>
|
||||
<div id="gpuwarn" style="display:none;color:var(--warn);font-size:.78rem;margin:-4px 0 8px"></div>
|
||||
<div id="fixerr" class="dlgerr"></div>
|
||||
<div class="row" style="justify-content:space-between">
|
||||
<button class="mini" id="fixhistbtn" onclick="toggleFixHistory()">+ From history</button>
|
||||
<div class="row"><button class="mini" onclick="fixdlg.close()">Cancel</button><button class="mini hot" onclick="sendFix()">Queue fix</button></div>
|
||||
</div>
|
||||
<div id="fixhist" style="display:none;max-height:210px;overflow:auto;margin-top:10px;border-top:1px solid var(--line);padding-top:10px"></div>
|
||||
</dialog>
|
||||
<dialog id="tmpldlg">
|
||||
<h3 style="font-size:1rem">★ Save as template</h3>
|
||||
<p style="color:var(--dim);font-size:.83rem;margin-top:6px">Turn this favorited generation into a reusable template — its workflow becomes a one-click KIND you can run again with new inputs.</p>
|
||||
<label style="display:block;font-size:.72rem;color:var(--dim);margin:12px 0 4px;text-transform:uppercase;letter-spacing:.04em">Template name</label>
|
||||
<input id="tmplname" style="width:100%;background:#0f0f12;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:9px 11px;font:inherit" placeholder="e.g. Karma ghost mannequin — white studio">
|
||||
<label style="display:block;font-size:.72rem;color:var(--dim);margin:12px 0 4px;text-transform:uppercase;letter-spacing:.04em">What it makes</label>
|
||||
<input id="tmpldesc" style="width:100%;background:#0f0f12;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:9px 11px;font:inherit" placeholder="e.g. Product on invisible form, real fabric">
|
||||
<div id="tmplsteps" style="color:var(--dim);font-size:.78rem;margin-top:12px;line-height:1.6"></div>
|
||||
<div class="row" style="justify-content:flex-end;margin-top:8px"><button class="mini" onclick="tmpldlg.close()">Cancel</button><button class="mini hot" onclick="saveTemplate()">Save template</button></div>
|
||||
</dialog>
|
||||
<dialog id="ctxdlg">
|
||||
<div class="row"><h3 id="ctxtitle" style="font-size:1rem;flex:1">Work item</h3><button class="mini" onclick="ctxdlg.close()">Close</button></div>
|
||||
<div id="ctxbody" style="margin-top:6px"></div>
|
||||
<div class="row" style="justify-content:flex-end;margin-top:14px;gap:6px">
|
||||
<button class="mini" id="ctxcancel" onclick="cancelCtx()" style="display:none">✕ Cancel job</button>
|
||||
<button class="mini" id="ctxedit" onclick="editCtx()" style="display:none">✎ Edit</button>
|
||||
<button class="mini" id="ctxreuse" onclick="reuseCtx()" title="Open Fix primed with this prompt + references">↻ Reuse context</button>
|
||||
<button class="mini hot" id="ctxfix" onclick="fixCtx()">Fix this version</button>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="compdlg">
|
||||
<h3 style="font-size:1rem">✦ Compose new from <span id="compn">0</span> assets</h3>
|
||||
<p style="color:var(--dim);font-size:.83rem;margin-top:6px">Explain HOW to use the selected assets — e.g. "put the garment from the first onto the model in the second, keep the second's lighting."</p>
|
||||
<div class="row" id="compthumbs" style="margin:10px 0 0;flex-wrap:wrap"></div>
|
||||
<textarea id="comptext" placeholder="e.g. combine these — garment from #1 on the model + pose from #2, studio white background"></textarea>
|
||||
<div id="gpuwarn2" style="display:none;color:var(--warn);font-size:.78rem;margin:-4px 0 8px"></div>
|
||||
<div class="row" style="justify-content:flex-end"><button class="mini" onclick="compdlg.close()">Cancel</button><button class="mini hot" onclick="sendCompose()">Generate</button></div>
|
||||
</dialog>
|
||||
<script>
|
||||
// ── Template / KIND catalog — everything Studio supports ─────────────────────
|
||||
const TEMPLATES=[
|
||||
// ── Fashion / commerce (the proven karma workflows, systematized) ──
|
||||
{k:'cad_ghost', t:'CAD → Ghost mannequin', d:'Real fabric, invisible form', ic:'👕', wf:'cad_ghost', grp:'Fashion'},
|
||||
{k:'outfit', t:'Outfit switcher', d:'Swap clothes, keep model+light', ic:'🔁', wf:'outfit', grp:'Fashion'},
|
||||
{k:'product', t:'Product photography', d:'Ecom front·side·back', ic:'🛍', wf:'product', grp:'Fashion'},
|
||||
{k:'lifestyle', t:'Lifestyle photography', d:'Same model/3D, in scene', ic:'🌅', wf:'lifestyle', grp:'Fashion'},
|
||||
{k:'cad_mockup',t:'CAD → mockup', d:'Fashion / packaging', ic:'✳️', wf:'cad_mockup', grp:'Fashion'},
|
||||
{k:'cad_3d', t:'CAD → 3D + drape', d:'Multi-view → 3D, fabric drape',ic:'🧊', wf:'cad_3d', grp:'Fashion'},
|
||||
// ── Media ──
|
||||
{k:'video', t:'Video', d:'Wan · LTX · Hunyuan', ic:'🎬', wf:'video', grp:'Media'},
|
||||
{k:'music', t:'Music', d:'ACE-Step · song', ic:'🎵', wf:'music', grp:'Media'},
|
||||
{k:'voice', t:'Voice', d:'Narration · TTS', ic:'🎙', wf:'voice', grp:'Media'},
|
||||
// ── Marketing ──
|
||||
{k:'social', t:'Social / Ad', d:'Posts · ads · campaigns', ic:'📣', wf:'social', grp:'Marketing'},
|
||||
{k:'email', t:'Marketing email', d:'HTML / MJML', ic:'✉️', wf:'email', grp:'Marketing'},
|
||||
{k:'deck', t:'Deck', d:'Sales · investor', ic:'📊', wf:'deck', grp:'Marketing'},
|
||||
// ── Documents ──
|
||||
{k:'doc', t:'Document', d:'Word · guide', ic:'📄', wf:'doc', grp:'Documents'},
|
||||
{k:'sheet', t:'Spreadsheet', d:'xlsx · data', ic:'🧮', wf:'sheet', grp:'Documents'},
|
||||
{k:'paper', t:'Paper (LaTeX)', d:'Academic · PDF', ic:'📐', wf:'paper', grp:'Documents'},
|
||||
// ── Web ──
|
||||
{k:'site', t:'Website / App', d:'From hanzo.app templates', ic:'🖥', wf:'site', grp:'Web'},
|
||||
];
|
||||
let DATA={assets:[],workflows:[]},FILTER='all';
|
||||
const $=id=>document.getElementById(id);
|
||||
const toast=m=>{const t=$('toast');t.textContent=m;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),3000)};
|
||||
const imgURL=a=>`/v1/library/file?path=${encodeURIComponent(a.path)}`;
|
||||
const thumbURL=a=>`/v1/library/file?thumb=1&path=${encodeURIComponent(a.path)}`;
|
||||
function tcard(x){return `<div class="tcard" onclick="pickTemplate('${x.k}','${x.t.replace(/'/g,"")}')"><div class="ic">${x.ic}</div><div class="tl">${x.t}</div><div class="td">${x.d}</div></div>`;}
|
||||
function tab(name){document.querySelectorAll('.tabs button').forEach(b=>b.classList.toggle('on',b.dataset.tab===name));
|
||||
for(const s of ['assets','templates','pipelines','runs'])$(s).hidden=s!==name;
|
||||
if(name==='runs')runs(); if(name==='pipelines')pipes();}
|
||||
function pickTemplate(k,t){$('tmpl').textContent=t; tab('assets'); $('pin').focus(); toast('Template: '+t);}
|
||||
function pickPreset(){toast('Design systems / presets — coming in the preset library');}
|
||||
function create(){const p=$('pin').value.trim(); const t=$('tmpl').textContent;
|
||||
if(!p&&t==='None'){toast('Describe what to create, or pick a template');return;}
|
||||
toast('Creation flow wires copilot → exec/tasks (HIP-0506 phase 1)');
|
||||
// real path once phase 1 lands: POST /v1/studio/create {prompt,template,design_system}
|
||||
}
|
||||
async function load(){
|
||||
const r=await fetch('/v1/library'); DATA=await r.json(); $('org').textContent=DATA.org||'';
|
||||
$('tcards').innerHTML=TEMPLATES.slice(0,7).map(tcard).join('');
|
||||
// Templates tab: grouped by category
|
||||
const grps=[...new Set(TEMPLATES.map(t=>t.grp))];
|
||||
$('tgall').innerHTML=grps.map(g=>`<div style="grid-column:1/-1;color:var(--dim);font-size:.8rem;margin:8px 0 2px">${g}</div>`+
|
||||
TEMPLATES.filter(t=>t.grp===g).map(tcard).join('')).join('');
|
||||
const groups=[...new Set(DATA.assets.map(a=>a.design).filter(Boolean))].sort();
|
||||
const delN=DATA.assets.filter(a=>a.status==='deleted').length;
|
||||
const chips=['all','marketing',...groups];
|
||||
$('filters').innerHTML=chips.map(g=>`<span class="chip ${g===FILTER?'on':''}" onclick="FILTER='${g}';render()">${g}</span>`).join('')
|
||||
+`<span class="chip ${FILTER==='deleted'?'on':''}" onclick="FILTER='deleted';render()" style="margin-left:auto">🗑 Deleted${delN?' ('+delN+')':''}</span>`;
|
||||
render();
|
||||
}
|
||||
let SELMODE=false; const SEL=new Set();
|
||||
function toggleSel(){SELMODE=!SELMODE;$('selbtn').classList.toggle('brand',SELMODE);if(!SELMODE)SEL.clear();updateBulk();render();}
|
||||
function clearSel(){SEL.clear();SELMODE=false;$('selbtn').classList.remove('brand');updateBulk();render();}
|
||||
function updateBulk(){$('bulkbar').classList.toggle('on',SELMODE&&SEL.size>0);$('bulkn').textContent=SEL.size+' selected';}
|
||||
function pick(ev,path){ev.stopPropagation();if(SEL.has(path))SEL.delete(path);else SEL.add(path);updateBulk();render();}
|
||||
function render(){
|
||||
const q=($('q').value||'').toLowerCase();
|
||||
const showDel=FILTER==='deleted';
|
||||
let rows=DATA.assets.filter(a=>{
|
||||
if(showDel)return a.status==='deleted';
|
||||
if(a.status==='deleted')return false; // hide trashed from normal views
|
||||
return FILTER==='all'?true:FILTER==='marketing'?(a.path||'').startsWith('marketing/'):a.design===FILTER;
|
||||
});
|
||||
if(q)rows=rows.filter(a=>(a.path||'').toLowerCase().includes(q));
|
||||
$('agrid').innerHTML=rows.map(a=>{
|
||||
const i=DATA.assets.indexOf(a);
|
||||
const img=/\.(png|jpe?g|webp)$/i.test(a.path||'');
|
||||
const prov=a.prov&&a.prov.workflow?` · ${a.prov.workflow}`:'';
|
||||
const picked=SEL.has(a.path);
|
||||
const cardClick=SELMODE?`onclick="pick(event,'${a.path.replace(/'/g,"\\'")}')"`:'';
|
||||
const inTray=trayHasLib(a.path);
|
||||
const pe=a.path.replace(/'/g,"\\'");
|
||||
return `<div class="card ${SELMODE?'sel-mode':''} ${picked?'picked':''}" ${cardClick}>
|
||||
<div class="pick">${picked?'✓':''}</div>
|
||||
${img&&!SELMODE?`<button class="addref ${inTray?'on':''}" title="Add to next generation" onclick="event.stopPropagation();addRef('${pe}')">${inTray?'✓':'+'}</button>`:''}
|
||||
${img&&!SELMODE&&!showDel?`<button class="delx" title="Move to Deleted (recoverable)" onclick="event.stopPropagation();quickDel('${pe}')">🗑</button>`:''}
|
||||
${img?`<div class="im" style="background-image:url('${thumbURL(a)}')" ${SELMODE?'':`onclick="zoom('${imgURL(a)}')"`}></div>`
|
||||
:`<div class="doc">${(a.path||'').split('/').pop()}</div>`}
|
||||
<div class="meta">
|
||||
<span class="t">${(a.path||'').split('/').pop()}</span>
|
||||
<span class="sub">${a.design||a.kind||''}${prov}</span>
|
||||
<div class="row"><span class="st ${a.status}">${a.status}</span></div>
|
||||
${SELMODE?'':`<div class="row tools">
|
||||
${showDel?`<button class="mini" onclick="setSt(${i},'draft')">Recover</button>`:`
|
||||
${a.status!=='approved'?`<button class="mini" onclick="setSt(${i},'approved')">Approve</button>`:''}
|
||||
${a.status!=='published'?`<button class="mini" onclick="setSt(${i},'published')">Publish</button>`:''}
|
||||
${img?`<button class="mini" onclick="rerun(${i})">Re-run</button><button class="mini" onclick="openLineage('${pe}')" title="Show this image's versions (what it came from + later fixes)">Versions</button><button class="mini hot" onclick="openFix(${i})">Fix</button>`:''}
|
||||
<button class="mini" onclick="setSt(${i},'deleted')" title="Move to Deleted (recoverable)">🗑</button>`}
|
||||
</div>`}
|
||||
</div></div>`;}).join('')||`<div class="empty">${showDel?'Deleted is empty.':'No assets in this view yet.'}</div>`;
|
||||
}
|
||||
async function bulk(status){
|
||||
if(!SEL.size)return;
|
||||
if(status==='deleted'&&!confirm('Move '+SEL.size+' asset(s) to Deleted? (recoverable)'))return;
|
||||
const paths=[...SEL];
|
||||
for(const p of paths){await fetch('/v1/library/status',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:p,status})});
|
||||
const a=DATA.assets.find(x=>x.path===p);if(a)a.status=status;}
|
||||
toast(paths.length+' → '+status); clearSel();
|
||||
}
|
||||
async function bulkFork(){ if(!SEL.size)return; toast('Fork/redo '+SEL.size+' — re-running each with a fresh seed');
|
||||
for(const p of [...SEL]){await fetch('/v1/library/rerun',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:p})});}
|
||||
clearSel(); }
|
||||
// ── Shared image-upload lane (ONE implementation) ────────────────────────────
|
||||
// Every "add a photo" surface (composer "+", Fix dialog, drag/drop, paste) funnels
|
||||
// through here → POST /upload/image, which lands the file in the caller's org input
|
||||
// dir (multi-tenant) and returns its name. Errors are RETURNED, never swallowed, so
|
||||
// every caller can SHOW them (no-silent-swallow rule).
|
||||
function errMsg(j){ if(!j)return ''; const e=j.error; return (e&&(e.message||e))||j.message||''; }
|
||||
async function uploadImage(file){
|
||||
if(!file||!file.type||!file.type.startsWith('image/'))return {error:'not an image file'};
|
||||
const fd=new FormData(); fd.append('image',file,file.name||'upload.png');
|
||||
try{
|
||||
const r=await fetch('/upload/image',{method:'POST',body:fd});
|
||||
let j={}; try{j=await r.json();}catch(_){}
|
||||
if(!r.ok)return {error:errMsg(j)||('upload failed ('+r.status+')')};
|
||||
if(!j.name)return {error:'upload failed (no name returned)'};
|
||||
return {name:j.name};
|
||||
}catch(e){ return {error:'upload failed ('+((e&&e.message)||e)+')'}; }
|
||||
}
|
||||
async function uploadMany(files,onName,onErr){ for(const f of [...files]){ const r=await uploadImage(f); if(r.name)onName(r.name); else onErr(r.error); } }
|
||||
function wireDrop(el,onFiles){
|
||||
['dragenter','dragover'].forEach(ev=>el.addEventListener(ev,e=>{e.preventDefault();el.classList.add('drag');}));
|
||||
['dragleave','dragend'].forEach(ev=>el.addEventListener(ev,e=>{e.preventDefault();el.classList.remove('drag');}));
|
||||
el.addEventListener('drop',e=>{e.preventDefault();el.classList.remove('drag');if(e.dataTransfer&&e.dataTransfer.files&&e.dataTransfer.files.length)onFiles(e.dataTransfer.files);});
|
||||
}
|
||||
function wirePaste(el,onFiles){ el.addEventListener('paste',e=>{const items=(e.clipboardData&&e.clipboardData.items)||[]; const fs=[...items].filter(i=>i.kind==='file').map(i=>i.getAsFile()).filter(Boolean); if(fs.length){e.preventDefault();onFiles(fs);}}); }
|
||||
const inputThumb=name=>`/v1/library/file?input=1&path=${encodeURIComponent(name)}`;
|
||||
// ── Reference tray: "+" on any photo / composer attach → next generation ──────
|
||||
// Holds mixed refs: {kind:'lib', ref:<library path>} and {kind:'up', ref:<input name>}.
|
||||
let TRAY=[];
|
||||
const trayHasLib=path=>TRAY.some(r=>r.kind==='lib'&&r.ref===path);
|
||||
function addRef(path){ const i=TRAY.findIndex(r=>r.kind==='lib'&&r.ref===path); if(i>=0)TRAY.splice(i,1); else{ if(TRAY.length>=5){toast('Up to 5 references');return;} TRAY.push({kind:'lib',ref:path,thumb:thumbURL({path})}); } drawTray(); render(); }
|
||||
function addTrayUpload(name){ if(TRAY.length>=5){toast('Up to 5 references');return;} TRAY.push({kind:'up',ref:name,thumb:inputThumb(name)}); drawTray(); }
|
||||
function removeTrayRef(i){ TRAY.splice(i,1); drawTray(); render(); }
|
||||
function clearTray(){ TRAY=[]; drawTray(); render(); }
|
||||
function drawTray(){
|
||||
$('reftray').classList.toggle('on',TRAY.length>0);
|
||||
$('refthumbs').innerHTML=TRAY.map((r,i)=>`<span style="position:relative;display:inline-block">
|
||||
<img class="rt" src="${r.thumb}" title="${r.ref}">
|
||||
<button onclick="removeTrayRef(${i})" title="Remove" style="position:absolute;top:-6px;right:-6px;width:16px;height:16px;border-radius:50%;border:none;background:#c0392b;color:#fff;font-size:.62rem;line-height:1;cursor:pointer">×</button></span>`).join('');
|
||||
}
|
||||
function attachPick(){ $('attachfile').click(); }
|
||||
async function attachFiles(files){ await uploadMany(files, addTrayUpload, err=>toast('Upload failed: '+err)); }
|
||||
async function genFromTray(){
|
||||
const t=$('refprompt').value.trim();
|
||||
if(!TRAY.length){toast('Add photos with + first');return;}
|
||||
if(t.length<3){toast('Describe what to make');return;}
|
||||
if(!(await gpuOnline()))toast('⚠ No GPU online — queues until a BYO-GPU connects');
|
||||
const libs=TRAY.filter(r=>r.kind==='lib').map(r=>r.ref), ups=TRAY.filter(r=>r.kind==='up').map(r=>r.ref);
|
||||
let r;
|
||||
if(libs.length+ups.length===1){ // single ref → edit (library or uploaded base)
|
||||
r=await fetch('/v1/library/fix',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(libs.length?{path:libs[0],instruction:t}:{upload:ups[0],instruction:t})});
|
||||
}else{ // 2-5 refs → compose
|
||||
r=await fetch('/v1/library/compose',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths:libs,uploads:ups,instruction:t})});
|
||||
}
|
||||
const j=await r.json().catch(()=>({}));
|
||||
if(r.ok){ toast('Queued → see Queue & History'); $('refprompt').value=''; clearTray(); }
|
||||
else toast('Failed: '+(errMsg(j)||('error '+r.status)));
|
||||
}
|
||||
// ── GPU status: fix/compose need an ONLINE GPU worker (Qwen models live there) ──
|
||||
let GPU_ONLINE=null, GPU_TS=0;
|
||||
async function gpuOnline(){
|
||||
// Check the FLEET (same source dispatch uses), not /v1/engines — a `hanzo gpu
|
||||
// connect` box registers with the fleet, so /v1/engines missed it and the UI
|
||||
// wrongly said "connect a GPU". Cache 15s so we don't hammer it.
|
||||
if(GPU_ONLINE!==null && Date.now()-GPU_TS<15000)return GPU_ONLINE;
|
||||
try{const s=await (await fetch('/v1/gpu-status')).json(); GPU_ONLINE=!!s.online;}
|
||||
catch(_){GPU_ONLINE=false;}
|
||||
GPU_TS=Date.now(); return GPU_ONLINE;
|
||||
}
|
||||
async function openCompose(){
|
||||
if(SEL.size<2){toast('Select 2+ assets to compose');return;}
|
||||
const paths=[...SEL]; $('compn').textContent=paths.length;
|
||||
$('compthumbs').innerHTML=paths.map((p,i)=>`<div style="text-align:center"><img src="/v1/library/file?thumb=1&path=${encodeURIComponent(p)}" style="width:64px;height:96px;object-fit:cover;border-radius:6px;border:1px solid var(--line)"><div style="font-size:.62rem;color:var(--dim)">#${i+1}</div></div>`).join('');
|
||||
$('comptext').value='';
|
||||
$('gpuwarn2').style.display=(await gpuOnline())?'none':'block';
|
||||
$('gpuwarn2').textContent='⚠ No GPU worker online — this queues but renders only when a BYO-GPU (e.g. spark) is connected.';
|
||||
$('compdlg').showModal();
|
||||
}
|
||||
async function sendCompose(){
|
||||
const t=$('comptext').value.trim(); if(t.length<3){toast('Describe how to combine them');return;}
|
||||
const paths=[...SEL]; toast('Composing from '+paths.length+' assets…');
|
||||
const r=await fetch('/v1/library/compose',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths,instruction:t})});
|
||||
const j=await r.json().catch(()=>({}));
|
||||
if(r.ok){ $('compdlg').close(); toast(`Compose queued (${j.refs} refs) → see Queue & History`); clearSel(); }
|
||||
else{ $('gpuwarn2').style.display='block'; $('gpuwarn2').textContent='Couldn’t queue: '+(errMsg(j)||('error '+r.status)); }
|
||||
}
|
||||
function pipes(){ $('wfgrid').innerHTML=(DATA.workflows||[]).map(w=>`<div class="card"><div class="doc">⚙︎ ${w}</div><div class="meta"><div class="row"><a class="mini" href="/?advanced=1&workflow=${encodeURIComponent(w)}">Edit</a><a class="mini hot" href="/?advanced=1&workflow=${encodeURIComponent(w)}&run=1">Run</a></div></div></div>`).join('')||'<div class="empty">No saved workflows for this org yet.</div>'; }
|
||||
// ── YouTube-style Queue & History ───────────────────────────────────────────
|
||||
let RATINGS={};
|
||||
const vURL=im=>`/view?filename=${encodeURIComponent(im.filename)}&subfolder=${encodeURIComponent(im.subfolder||'')}&type=${im.type||'output'}`;
|
||||
const promptTitle=v=>{ // pull the SaveImage prefix / positive text from the run's graph
|
||||
const g=(v.prompt&&v.prompt[2])||{};
|
||||
for(const n of Object.values(g)){if(n&&n.class_type==='SaveImage')return (n.inputs.filename_prefix||'render').split('/').pop();}
|
||||
return 'render';
|
||||
};
|
||||
// ── Work log: the org's dispatched renders with real status + full context ────
|
||||
let WL=[], CTX=null;
|
||||
// live queue status (positions/ETAs/node) computed SERVER-SIDE; QLOCAL seeds the
|
||||
// client-side elapsed tick between polls. NODES = the org's GPU nodes for badges.
|
||||
let QST={items:{},medians:{},lanes:{},now:0}, QLOCAL=Date.now(), NODES=[];
|
||||
const WSTATUS={queued:['⏳','queued'],running:['●','running'],done:['✓','done'],failed:['⚠','failed'],cancelled:['✕','cancelled']};
|
||||
const fmtDur=s=>{ s=Math.max(0,Math.round(s)); if(s<60)return s+'s'; const m=Math.floor(s/60),r=s%60; if(s<3600)return m+'m'+(r?(' '+r+'s'):''); return Math.floor(s/3600)+'h '+Math.floor((s%3600)/60)+'m'; };
|
||||
const fmtEta=s=>{ s=Math.max(0,Math.round(s)); return s<60?('~'+s+'s est'):('~'+Math.round(s/60)+' min est'); };
|
||||
function wlThumb(it){ if(it.output)return thumbURL({path:it.output});
|
||||
if(it.refs&&it.refs[0])return inputThumb(it.refs[0]);
|
||||
if(it.parents&&it.parents[0])return thumbURL({path:it.parents[0]}); return ''; }
|
||||
function qmetaHTML(it,e){
|
||||
if(!e)return '';
|
||||
const node=`<span class="wnode">⚙ ${(e.node||it.node||'gpu')}</span>`;
|
||||
if(e.status==='running'){
|
||||
const el=(e.elapsed_seconds||0)+(Date.now()-QLOCAL)/1000;
|
||||
return `<div class="wmeta">${node} · #${e.position}/${e.of} · running <span id="el-${it.id}">${fmtDur(el)}</span>${e.eta_seconds!=null?(' · ~'+fmtDur(e.eta_seconds)+' left'):''}</div>`;
|
||||
}
|
||||
return `<div class="wmeta">${node} · #${e.position}/${e.of} in line · ${fmtEta(e.eta_seconds||0)}</div>`;
|
||||
}
|
||||
function wlRow(it){
|
||||
const e=QST.items[it.id]; // live status wins over the stored one
|
||||
const status=(e&&e.status)||it.status;
|
||||
const [ic,lbl]=WSTATUS[status]||['·',status||''];
|
||||
const th=wlThumb(it), when=it.ts?new Date(it.ts*1000).toLocaleString():'';
|
||||
const active=status==='queued'||status==='running';
|
||||
return `<div class="qrow" style="cursor:pointer" onclick="openContext('${it.id}')">
|
||||
<div class="qthumb" style="${th?`background-image:url('${th}');background-size:cover;background-position:center`:'display:flex;align-items:center;justify-content:center;color:var(--dim)'}">${th?'':'🗂'}</div>
|
||||
<div class="qtitle">${it.kind} · <span class="wst ${status}">${ic} ${lbl}</span>
|
||||
<div class="qdesc">${(it.prompt||'(no prompt)').replace(/</g,'<')}</div>
|
||||
${active&&e?qmetaHTML(it,e):`<div class="qsub">${when}${it.node?(' · ⚙ '+it.node):''}${it.error?` · <span style="color:#ff8f8f">${it.error.replace(/</g,'<')}</span>`:''}</div>`}</div>
|
||||
${active?`<button class="mini" onclick="event.stopPropagation();openAmend('${it.id}')" title="Amend prompt/refs (re-queues)">Edit</button><button class="mini" onclick="event.stopPropagation();cancelJob('${it.id}')">Cancel</button>`:''}
|
||||
<button class="mini" onclick="event.stopPropagation();openContext('${it.id}')">Open</button></div>`;
|
||||
}
|
||||
// server does the position/ETA math over the org's OWN jobs; the client only renders.
|
||||
async function loadQueueStatus(){ let d; try{d=await (await fetch('/v1/queue/status')).json();}catch(_){d=null;} if(d){QST=d;QLOCAL=Date.now();} }
|
||||
async function loadNodes(){ try{const d=await (await fetch('/v1/nodes')).json(); NODES=d.nodes||[];}catch(_){NODES=[];} renderQhead(); }
|
||||
function renderQhead(){
|
||||
if(!$('qhead'))return;
|
||||
const badges=(NODES||[]).map(n=>`<span class="nodebadge ${n.online?'on':'off'}"><span class="dot"></span>${n.name}${n.online?'':' · offline'}</span>`).join('')
|
||||
+`<span class="nodebadge on"><span class="dot"></span>studio pod</span>`;
|
||||
const m=QST.medians||{}, ks=Object.keys(m);
|
||||
const ms=ks.length?('median '+ks.map(k=>k+' ~'+fmtDur(m[k])).join(' · ')):'';
|
||||
$('qhead').innerHTML=badges+(ms?`<span class="mstat">${ms}</span>`:'');
|
||||
}
|
||||
async function refreshQueue(){
|
||||
const before=Object.keys(QST.items||{});
|
||||
await loadQueueStatus(); await worklog(); renderQhead();
|
||||
let fin=0; before.forEach(id=>{ if(!QST.items[id]){ const w=WL.find(x=>x.id===id); if(w&&w.status==='done')fin++; } });
|
||||
if(fin>0)toast('✓ '+fin+' render'+(fin>1?'s':'')+' finished — in your library');
|
||||
}
|
||||
async function worklog(){
|
||||
const q=($('wlq')&&$('wlq').value||'').trim();
|
||||
let d={items:[]}; try{d=await (await fetch('/v1/worklog'+(q?('?q='+encodeURIComponent(q)):''))).json();}catch(_){}
|
||||
WL=d.items||[];
|
||||
$('wllist').innerHTML=WL.map(wlRow).join('')||'<div class="qsub" style="padding:6px 2px">No work yet — your renders will appear here with full context.</div>';
|
||||
}
|
||||
function pv(url,label,click){ return `<div style="text-align:center">
|
||||
<img src="${url}" ${click?`onclick="${click}" style="cursor:pointer;`:'style="'}width:60px;height:88px;object-fit:cover;border-radius:6px;border:1px solid var(--line)">
|
||||
<div style="font-size:.58rem;color:var(--dim);margin-top:2px">${label}</div></div>`; }
|
||||
function ctxHTML(it,lin){
|
||||
const parents=(it.parents||[]).map(p=>pv(thumbURL({path:p}),'from',`openLibItem('${p.replace(/'/g,"\\'")}')`)).join('');
|
||||
const refs=(it.refs||[]).map(n=>pv(inputThumb(n),(it.uploads||[]).includes(n)?'upload':'ref')).join('');
|
||||
const result=it.output?pv(thumbURL({path:it.output}),'result',`openLibItem('${it.output.replace(/'/g,"\\'")}')`):'';
|
||||
const params=it.params&&Object.keys(it.params).length?Object.entries(it.params).map(([k,v])=>k+':'+v).join(' · '):'';
|
||||
const trail=(it.events||[]).map(e=>`${e.s}${e.ts?(' '+new Date(e.ts*1000).toLocaleTimeString()):''}${e.note?(' — '+e.note.replace(/</g,'<')):''}`).join(' → ');
|
||||
const chain=[...lin.ancestors.map(a=>[a,a.kind||'ancestor']),...(it.output?[[{path:it.output},'● this']]:[]),...lin.descendants.map(d=>[d,d.kind||'later'])];
|
||||
const chainHTML=chain.length>1?`<div class="ctxlabel">Versions</div><div style="display:flex;gap:8px;overflow-x:auto;padding-bottom:4px">${chain.map(([n,role])=>pv(thumbURL({path:n.path}),role,role==='● this'?'':`openLibItem('${(n.path||'').replace(/'/g,"\\'")}')`)).join('')}</div>`:'';
|
||||
return `${it.prompt?`<div class="ctxlabel">Prompt</div><div style="font-size:.86rem;background:#0f0f12;border:1px solid var(--line);border-radius:8px;padding:9px 11px">${it.prompt.replace(/</g,'<')}</div>`:''}
|
||||
${(parents||refs||result)?`<div class="ctxlabel">References & result</div><div class="row" style="flex-wrap:wrap">${parents}${refs}${result}</div>`:''}
|
||||
<div class="ctxlabel">Workflow</div><div style="font-size:.82rem;color:var(--dim)">${it.kind} · ${it.status||''}${it.lane&&it.lane!=='local'?(' · '+it.lane):''}${params?(' · '+params):''}</div>
|
||||
${trail?`<div class="ctxlabel">Process</div><div style="font-size:.78rem;color:var(--dim)">${trail}</div>`:''}
|
||||
${it.error?`<div class="dlgerr" style="display:block">${it.error.replace(/</g,'<')}</div>`:''}${chainHTML}`;
|
||||
}
|
||||
async function lineageFor(path){ if(!path)return {ancestors:[],descendants:[],node:''}; try{return await (await fetch('/v1/worklog/lineage?path='+encodeURIComponent(path))).json();}catch(_){return {ancestors:[],descendants:[],node:path};} }
|
||||
async function openContext(id){
|
||||
const it=WL.find(x=>x.id===id); if(!it)return; CTX=it;
|
||||
$('ctxtitle').textContent=it.kind+' — '+(it.status||'');
|
||||
$('ctxbody').innerHTML=ctxHTML(it, await lineageFor(it.output));
|
||||
const hasBase=!!(it.output||(it.parents&&it.parents[0]));
|
||||
const live=it.status==='queued'||it.status==='running';
|
||||
$('ctxreuse').style.display=hasBase?'':'none';
|
||||
$('ctxfix').style.display=hasBase?'':'none';
|
||||
$('ctxcancel').style.display=live?'':'none';
|
||||
$('ctxedit').style.display=live?'':'none';
|
||||
$('ctxdlg').showModal();
|
||||
}
|
||||
function cancelCtx(){ if(!CTX)return; ctxdlg.close(); cancelJob(CTX.id); }
|
||||
function editCtx(){ if(!CTX)return; ctxdlg.close(); openAmend(CTX.id); }
|
||||
async function openLineage(path){
|
||||
CTX={output:path,parents:[],refs:[],uploads:[],prompt:'',kind:'asset',status:''};
|
||||
$('ctxtitle').textContent='Versions'; $('ctxbody').innerHTML=ctxHTML(CTX, await lineageFor(path));
|
||||
$('ctxreuse').style.display='none'; $('ctxfix').style.display=''; $('ctxdlg').showModal();
|
||||
}
|
||||
function openLibItem(path){ ctxdlg.close(); zoom(imgURL({path})); }
|
||||
async function primeFix(base,prompt,libRefs,upRefs){
|
||||
await openFixPath(base,prompt);
|
||||
(libRefs||[]).filter(p=>p&&p!==base).forEach(p=>{ if(FIXREFS.length<MAXFIXREFS)FIXREFS.push({kind:'lib',ref:p,thumb:thumbURL({path:p})}); });
|
||||
(upRefs||[]).forEach(n=>{ if(FIXREFS.length<MAXFIXREFS)FIXREFS.push({kind:'up',ref:n,thumb:inputThumb(n)}); });
|
||||
drawFixRefs();
|
||||
}
|
||||
function reuseCtx(){ if(!CTX)return; const base=CTX.output||(CTX.parents&&CTX.parents[0]); if(!base){toast('No base image to reuse');return;} ctxdlg.close(); primeFix(base,CTX.prompt||'',CTX.parents||[],CTX.uploads||[]); }
|
||||
function fixCtx(){ if(!CTX)return; const base=CTX.output||(CTX.parents&&CTX.parents[0]); if(!base){toast('No result to fix yet');return;} ctxdlg.close(); openFixPath(base,''); }
|
||||
async function runs(){
|
||||
refreshQueue(); loadNodes();
|
||||
RATINGS=await (await fetch('/v1/library/ratings')).json().catch(()=>({}));
|
||||
// live queue
|
||||
let q={queue_running:[],queue_pending:[]}; try{q=await (await fetch('/queue')).json();}catch(_){}
|
||||
const qr=q.queue_running||[], qp=q.queue_pending||[];
|
||||
$('qrunN').textContent=qr.length; $('qpendN').textContent=qp.length;
|
||||
// YouTube-style up-next row: reference thumbnail + title + brief description.
|
||||
const qitem=(it,running,pos)=>{
|
||||
const id=it[1], g=it[2]||{};
|
||||
const nodes=Object.values(g);
|
||||
const title=(nodes.find(n=>n.class_type==='SaveImage')?.inputs?.filename_prefix||id+'').split('/').pop();
|
||||
// reference image(s) attached to this job (fix/compose sources live in input dir)
|
||||
const refs=nodes.filter(n=>n.class_type==='LoadImage').map(n=>n.inputs?.image).filter(Boolean);
|
||||
const thumb=refs.length?`/v1/library/file?thumb=1&input=1&path=${encodeURIComponent(refs[0])}`:'';
|
||||
// brief description = the positive prompt / instruction
|
||||
let desc='';
|
||||
for(const n of nodes){ if(n.class_type&&n.class_type.indexOf('TextEncode')>=0){ const t=n.inputs?.prompt||n.inputs?.text||''; if(t&&!/blurry|deformed|watermark|ghosting/i.test(t.slice(0,40))){desc=t;break;} } }
|
||||
desc=(desc||'').replace(/\.\s*(Photorealistic|Combine|keep the same).*$/i,'').trim().slice(0,140);
|
||||
const eta=running?'rendering now':(pos!=null?`~${(pos+1)*ETA_MIN} min · #${pos+1} in line`:'queued');
|
||||
return `<div class="qrow">
|
||||
<div class="qthumb" style="${thumb?`background-image:url('${thumb}');background-size:cover;background-position:center`:'display:flex;align-items:center;justify-content:center;color:var(--dim)'}">${thumb?'':(running?'▶':'⏳')}</div>
|
||||
<div class="qtitle">${title}<div class="qdesc">${desc||'(no description)'}</div><div class="qsub">${running?'<span style=color:var(--ok)>● </span>':''}${eta}${refs.length>1?` · ${refs.length} refs`:''}</div></div>
|
||||
${running?'':`<button class="mini" onclick="cancelJob('${id}')">Remove</button>`}</div>`;};
|
||||
// BYO-GPU renders (fix/compose on a connected GPU) — rendered on the worker,
|
||||
// shown here so a dispatched fix is visible (was invisible before).
|
||||
let gjobs=[]; try{gjobs=(await (await fetch('/v1/render-queue')).json()).jobs||[];}catch(_){}
|
||||
const gjRow=j=>`<div class="qrow">
|
||||
<div class="qthumb" style="${j.refs&&j.refs[0]?`background-image:url('/v1/library/file?thumb=1&input=1&path=${encodeURIComponent(j.refs[0])}');background-size:cover;background-position:center`:'display:flex;align-items:center;justify-content:center;color:var(--ok)'}">${j.refs&&j.refs[0]?'':'▶'}</div>
|
||||
<div class="qtitle">${j.prefix||'render'}<div class="qsub"><span style=color:var(--ok)>● </span>rendering on ${j.node||'your GPU'} · ${Math.max(0,Math.round((j.age||0)/60))}m elapsed${QST.items[j.id]&&QST.items[j.id].eta_seconds!=null?(' · ~'+fmtDur(QST.items[j.id].eta_seconds)+' left'):''}</div></div>
|
||||
${j.id?`<button class="mini" onclick="cancelJob('${j.id}')">Cancel</button>`:''}</div>`;
|
||||
const gjHtml=gjobs.map(gjRow).join('');
|
||||
$('qrun').innerHTML=gjHtml + qr.map(it=>qitem(it,true,null)).join('') || '<div class="qsub" style="padding:6px 2px">Idle — nothing rendering.</div>';
|
||||
$('qpend').innerHTML=qp.map((it,i)=>qitem(it,false,i)).join('')||'<div class="qsub" style="padding:6px 2px">Nothing queued.</div>';
|
||||
// history
|
||||
const h=await (await fetch('/history?max_items=40')).json();
|
||||
const items=Object.entries(h).reverse().map(([id,v])=>{const ims=[];for(const o of Object.values(v.outputs||{}))for(const im of (o.images||[]))if(im.type==='output')ims.push(im);
|
||||
return {id,ok:(v.status||{}).status_str||'?',im:ims[0],title:promptTitle(v)};});
|
||||
$('rgrid').innerHTML=items.map(r=>{
|
||||
const rt=RATINGS[r.id]||{}; const st=rt.stars||0;
|
||||
const stars=[1,2,3,4,5].map(n=>`<span class="${n<=st?'on':''}" onclick="rate('${r.id}',${n})">★</span>`).join('');
|
||||
return `<div class="card">
|
||||
${r.im?`<div class="im" style="background-image:url('${vURL(r.im)}')" onclick="zoom('${vURL(r.im)}')"></div>`:`<div class="doc">no output</div>`}
|
||||
<div class="meta">
|
||||
<span class="t">${r.title}</span>
|
||||
<div class="row" style="justify-content:space-between">
|
||||
<span class="stars">${stars}</span>
|
||||
<span class="fav ${rt.favorite?'on':''}" onclick="toggleFav('${r.id}','${r.title}')" title="Favorite → save as template">★fav</span>
|
||||
</div>
|
||||
<textarea class="notebox" placeholder="Notes for the AI to improve next time…" onblur="rateNote('${r.id}',this.value)">${(rt.notes||'').replace(/</g,'<')}</textarea>
|
||||
</div></div>`;}).join('')||'<div class="empty">No runs yet.</div>';
|
||||
}
|
||||
async function clearQueue(){if(!confirm('Clear all queued jobs?'))return;await fetch('/queue',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clear:true})});toast('Queue cleared');runs();}
|
||||
async function rate(key,stars){await fetch('/v1/library/rate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key,stars})});RATINGS[key]={...(RATINGS[key]||{}),stars};runs();}
|
||||
async function rateNote(key,notes){await fetch('/v1/library/rate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key,notes})});toast('Note saved — the AI will use it next time');}
|
||||
let FAVKEY=null,FAVTITLE=null;
|
||||
async function toggleFav(key,title){const cur=RATINGS[key]||{};const nv=!cur.favorite;
|
||||
await fetch('/v1/library/rate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key,favorite:nv})});
|
||||
RATINGS[key]={...cur,favorite:nv};
|
||||
if(nv){FAVKEY=key;FAVTITLE=title;$('tmplname').value=title;$('tmpldesc').value='';
|
||||
$('tmplsteps').innerHTML='This favorited run has an embedded workflow. Saving turns it into a reusable KIND:<br>1 · name it & describe what it makes<br>2 · it appears under Templates for your org<br>3 · run it anytime with new inputs';
|
||||
$('tmpldlg').showModal();}
|
||||
else runs();
|
||||
}
|
||||
async function saveTemplate(){const nm=$('tmplname').value.trim();if(!nm){toast('Name the template');return;}
|
||||
// Real save lands with the studio-app template store (HIP-0506 phase 3); for now
|
||||
// persist intent on the rating entry so it's not lost and surfaces the guided flow.
|
||||
await fetch('/v1/library/rate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key:FAVKEY,notes:'TEMPLATE: '+nm+' — '+$('tmpldesc').value})});
|
||||
$('tmpldlg').close();toast('★ Saved "'+nm+'" — it will appear under Templates');runs();
|
||||
}
|
||||
async function setSt(i,status){const a=DATA.assets[i];const r=await fetch('/v1/library/status',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:a.path,status})});if(r.ok){a.status=status;render();toast(`${a.path.split('/').pop()} → ${status}`);}else toast('Failed');}
|
||||
// Optimistic trash: mark deleted locally + re-render immediately (feels instant),
|
||||
// then persist. On failure, revert. This is the always-visible 🗑 on each card.
|
||||
async function quickDel(path){
|
||||
const a=DATA.assets.find(x=>x.path===path); if(!a)return;
|
||||
const prev=a.status; a.status='deleted'; render(); toast('Moved to Deleted');
|
||||
try{const r=await fetch('/v1/library/status',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path,status:'deleted'})});
|
||||
if(!r.ok)throw 0;}catch(_){a.status=prev;render();toast('Delete failed — restored');}
|
||||
}
|
||||
async function rerun(i){const a=DATA.assets[i];toast('Queueing re-run…');const r=await fetch('/v1/library/rerun',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:a.path})});const j=await r.json();toast(r.ok?`Re-run queued — see Runs`:'Failed');}
|
||||
// ── Fix surface: base image + up to 4 references (history picks + drag/drop
|
||||
// uploads). No references → the single-image edit (/v1/library/fix); any
|
||||
// reference → the multi-input compose (/v1/library/compose) — same proven Qwen
|
||||
// graph, base image first. 5-ref cap = base + 4. ─────────────────────────────
|
||||
let FIXPATH=null; // the base image (library asset path)
|
||||
let FIXREFS=[]; // [{kind:'lib'|'up', ref, thumb}] added references
|
||||
let FIXMODE='fix'; // 'fix' (new render) | 'amend' (swap a queued job)
|
||||
let AMENDID=null; // the queued prompt_id when FIXMODE==='amend'
|
||||
let ORIGREFS=[]; // amend: the job's current refs (input-dir names), locked base
|
||||
let MAXFIXREFS=4; // added-ref cap = 5 − base count (set per open)
|
||||
function drawFixRefs(){
|
||||
let base;
|
||||
if(FIXMODE==='amend'){
|
||||
base=(ORIGREFS||[]).map(n=>`<div style="text-align:center"><img src="${inputThumb(n)}" style="width:56px;height:84px;object-fit:cover;border-radius:6px;border:2px solid var(--brand)"><div style="font-size:.6rem;color:var(--dim);margin-top:3px">current</div></div>`).join('')
|
||||
||`<div style="font-size:.72rem;color:var(--dim)">Amending queued job</div>`;
|
||||
}else{
|
||||
base=`<div style="text-align:center"><img src="${thumbURL({path:FIXPATH})}" style="width:56px;height:84px;object-fit:cover;border-radius:6px;border:2px solid var(--brand)"><div style="font-size:.6rem;color:var(--dim);margin-top:3px">base</div></div>`;
|
||||
}
|
||||
const refs=FIXREFS.map((r,i)=>`<div style="position:relative;text-align:center">
|
||||
<img src="${r.thumb}" style="width:56px;height:84px;object-fit:cover;border-radius:6px;border:1px solid var(--line)">
|
||||
<button onclick="removeFixRef(${i})" title="Remove" style="position:absolute;top:-6px;right:-6px;width:18px;height:18px;border-radius:50%;border:none;background:#c0392b;color:#fff;font-size:.72rem;line-height:1;cursor:pointer">×</button>
|
||||
<div style="font-size:.6rem;color:var(--dim);margin-top:3px">${r.kind==='up'?'upload':'ref'}</div></div>`).join('');
|
||||
$('fixrefs').innerHTML=base+refs;
|
||||
}
|
||||
function removeFixRef(i){FIXREFS.splice(i,1);drawFixRefs();if($('fixhist').style.display!=='none')renderFixHist();}
|
||||
function toggleFixHistory(){const h=$('fixhist');const show=h.style.display==='none';h.style.display=show?'block':'none';if(show)renderFixHist();}
|
||||
function renderFixHist(){
|
||||
const imgs=DATA.assets.filter(a=>/\.(png|jpe?g|webp)$/i.test(a.path||'')&&a.status!=='deleted'&&a.path!==FIXPATH);
|
||||
$('fixhist').innerHTML=`<div style="font-size:.72rem;color:var(--dim);margin-bottom:8px">Tap a past generation to add it as a reference (max ${MAXFIXREFS}).</div>`
|
||||
+`<div class="grid" style="grid-template-columns:repeat(auto-fill,minmax(70px,1fr));gap:8px">`
|
||||
+imgs.map(a=>{const on=FIXREFS.some(r=>r.kind==='lib'&&r.ref===a.path);
|
||||
return `<img src="${thumbURL(a)}" onclick="addFixHistory('${a.path.replace(/'/g,"\\'")}')" title="${a.path}" style="width:100%;aspect-ratio:2/3;object-fit:cover;border-radius:6px;cursor:pointer;border:2px solid ${on?'var(--brand)':'transparent'}">`;
|
||||
}).join('')+`</div>`;
|
||||
}
|
||||
function addFixHistory(path){
|
||||
const at=FIXREFS.findIndex(r=>r.kind==='lib'&&r.ref===path);
|
||||
if(at>=0){FIXREFS.splice(at,1);}
|
||||
else{ if(FIXREFS.length>=MAXFIXREFS){toast('Up to '+MAXFIXREFS+' references');return;} FIXREFS.push({kind:'lib',ref:path,thumb:thumbURL({path})}); }
|
||||
drawFixRefs(); renderFixHist();
|
||||
}
|
||||
function showFixErr(m){ const e=$('fixerr'); e.textContent=m||''; e.style.display=m?'block':'none'; }
|
||||
async function uploadFixFiles(files){
|
||||
for(const f of [...files]){
|
||||
if(FIXREFS.length>=MAXFIXREFS){toast('Up to '+MAXFIXREFS+' references');break;}
|
||||
const r=await uploadImage(f);
|
||||
if(r.name){FIXREFS.push({kind:'up',ref:r.name,thumb:inputThumb(r.name)}); drawFixRefs(); showFixErr('');}
|
||||
else showFixErr('Upload failed: '+r.error);
|
||||
}
|
||||
}
|
||||
async function openFixPath(path,prompt){FIXMODE='fix';AMENDID=null;ORIGREFS=[];MAXFIXREFS=4;
|
||||
FIXPATH=path;FIXREFS=[];$('fixtext').value=prompt||'';showFixErr('');
|
||||
$('fixtitle').textContent='Fix with chat';
|
||||
$('fixdesc').textContent='Describe the change — a new render is queued from this image. Add reference photos (drag in, or pick from your history) to guide it.';
|
||||
$('fixhist').style.display='none'; drawFixRefs();
|
||||
$('gpuwarn').style.display=(await gpuOnline())?'none':'block';
|
||||
$('gpuwarn').textContent='⚠ No GPU worker online — this queues but renders only when a BYO-GPU (e.g. spark) is connected.';
|
||||
$('fixdlg').showModal();}
|
||||
async function openFix(i){ return openFixPath(DATA.assets[i].path,''); }
|
||||
async function openAmend(id){
|
||||
const it=WL.find(x=>x.id===id); if(!it){toast('Job not found');return;}
|
||||
FIXMODE='amend';AMENDID=id;FIXPATH=null;ORIGREFS=it.refs||[];FIXREFS=[];MAXFIXREFS=Math.max(0,5-ORIGREFS.length);
|
||||
$('fixtext').value=it.prompt||'';showFixErr('');
|
||||
$('fixtitle').textContent='Edit queued job';
|
||||
$('fixdesc').textContent='Change the prompt and/or add references. Saving cancels the queued job and re-queues it with the amendment — it re-enters the queue, so its position resets.';
|
||||
$('fixhist').style.display='none'; drawFixRefs();
|
||||
$('gpuwarn').style.display='none';
|
||||
$('fixdlg').showModal();
|
||||
}
|
||||
async function sendFix(){const t=$('fixtext').value.trim();if(t.length<3){showFixErr('Describe the change (3+ characters).');return;}
|
||||
showFixErr(''); toast(FIXMODE==='amend'?'Amending…':'Queueing fix…');
|
||||
const paths=FIXREFS.filter(x=>x.kind==='lib').map(x=>x.ref);
|
||||
const uploads=FIXREFS.filter(x=>x.kind==='up').map(x=>x.ref);
|
||||
let r;
|
||||
if(FIXMODE==='amend'){ // atomic swap: cancel the queued job + re-dispatch amended
|
||||
r=await fetch('/v1/queue/amend',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt_id:AMENDID,instruction:t,paths,uploads})});
|
||||
}else if(!FIXREFS.length){ // single base image → the proven edit path
|
||||
r=await fetch('/v1/library/fix',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:FIXPATH,instruction:t})});
|
||||
}else{ // base + references → multi-reference compose
|
||||
r=await fetch('/v1/library/compose',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths:[FIXPATH,...paths],instruction:t,uploads})});
|
||||
}
|
||||
const j=await r.json().catch(()=>({}));
|
||||
if(r.ok){ $('fixdlg').close(); toast(FIXMODE==='amend'?'Amended — re-queued (position reset)':'Fix queued → see Queue & History'); if(typeof runs==='function')runs(); }
|
||||
else showFixErr('Couldn’t '+(FIXMODE==='amend'?'amend':'queue')+': '+(errMsg(j)||('error '+r.status))); // stay open so the reason is visible
|
||||
}
|
||||
async function cancelJob(id){
|
||||
if(!confirm('Cancel this job?'))return;
|
||||
const r=await fetch('/v1/queue/cancel',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt_id:id})});
|
||||
const j=await r.json().catch(()=>({}));
|
||||
if(r.ok)toast('Cancelled'+(j.canceled==='gpu'?' — if it was already rendering on your GPU it may still finish':''));
|
||||
else toast('Cancel failed: '+(errMsg(j)||('error '+r.status)));
|
||||
if(typeof runs==='function')runs();
|
||||
}
|
||||
// One shared drag/drop/paste + click-to-upload wiring, applied to every add-photo
|
||||
// surface: the Fix dialog dropzone AND the composer prompt box + "+" button.
|
||||
(function(){
|
||||
const fdrop=$('fixdrop'),ffile=$('fixfile');
|
||||
fdrop.addEventListener('click',()=>ffile.click());
|
||||
ffile.addEventListener('change',e=>{uploadFixFiles(e.target.files);e.target.value='';});
|
||||
wireDrop($('fixdlg'),uploadFixFiles); wirePaste($('fixdlg'),uploadFixFiles);
|
||||
const af=$('attachfile');
|
||||
af.addEventListener('change',e=>{attachFiles(e.target.files);e.target.value='';});
|
||||
const box=document.querySelector('.prompt'); if(box){ wireDrop(box,attachFiles); wirePaste(box,attachFiles); }
|
||||
})();
|
||||
function zoom(u){$('zoomimg').src=u;$('zoom').style.display='flex';}
|
||||
const ta=$('pin'); ta.addEventListener('input',()=>{ta.style.height='auto';ta.style.height=Math.min(ta.scrollHeight,180)+'px';});
|
||||
async function loadSession(){
|
||||
let s={}; try{ s=await (await fetch('/v1/session')).json(); }catch(e){ return; }
|
||||
const nm=(s.user||s.email||'').trim(); $('av').textContent=(nm[0]||'·').toUpperCase(); if(s.org)$('org').textContent=s.org;
|
||||
$('user').onclick=()=>{ const ex=$('omenu'); if(ex){ex.remove();return;}
|
||||
const orgs=(s.orgs&&s.orgs.length?s.orgs:[s.org]).filter(Boolean);
|
||||
const m=document.createElement('div'); m.id='omenu';
|
||||
m.style.cssText='position:fixed;top:54px;right:24px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:6px;min-width:190px;z-index:30;box-shadow:0 12px 34px #000b';
|
||||
m.innerHTML=orgs.map(o=>`<div class="omi" data-o="${o}" style="padding:8px 11px;border-radius:8px;cursor:pointer;font-size:.85rem;color:${o===s.org?'var(--txt)':'var(--dim)'}">${o===s.org?'● ':'○ '}${o}</div>`).join('')
|
||||
+`<div style="height:1px;background:var(--line);margin:5px 4px"></div>`+(s.console_url?`<a href="${s.console_url}" style="display:block;padding:8px 11px;color:var(--dim);text-decoration:none;font-size:.85rem">Console ↗</a>`:'')+`<a href="/logout" style="display:block;padding:8px 11px;color:var(--dim);text-decoration:none;font-size:.85rem">Sign out</a>`;
|
||||
document.body.appendChild(m);
|
||||
m.querySelectorAll('.omi').forEach(el=>el.onclick=async()=>{try{await fetch('/v1/session/org',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({org:el.dataset.o})});}catch(e){} location.reload();});
|
||||
setTimeout(()=>document.addEventListener('click',function h(e){if(!m.contains(e.target)&&!$('user').contains(e.target)){m.remove();document.removeEventListener('click',h);}}),0);
|
||||
};
|
||||
}
|
||||
// ── persistent live-queue bar: always shows what's generating + ETA ──────────
|
||||
const ETA_MIN=9; // ~min per render on a warm BYO-GPU
|
||||
async function updateLivebar(){
|
||||
// Two queues: the pod's local /queue (in-pod renders) AND the org's BYO-GPU
|
||||
// renders on gpu-jobs (fix/compose/rerun dispatched to a connected GPU). The
|
||||
// latter is why a queued fix "never showed" — it renders on the worker, not
|
||||
// the pod. Merge both.
|
||||
let q={queue_running:[],queue_pending:[]}; try{q=await (await fetch('/queue')).json();}catch(_){}
|
||||
let rq={jobs:[]}; try{rq=await (await fetch('/v1/render-queue')).json();}catch(_){}
|
||||
const run=q.queue_running||[], pend=q.queue_pending||[];
|
||||
const gpuJobs=rq.jobs||[];
|
||||
const lb=$('livebar'), st=$('lbstatus');
|
||||
const name=it=>{try{return (Object.values(it[2]).find(n=>n.class_type==='SaveImage')?.inputs?.filename_prefix||'render').split('/').pop();}catch(_){return 'render';}};
|
||||
if(gpuJobs.length){
|
||||
lb.classList.remove('idle');
|
||||
const first=gpuJobs[0], more=gpuJobs.length-1;
|
||||
st.innerHTML=`<span class="lb-run"><span class="spin"></span>Generating on your GPU: <b>${first.prefix||'render'}</b></span>`
|
||||
+ (more>0?` <span class="lb-q">· ${more} more</span>`:'')
|
||||
+ ` <span class="lb-eta">· ~${ETA_MIN} min each</span>`;
|
||||
} else if(run.length){
|
||||
lb.classList.remove('idle');
|
||||
const q1=pend.length;
|
||||
st.innerHTML=`<span class="lb-run"><span class="spin"></span>Generating: <b>${name(run[0])}</b></span>`
|
||||
+ (q1?` <span class="lb-q">· ${q1} queued</span>`:'')
|
||||
+ ` <span class="lb-eta">· ~${ETA_MIN}${q1?`–${(q1+1)*ETA_MIN}`:''} min</span>`;
|
||||
} else if(pend.length){
|
||||
lb.classList.remove('idle');
|
||||
st.innerHTML=`<span class="lb-q">⏳ ${pend.length} queued · ~${pend.length*ETA_MIN} min · waiting for a GPU</span>`;
|
||||
} else {
|
||||
lb.classList.add('idle');
|
||||
const gpu=await gpuOnline();
|
||||
st.innerHTML=`<span class="lb-q">Idle — nothing generating.</span>${gpu?'':' <span class="lb-eta">· connect a GPU to generate</span>'}`;
|
||||
}
|
||||
}
|
||||
// header auto-hide (immersive) — reveal on top-hover / mouse near top
|
||||
document.getElementById('tophover').addEventListener('mouseenter',()=>document.querySelector('header').classList.add('reveal'));
|
||||
document.addEventListener('mousemove',e=>{if(e.clientY<8)document.querySelector('header').classList.add('reveal');else if(e.clientY>90&&document.body.classList.contains('immersive'))document.querySelector('header').classList.remove('reveal');});
|
||||
load(); loadSession(); updateLivebar(); setInterval(updateLivebar, 5000);
|
||||
// Queue transparency: refresh positions/ETAs while the queue view is open (sane
|
||||
// cadence, no hammering); node badges less often; tick the running item's elapsed.
|
||||
setInterval(()=>{ if(!$('runs').hidden)refreshQueue(); }, 6000);
|
||||
setInterval(()=>{ if(!$('runs').hidden)loadNodes(); }, 20000);
|
||||
setInterval(()=>{ for(const id in (QST.items||{})){ const e=QST.items[id]; if(e.status==='running'){ const el=$('el-'+id); if(el)el.textContent=fmtDur((e.elapsed_seconds||0)+(Date.now()-QLOCAL)/1000); } } }, 1000);
|
||||
</script>
|
||||
@@ -0,0 +1,966 @@
|
||||
"""Studio Home — the content-forward default view (flow-style), with the node
|
||||
editor demoted to "Advanced mode".
|
||||
|
||||
The normal user works per DESIGN, not per node graph: see the assets a pipeline
|
||||
produced, judge them (status chips make blotchy rejects vs approved picks
|
||||
obvious), re-run or chat-fix a given output, and step into the graph editor only
|
||||
when they mean to. This module adds that surface without inventing any new
|
||||
storage or lifecycle:
|
||||
|
||||
GET /studio the home page (auth-gated like everything else)
|
||||
GET /v1/library org's library.json + per-asset workflow provenance
|
||||
POST /v1/library/status {path, status} draft|approved|queued|published
|
||||
POST /v1/library/rerun {path, count?} re-queue the EXACT embedded graph, new seed
|
||||
POST /v1/library/fix {path, instruction} chat-fix: Qwen edit pass on that output
|
||||
POST /v1/library/compose {paths[], uploads[]?, instruction} multi-reference render
|
||||
(library assets and/or images uploaded via /upload/image)
|
||||
|
||||
One source of truth per concern, all pre-existing:
|
||||
* assets + curation state -> orgs/<org>/output/library.json (library_manifest.py)
|
||||
* what made an image -> the API graph ComfyUI embeds in the PNG itself
|
||||
* running work -> the ONE /prompt path (self-POST, so gpu_dispatch,
|
||||
billing and content_publish all apply unchanged)
|
||||
* identity -> the caller's own session; headers are forwarded
|
||||
verbatim on the self-POST, no token is stored
|
||||
* workflow editing -> the 0.15.1 deep-link opener (?workflow=&run=1)
|
||||
|
||||
Env:
|
||||
STUDIO_HOME_DEFAULT "1"/"true": browser GET / redirects to /studio
|
||||
(the SPA stays reachable at /?advanced=1 and all
|
||||
non-root paths). Off by default so local dev and
|
||||
worker pods are untouched.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
|
||||
import folder_paths
|
||||
from middleware import worklog
|
||||
|
||||
log = logging.getLogger("studio.home")
|
||||
|
||||
_HTML = Path(__file__).with_name("studio_home.html")
|
||||
_STATUSES = ("draft", "approved", "queued", "published", "deleted")
|
||||
_PROV_CACHE: dict[str, tuple[float, dict]] = {} # abspath -> (mtime, provenance)
|
||||
|
||||
_NEG = ("ghosting, double exposure, duplicated body, transparent fabric, sheer "
|
||||
"fabric, illustration, painterly, blotchy skin, mottled skin, jpeg "
|
||||
"artifacts, pixelation, noise, deformed hands, extra limbs, text, watermark")
|
||||
|
||||
|
||||
def _home_default() -> bool:
|
||||
return os.environ.get("STUDIO_HOME_DEFAULT", "").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def _org_of(request: web.Request) -> str:
|
||||
user = request.get("iam_user") or {}
|
||||
return user.get("org_id") or os.environ.get("STUDIO_ORG_ID") or "default"
|
||||
|
||||
|
||||
def _library_root(org: str) -> Path | None:
|
||||
"""The org's asset tree. Pod layout first (orgs/<org>/output via
|
||||
folder_paths), then the flat-dev staging layout (output/orgs/<org>/output)."""
|
||||
for cand in (
|
||||
Path(folder_paths.get_org_output_directory(org)),
|
||||
Path(folder_paths.get_output_directory()) / "orgs" / org / "output",
|
||||
):
|
||||
if (cand / "library.json").is_file():
|
||||
return cand
|
||||
return None
|
||||
|
||||
|
||||
def _view_params(abs_path: Path) -> dict:
|
||||
"""Query params for the existing org-scoped /view endpoint."""
|
||||
out = Path(folder_paths.get_output_directory())
|
||||
try:
|
||||
rel = abs_path.relative_to(out)
|
||||
except ValueError:
|
||||
# org-scoped dir outside the flat output dir (pod layout): /view resolves
|
||||
# the org dir itself in multi-tenant mode, so make the path org-relative.
|
||||
for org_base in abs_path.parents:
|
||||
if org_base.name == "output" and org_base.parent.parent.name == "orgs":
|
||||
rel = abs_path.relative_to(org_base)
|
||||
break
|
||||
else:
|
||||
rel = Path(abs_path.name)
|
||||
return {"filename": rel.name, "subfolder": str(rel.parent) if str(rel.parent) != "." else "", "type": "output"}
|
||||
|
||||
|
||||
def _provenance(abs_path: Path) -> dict:
|
||||
"""What made this image, from the graph ComfyUI embeds in the PNG."""
|
||||
try:
|
||||
mtime = abs_path.stat().st_mtime
|
||||
except OSError:
|
||||
return {}
|
||||
key = str(abs_path)
|
||||
hit = _PROV_CACHE.get(key)
|
||||
if hit and hit[0] == mtime:
|
||||
return hit[1]
|
||||
prov: dict = {}
|
||||
if abs_path.suffix.lower() == ".png":
|
||||
try:
|
||||
from PIL import Image
|
||||
info = Image.open(abs_path).info
|
||||
graph = json.loads(info.get("prompt", "null"))
|
||||
if isinstance(graph, dict):
|
||||
for node in graph.values():
|
||||
ct = node.get("class_type", "")
|
||||
ins = node.get("inputs", {})
|
||||
if ct == "SaveImage":
|
||||
prov["workflow"] = ins.get("filename_prefix", "")
|
||||
elif "KSampler" in ct:
|
||||
prov.update(steps=ins.get("steps"), cfg=ins.get("cfg"),
|
||||
seed=ins.get("seed", ins.get("noise_seed")))
|
||||
prov["nodes"] = len(graph)
|
||||
except Exception: # non-Comfy PNG, truncated file — provenance is optional
|
||||
prov = {}
|
||||
_PROV_CACHE[key] = (mtime, prov)
|
||||
return prov
|
||||
|
||||
|
||||
def _load_library(root: Path) -> dict:
|
||||
try:
|
||||
return json.loads((root / "library.json").read_text())
|
||||
except Exception:
|
||||
return {"assets": []}
|
||||
|
||||
|
||||
def _save_library(root: Path, lib: dict) -> None:
|
||||
tmp = root / "library.json.tmp"
|
||||
tmp.write_text(json.dumps(lib, indent=1, sort_keys=False))
|
||||
tmp.replace(root / "library.json")
|
||||
|
||||
|
||||
def _safe_asset(root: Path, rel: str) -> Path | None:
|
||||
p = (root / rel).resolve()
|
||||
return p if p.is_file() and str(p).startswith(str(root.resolve())) else None
|
||||
|
||||
|
||||
_REF_EXT = (".png", ".jpg", ".jpeg", ".webp")
|
||||
|
||||
|
||||
def _resolve_uploads(org: str, uploads: list | None) -> list[str]:
|
||||
"""Validate reference images already uploaded via ``POST /upload/image`` — the
|
||||
core engine route lands them in this org's input dir (the SAME place fix/compose
|
||||
stage their library-sourced references), so the compose graph's LoadImage nodes
|
||||
can name them directly and the BYO-GPU collector ships them with the job. Return
|
||||
the safe basenames, in order, de-duped. A traversal/absent/non-image entry is
|
||||
dropped, never fatal — the caller enforces the reference count."""
|
||||
in_dir = Path(folder_paths.get_org_input_directory(org))
|
||||
root = str(in_dir.resolve())
|
||||
names: list[str] = []
|
||||
for u in uploads or []:
|
||||
if not isinstance(u, str):
|
||||
continue
|
||||
name = os.path.basename(u.strip())
|
||||
if not name or name in names or os.path.splitext(name)[1].lower() not in _REF_EXT:
|
||||
continue
|
||||
p = (in_dir / name).resolve()
|
||||
if p.is_file() and str(p).startswith(root):
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def _reseed(graph: dict) -> dict:
|
||||
for node in graph.values():
|
||||
ins = node.get("inputs", {})
|
||||
for k in ("seed", "noise_seed"):
|
||||
if isinstance(ins.get(k), (int, float)):
|
||||
ins[k] = random.randrange(2**31)
|
||||
return graph
|
||||
|
||||
|
||||
def _fix_graph(ref_name: str, instruction: str, prefix: str) -> dict:
|
||||
"""The proven Qwen-Image-Edit-2511 wiring, instruction-edit form: the chosen
|
||||
output is the sole reference; the user's words are the edit."""
|
||||
prompt = (f"{instruction.strip()}. Photorealistic, keep the same model, "
|
||||
f"pose, framing, lighting and background; only apply the "
|
||||
f"requested change.")
|
||||
return {
|
||||
"1": {"class_type": "LoadImage", "inputs": {"image": ref_name}},
|
||||
"2": {"class_type": "FluxKontextImageScale", "inputs": {"image": ["1", 0]}},
|
||||
"3": {"class_type": "CLIPLoader", "inputs": {"clip_name": "qwen2.5-vl-7b.safetensors", "type": "qwen_image", "device": "default"}},
|
||||
"4": {"class_type": "UNETLoader", "inputs": {"unet_name": "qwen-image-edit-2511.safetensors", "weight_dtype": "default"}},
|
||||
"5": {"class_type": "VAELoader", "inputs": {"vae_name": "qwen-image-vae.safetensors"}},
|
||||
"6": {"class_type": "TextEncodeQwenImageEditPlus", "inputs": {"clip": ["3", 0], "vae": ["5", 0], "image1": ["2", 0], "prompt": prompt}},
|
||||
"7": {"class_type": "TextEncodeQwenImageEditPlus", "inputs": {"clip": ["3", 0], "prompt": _NEG}},
|
||||
"8": {"class_type": "FluxKontextMultiReferenceLatentMethod", "inputs": {"conditioning": ["6", 0], "reference_latents_method": "index_timestep_zero"}},
|
||||
"9": {"class_type": "FluxKontextMultiReferenceLatentMethod", "inputs": {"conditioning": ["7", 0], "reference_latents_method": "index_timestep_zero"}},
|
||||
"10": {"class_type": "EmptySD3LatentImage", "inputs": {"width": 1024, "height": 1536, "batch_size": 1}},
|
||||
"11": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["4", 0], "shift": 3.1}},
|
||||
"12": {"class_type": "CFGNorm", "inputs": {"model": ["11", 0], "strength": 1.0}},
|
||||
"13": {"class_type": "KSampler", "inputs": {"model": ["12", 0], "positive": ["8", 0], "negative": ["9", 0], "latent_image": ["10", 0],
|
||||
"seed": random.randrange(2**31), "steps": 30, "cfg": 4.0, "sampler_name": "euler", "scheduler": "simple", "denoise": 1.0}},
|
||||
"14": {"class_type": "VAEDecode", "inputs": {"samples": ["13", 0], "vae": ["5", 0]}},
|
||||
"15": {"class_type": "SaveImage", "inputs": {"images": ["14", 0], "filename_prefix": prefix}},
|
||||
}
|
||||
|
||||
|
||||
def _compose_graph(ref_names: list[str], instruction: str, prefix: str) -> dict:
|
||||
"""Multi-reference compose: several chosen assets become the references and the
|
||||
user's words say HOW to combine them into a new output. Same proven Qwen
|
||||
reference-conditioned wiring as _fix_graph (TextEncodeQwenImageEditPlus takes
|
||||
image1..imageN), so no new pipeline — the multi-input case of the good graph."""
|
||||
prompt = (f"{instruction.strip()}. Combine the provided reference images as "
|
||||
f"described. Photorealistic, coherent single result.")
|
||||
g: dict = {
|
||||
"3": {"class_type": "CLIPLoader", "inputs": {"clip_name": "qwen2.5-vl-7b.safetensors", "type": "qwen_image", "device": "default"}},
|
||||
"4": {"class_type": "UNETLoader", "inputs": {"unet_name": "qwen-image-edit-2511.safetensors", "weight_dtype": "default"}},
|
||||
"5": {"class_type": "VAELoader", "inputs": {"vae_name": "qwen-image-vae.safetensors"}},
|
||||
}
|
||||
pos_inputs: dict = {"clip": ["3", 0], "vae": ["5", 0], "prompt": prompt}
|
||||
nid = 20
|
||||
for i, name in enumerate(ref_names[:5], start=1): # up to 5 references
|
||||
li, si = str(nid), str(nid + 1)
|
||||
g[li] = {"class_type": "LoadImage", "inputs": {"image": name}}
|
||||
g[si] = {"class_type": "FluxKontextImageScale", "inputs": {"image": [li, 0]}}
|
||||
pos_inputs[f"image{i}"] = [si, 0]
|
||||
nid += 2
|
||||
g["6"] = {"class_type": "TextEncodeQwenImageEditPlus", "inputs": pos_inputs}
|
||||
g["7"] = {"class_type": "TextEncodeQwenImageEditPlus", "inputs": {"clip": ["3", 0], "prompt": _NEG}}
|
||||
g["8"] = {"class_type": "FluxKontextMultiReferenceLatentMethod", "inputs": {"conditioning": ["6", 0], "reference_latents_method": "index_timestep_zero"}}
|
||||
g["9"] = {"class_type": "FluxKontextMultiReferenceLatentMethod", "inputs": {"conditioning": ["7", 0], "reference_latents_method": "index_timestep_zero"}}
|
||||
g["10"] = {"class_type": "EmptySD3LatentImage", "inputs": {"width": 1024, "height": 1536, "batch_size": 1}}
|
||||
g["11"] = {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["4", 0], "shift": 3.1}}
|
||||
g["12"] = {"class_type": "CFGNorm", "inputs": {"model": ["11", 0], "strength": 1.0}}
|
||||
g["13"] = {"class_type": "KSampler", "inputs": {"model": ["12", 0], "positive": ["8", 0], "negative": ["9", 0], "latent_image": ["10", 0],
|
||||
"seed": random.randrange(2**31), "steps": 30, "cfg": 4.0, "sampler_name": "euler", "scheduler": "simple", "denoise": 1.0}}
|
||||
g["14"] = {"class_type": "VAEDecode", "inputs": {"samples": ["13", 0], "vae": ["5", 0]}}
|
||||
g["15"] = {"class_type": "SaveImage", "inputs": {"images": ["14", 0], "filename_prefix": prefix}}
|
||||
return g
|
||||
|
||||
|
||||
class DispatchError(ValueError):
|
||||
"""A create/fix/compose request that can't be honored — bad input, unknown
|
||||
asset, or a downstream /prompt failure. Carries a human message; the routes
|
||||
return it as ``{"error": <message>}`` so a dispatch NEVER fails silently."""
|
||||
|
||||
|
||||
def _prompt_error_message(body) -> str:
|
||||
"""A short, human reason from a /prompt non-200 body. On a model-less cloud
|
||||
pod the graph fails validation ("unet_name '…' not in []"); the router returns
|
||||
'no GPU worker' / 'provisioning'. This is what the user must SEE instead of a
|
||||
silent nothing."""
|
||||
err = body.get("error") if isinstance(body, dict) else None
|
||||
if isinstance(err, dict):
|
||||
msg = err.get("message") or err.get("type") or "render could not be queued"
|
||||
det = err.get("details")
|
||||
return f"{msg} — {det}" if det and det != msg else msg
|
||||
if isinstance(err, str) and err.strip():
|
||||
return err.strip()
|
||||
if isinstance(body, dict) and body.get("message"):
|
||||
return str(body["message"])
|
||||
return "render could not be queued — no GPU worker online for this org"
|
||||
|
||||
|
||||
async def _queue_prompt(request: web.Request, server, graph: dict) -> str:
|
||||
"""Self-POST to the ONE /prompt path with the caller's own credentials, so
|
||||
dispatch/billing/tenancy/content-publish behave exactly as a UI run. A non-200
|
||||
from /prompt (model-less pod fails validation; router has no GPU worker) is
|
||||
raised as a ``DispatchError`` carrying the real reason — NOT a raw 502 whose
|
||||
truncated body the dialog can't parse (that was the deployed 'nothing happened /
|
||||
nothing queued' — the error was swallowed)."""
|
||||
port = getattr(server, "port", None) or int(os.environ.get("PORT", "8188"))
|
||||
headers = {"Content-Type": "application/json"}
|
||||
for h in ("Authorization", "Cookie"):
|
||||
if request.headers.get(h):
|
||||
headers[h] = request.headers[h]
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60)) as s:
|
||||
async with s.post(f"http://127.0.0.1:{port}/prompt", json={"prompt": graph}, headers=headers) as r:
|
||||
text = await r.text()
|
||||
try:
|
||||
body = json.loads(text) if text else {}
|
||||
except ValueError:
|
||||
body = {} # non-JSON (e.g. an HTML error page) — don't 500, report it
|
||||
if r.status != 200:
|
||||
raise DispatchError(_prompt_error_message(body) if body
|
||||
else f"render engine returned HTTP {r.status}")
|
||||
pid = body.get("prompt_id", "") if isinstance(body, dict) else ""
|
||||
if not pid:
|
||||
raise DispatchError("render was not queued (no prompt_id returned)")
|
||||
return pid
|
||||
except aiohttp.ClientError as e:
|
||||
raise DispatchError(f"could not reach the render engine ({e})")
|
||||
|
||||
|
||||
def _graph_params(graph: dict) -> dict:
|
||||
"""The render params worth showing in the work-log context panel."""
|
||||
p: dict = {}
|
||||
for n in graph.values():
|
||||
if not isinstance(n, dict):
|
||||
continue
|
||||
ct, ins = n.get("class_type", ""), n.get("inputs", {})
|
||||
if "KSampler" in ct:
|
||||
p.update(steps=ins.get("steps"), cfg=ins.get("cfg"),
|
||||
sampler=ins.get("sampler_name"), scheduler=ins.get("scheduler"),
|
||||
seed=ins.get("seed", ins.get("noise_seed")))
|
||||
elif ct == "EmptySD3LatentImage":
|
||||
p.update(width=ins.get("width"), height=ins.get("height"))
|
||||
return {k: v for k, v in p.items() if v is not None}
|
||||
|
||||
|
||||
def _in_render_jobs(org: str, pid: str) -> bool:
|
||||
"""True if a prompt_id was dispatched to this org's BYO-GPU lane (gpu-jobs) —
|
||||
gpu_dispatch._track_dispatched writes it to render_jobs.json during the self-POST."""
|
||||
return _render_job(org, pid) is not None
|
||||
|
||||
|
||||
def _render_job(org: str, pid: str) -> dict | None:
|
||||
try:
|
||||
jf = Path(folder_paths.get_org_output_directory(org)) / "render_jobs.json"
|
||||
for j in json.loads(jf.read_text()):
|
||||
if j.get("id") == pid:
|
||||
return j
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _render_job_node(org: str, pid: str) -> str:
|
||||
"""The node label the gpu-jobs lane recorded for this dispatch (spark/dbc/…)."""
|
||||
j = _render_job(org, pid)
|
||||
return (j.get("node") if j else None) or "gpu"
|
||||
|
||||
|
||||
def _output_mtime(root: Path | None, rel: str) -> int | None:
|
||||
try:
|
||||
return int((root / rel).stat().st_mtime) if root and rel else None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _remove_render_job(org: str, pid: str) -> bool:
|
||||
"""Drop a BYO-GPU tracking row from this org's render_jobs.json. The file is
|
||||
per-org so a match is inherently owned. Returns True if one was removed."""
|
||||
try:
|
||||
jf = Path(folder_paths.get_org_output_directory(org)) / "render_jobs.json"
|
||||
jobs = json.loads(jf.read_text())
|
||||
except Exception:
|
||||
return False
|
||||
keep = [j for j in jobs if j.get("id") != pid]
|
||||
if len(keep) == len(jobs):
|
||||
return False
|
||||
tmp = jf.with_name(jf.name + ".tmp")
|
||||
tmp.write_text(json.dumps(keep))
|
||||
tmp.replace(jf)
|
||||
return True
|
||||
|
||||
|
||||
def _owns_job(server, org: str, pid: str) -> bool:
|
||||
"""Does this prompt_id belong to the caller's org? True if it's in the org's
|
||||
work log, or a local-queue item stamped with this org, or the org's BYO-GPU
|
||||
tracking file. A cross-tenant id matches none of these → the route 404s and
|
||||
never reveals that it exists for someone else."""
|
||||
if any(r.get("id") == pid for r in worklog.load(org)):
|
||||
return True
|
||||
try:
|
||||
running, pending = server.prompt_queue.get_current_queue_volatile()
|
||||
if any(it[1] == pid and (it[3] or {}).get("org_id") == org
|
||||
for it in list(running) + list(pending)):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return _in_render_jobs(org, pid)
|
||||
|
||||
|
||||
def _cancel_job(server, org: str, pid: str) -> str:
|
||||
"""Cancel an OWNED job across both realms (caller must have checked ownership).
|
||||
Local pod queue: delete a pending item, or interrupt a running one — both
|
||||
org-scoped so one tenant can't touch another's render. BYO-GPU: drop the
|
||||
tracking row (a queued gpu-job stops showing; one already rendering on the
|
||||
worker may still finish — the pod can't stop a remote process). Marks the work
|
||||
log cancelled. Returns the realm acted on."""
|
||||
realm = "gone"
|
||||
try:
|
||||
running, pending = server.prompt_queue.get_current_queue_volatile()
|
||||
except Exception:
|
||||
running, pending = [], []
|
||||
if any(it[1] == pid and (it[3] or {}).get("org_id") == org for it in pending):
|
||||
server.prompt_queue.delete_queue_item(
|
||||
lambda it: it[1] == pid and (it[3] or {}).get("org_id") == org)
|
||||
realm = "local"
|
||||
elif any(it[1] == pid and (it[3] or {}).get("org_id") == org for it in running):
|
||||
try:
|
||||
import nodes
|
||||
nodes.interrupt_processing()
|
||||
realm = "local"
|
||||
except Exception:
|
||||
pass
|
||||
if _remove_render_job(org, pid) and realm == "gone":
|
||||
realm = "gpu"
|
||||
worklog.set_status(org, pid, "cancelled")
|
||||
return realm
|
||||
|
||||
|
||||
def _landed_output(root: Path | None, prefix: str) -> str | None:
|
||||
"""Library-relative path of the newest file a SaveImage prefix produced, or None
|
||||
if it hasn't landed yet. Lets the work-log report done + wire lineage edges."""
|
||||
if root is None or not prefix:
|
||||
return None
|
||||
try:
|
||||
parent = root / os.path.dirname(prefix)
|
||||
stem = os.path.basename(prefix)
|
||||
# Match SaveImage's exact "<prefix>_<counter>_.png" — NOT a loose "<prefix>*"
|
||||
# which also matches a CHILD prefix ("a" would swallow "a_00001__00001_"),
|
||||
# collapsing distinct lineage steps onto one output.
|
||||
pat = re.compile(r"^" + re.escape(stem) + r"_\d+_\.png$")
|
||||
hits = [p for p in parent.glob(stem + "_*_.png") if pat.match(p.name)]
|
||||
hits.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
if hits:
|
||||
return str(hits[0].relative_to(root))
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def _dispatch(request, server, org: str, graph: dict, *, kind: str, prompt: str,
|
||||
refs: list, uploads: list, parents: list, output_prefix: str) -> str:
|
||||
"""The ONE place a render is queued AND recorded. On success writes a `queued`
|
||||
work-log row (lane detected); on failure writes a `failed` row with the reason —
|
||||
so a dispatch is NEVER invisible, whatever lane it took or however it failed."""
|
||||
common = dict(kind=kind, prompt=prompt, refs=refs, uploads=uploads,
|
||||
parents=parents, output_prefix=output_prefix, params=_graph_params(graph))
|
||||
try:
|
||||
pid = await _queue_prompt(request, server, graph)
|
||||
except DispatchError as e:
|
||||
worklog.record(org, status="failed", error=str(e), **common)
|
||||
raise
|
||||
lane = "gpu" if _in_render_jobs(org, pid) else "local"
|
||||
node = _render_job_node(org, pid) if lane == "gpu" else "studio pod"
|
||||
worklog.record(org, pid=pid, status="queued", lane=lane, node=node, **common)
|
||||
return pid
|
||||
|
||||
|
||||
async def dispatch_fix(request: web.Request, server, org: str, rel: str,
|
||||
instruction: str, upload: str | None = None) -> dict:
|
||||
"""Run the proven Qwen single-image edit. The base image is EITHER a library
|
||||
asset (``rel``, staged into the org input dir) OR an image the caller just
|
||||
uploaded via ``/upload/image`` (``upload``, already resident there) — so a fresh
|
||||
photo can be edited without first landing it in the library. The ONE fix path:
|
||||
the /v1/library/fix route and the chat `create` capability both call this."""
|
||||
instruction = (instruction or "").strip()
|
||||
if not 3 <= len(instruction) <= 500:
|
||||
raise DispatchError("instruction required (3-500 chars)")
|
||||
in_dir = Path(folder_paths.get_org_input_directory(org))
|
||||
in_dir.mkdir(parents=True, exist_ok=True)
|
||||
if upload:
|
||||
names = _resolve_uploads(org, [upload])
|
||||
if not names:
|
||||
raise DispatchError(f"unknown upload: {upload}")
|
||||
ref_name = names[0]
|
||||
stem = re.sub(r"[^a-zA-Z0-9_-]", "_", Path(ref_name).stem)[:48]
|
||||
else:
|
||||
root = _library_root(org)
|
||||
p = _safe_asset(root, rel) if root else None
|
||||
if p is None or p.suffix.lower() != ".png":
|
||||
raise DispatchError(f"unknown asset: {rel}")
|
||||
ref_name = f"fix_src_{abs(hash((str(p), p.stat().st_mtime))) % 10**10}.png"
|
||||
(in_dir / ref_name).write_bytes(p.read_bytes())
|
||||
stem = re.sub(r"[^a-zA-Z0-9_-]", "_", p.stem)[:48]
|
||||
pid = await _dispatch(request, server, org, _fix_graph(ref_name, instruction, f"fixes/{stem}"),
|
||||
kind="fix", prompt=instruction, refs=[ref_name],
|
||||
uploads=[upload] if upload else [], parents=[] if upload else [rel],
|
||||
output_prefix=f"fixes/{stem}")
|
||||
return {"ok": True, "prompt_id": pid, "output_prefix": f"fixes/{stem}"}
|
||||
|
||||
|
||||
async def dispatch_compose(request: web.Request, server, org: str, paths: list,
|
||||
instruction: str, uploads: list | None = None) -> dict:
|
||||
"""Stage 2-5 references and run the multi-input Qwen graph. References are
|
||||
library assets (``paths``, staged into the org input dir) and/or images the
|
||||
caller just uploaded via ``/upload/image`` (``uploads``, already resident in
|
||||
that dir). Shared by the /v1/library/compose route, the Fix surface's
|
||||
add-reference flow, and the chat `create` capability."""
|
||||
instruction = (instruction or "").strip()
|
||||
if not 3 <= len(instruction) <= 500:
|
||||
raise DispatchError("instruction required (3-500 chars)")
|
||||
paths = paths or []
|
||||
uploaded = _resolve_uploads(org, uploads)
|
||||
if not 2 <= len(paths) + len(uploaded) <= 5:
|
||||
raise DispatchError("select 2-5 references to compose")
|
||||
root = _library_root(org)
|
||||
in_dir = Path(folder_paths.get_org_input_directory(org))
|
||||
in_dir.mkdir(parents=True, exist_ok=True)
|
||||
ref_names = []
|
||||
for rel in paths:
|
||||
p = _safe_asset(root, rel) if root else None
|
||||
if p is None or p.suffix.lower() != ".png":
|
||||
raise DispatchError(f"unknown asset: {rel}")
|
||||
nm = f"compose_src_{abs(hash((str(p), p.stat().st_mtime))) % 10**10}.png"
|
||||
(in_dir / nm).write_bytes(p.read_bytes())
|
||||
ref_names.append(nm)
|
||||
ref_names.extend(uploaded)
|
||||
labels = [Path(x).stem for x in paths] + [Path(u).stem for u in uploaded]
|
||||
stem = "compose_" + "_".join(re.sub(r"[^a-zA-Z0-9]", "", s)[:12] for s in labels[:2])
|
||||
pid = await _dispatch(request, server, org, _compose_graph(ref_names, instruction, f"composes/{stem}"),
|
||||
kind="compose", prompt=instruction, refs=ref_names,
|
||||
uploads=uploaded, parents=list(paths), output_prefix=f"composes/{stem}")
|
||||
return {"ok": True, "prompt_id": pid, "output_prefix": f"composes/{stem}", "refs": len(ref_names)}
|
||||
|
||||
|
||||
class NotOwned(DispatchError):
|
||||
"""The prompt_id isn't the caller's — routes map this to 404 (never reveals it)."""
|
||||
|
||||
|
||||
async def dispatch_amend(request: web.Request, server, org: str, prompt_id: str,
|
||||
instruction: str, paths: list | None = None,
|
||||
uploads: list | None = None) -> dict:
|
||||
"""Amend a still-queued job as an honest ATOMIC SWAP: queued graphs are
|
||||
immutable, so cancel the original and re-dispatch with the amended prompt and an
|
||||
augmented reference set. The original's references (already staged in the org
|
||||
input dir, read from its work-log row — the authoritative ownership record) are
|
||||
kept and combined with any new library picks (``paths``) and uploads
|
||||
(``uploads``). The re-dispatch goes through the ONE funnel, so it lands in the
|
||||
right lane and records a fresh work-log row. Position resets (it re-enters the
|
||||
queue) — the caller SHOWS that honestly."""
|
||||
instruction = (instruction or "").strip()
|
||||
if not 3 <= len(instruction) <= 500:
|
||||
raise DispatchError("instruction required (3-500 chars)")
|
||||
orig = next((r for r in worklog.load(org) if r.get("id") == prompt_id), None)
|
||||
if orig is None:
|
||||
raise NotOwned(f"no such job: {prompt_id}")
|
||||
if orig.get("status") in ("done", "failed", "cancelled"):
|
||||
raise DispatchError("this job already finished — start a new fix instead")
|
||||
root = _library_root(org)
|
||||
in_dir = Path(folder_paths.get_org_input_directory(org))
|
||||
in_dir.mkdir(parents=True, exist_ok=True)
|
||||
# keep the original references that are still present (already input-dir names)
|
||||
ref_names = _resolve_uploads(org, orig.get("refs") or [])
|
||||
new_uploads = _resolve_uploads(org, uploads)
|
||||
for rel in (paths or []):
|
||||
p = _safe_asset(root, rel) if root else None
|
||||
if p is None or p.suffix.lower() != ".png":
|
||||
raise DispatchError(f"unknown asset: {rel}")
|
||||
nm = f"amend_src_{abs(hash((str(p), p.stat().st_mtime))) % 10**10}.png"
|
||||
(in_dir / nm).write_bytes(p.read_bytes())
|
||||
ref_names.append(nm)
|
||||
# de-dupe, keep order, cap at the graph's 5
|
||||
seen: set = set()
|
||||
refs = [n for n in ref_names + new_uploads if not (n in seen or seen.add(n))][:5]
|
||||
if not refs:
|
||||
raise DispatchError("no reference images available to amend")
|
||||
_cancel_job(server, org, prompt_id)
|
||||
parents = list(orig.get("parents") or []) + list(paths or [])
|
||||
stem = re.sub(r"[^a-zA-Z0-9_-]", "_", Path(refs[0]).stem)[:40] + "_amended"
|
||||
if len(refs) == 1:
|
||||
graph, kind, out = _fix_graph(refs[0], instruction, f"fixes/{stem}"), "fix", f"fixes/{stem}"
|
||||
else:
|
||||
graph, kind, out = _compose_graph(refs, instruction, f"composes/{stem}"), "compose", f"composes/{stem}"
|
||||
all_uploads = list(dict.fromkeys(list(orig.get("uploads") or []) + new_uploads))
|
||||
pid = await _dispatch(request, server, org, graph, kind=kind, prompt=instruction,
|
||||
refs=refs, uploads=all_uploads, parents=parents, output_prefix=out)
|
||||
return {"ok": True, "prompt_id": pid, "amended_from": prompt_id,
|
||||
"output_prefix": out, "refs": len(refs)}
|
||||
|
||||
|
||||
def _workflows(org: str) -> list[str]:
|
||||
"""Saved workflow files for the org (any user), for the Pipelines tab."""
|
||||
base = Path(folder_paths.get_user_directory())
|
||||
roots = [base / "orgs" / org / "user", base / "default"]
|
||||
seen: set[str] = set()
|
||||
for root in roots:
|
||||
if not root.is_dir():
|
||||
continue
|
||||
for wf in root.rglob("workflows/*/*.json"):
|
||||
seen.add(str(wf).split("workflows/", 1)[1])
|
||||
for wf in root.rglob("workflows/*.json"):
|
||||
seen.add(str(wf).split("workflows/", 1)[1])
|
||||
return sorted(seen)
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def home_redirect(request: web.Request, handler):
|
||||
"""Deployed default: land on the content view. The editor stays one click
|
||||
away (?advanced=1) and every non-root path is untouched."""
|
||||
if (_home_default() and request.path == "/" and request.method == "GET"
|
||||
and "advanced" not in request.query
|
||||
and "text/html" in request.headers.get("Accept", "")):
|
||||
raise web.HTTPFound("/studio")
|
||||
return await handler(request)
|
||||
|
||||
|
||||
def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
|
||||
|
||||
@routes.get("/studio")
|
||||
async def studio_home(request: web.Request):
|
||||
# no-store: the SPA HTML changes every release; without this, browsers
|
||||
# (and any proxy) heuristically cache it and serve a STALE page after a
|
||||
# deploy — which is why UI fixes (e.g. delete) appeared "not working" to
|
||||
# a user still running yesterday's cached studio_home.html. The page is
|
||||
# tiny; always serve fresh. Static assets keep their own long cache.
|
||||
return web.Response(text=_HTML.read_text(), content_type="text/html",
|
||||
headers={"Cache-Control": "no-store, must-revalidate"})
|
||||
|
||||
@routes.get("/v1/library")
|
||||
async def get_library(request: web.Request):
|
||||
org = _org_of(request)
|
||||
root = _library_root(org)
|
||||
if root is None:
|
||||
return web.json_response({"org": org, "assets": [], "workflows": _workflows(org),
|
||||
"note": "no library.json for this org yet"})
|
||||
lib = _load_library(root)
|
||||
assets = []
|
||||
for a in lib.get("assets", []):
|
||||
p = _safe_asset(root, a.get("path", ""))
|
||||
if p is None:
|
||||
continue
|
||||
row = dict(a)
|
||||
row["view"] = _view_params(p)
|
||||
row["mtime"] = int(p.stat().st_mtime)
|
||||
if p.suffix.lower() == ".png":
|
||||
row["prov"] = _provenance(p)
|
||||
assets.append(row)
|
||||
return web.json_response({"org": org, "assets": assets, "workflows": _workflows(org)})
|
||||
|
||||
@routes.get("/v1/library/file")
|
||||
async def get_asset_file(request: web.Request):
|
||||
# Org-scoped asset serving. The Studio engine's core /view serves the
|
||||
# GLOBAL output dir, so org assets (orgs/<org>/output/…) 404 there → blank
|
||||
# thumbnails. This resolves the caller's org and serves from its library root.
|
||||
# ?thumb=1 → a cached, downsized JPEG (grid loads full-res 1-2MB PNGs one by
|
||||
# one otherwise, which makes the whole page crawl). Full-res on click/zoom.
|
||||
org = _org_of(request)
|
||||
root = _library_root(org)
|
||||
# ?input=1 → a staged reference in orgs/<org>/input (fix/compose sources),
|
||||
# so YouTube-style queue rows can show the referenced work as a thumbnail.
|
||||
if request.query.get("input"):
|
||||
in_dir = Path(folder_paths.get_org_input_directory(org))
|
||||
name = os.path.basename(request.query.get("path", ""))
|
||||
ip = in_dir / name
|
||||
if not name or not ip.is_file() or not str(ip.resolve()).startswith(str(in_dir.resolve())):
|
||||
return web.Response(status=404)
|
||||
p = ip
|
||||
else:
|
||||
p = _safe_asset(root, request.query.get("path", "")) if root else None
|
||||
if p is None:
|
||||
return web.Response(status=404)
|
||||
if request.query.get("thumb") and p.suffix.lower() in (".png", ".jpg", ".jpeg", ".webp"):
|
||||
try:
|
||||
tdir = root / ".thumbs"
|
||||
tdir.mkdir(exist_ok=True)
|
||||
key = re.sub(r"[^a-zA-Z0-9]", "_", str(p))[-120:]
|
||||
tp = tdir / f"{key}.jpg"
|
||||
if not tp.is_file() or tp.stat().st_mtime < p.stat().st_mtime:
|
||||
from PIL import Image
|
||||
im = Image.open(p)
|
||||
im.thumbnail((480, 720)) # grid-sized; keeps aspect
|
||||
im.convert("RGB").save(tp, "JPEG", quality=82)
|
||||
return web.FileResponse(tp, headers={"Cache-Control": "public, max-age=86400"})
|
||||
except Exception:
|
||||
pass # fall through to full-res on any thumbnail failure
|
||||
return web.FileResponse(p)
|
||||
|
||||
@routes.get("/v1/gpu-status")
|
||||
async def gpu_status(request: web.Request):
|
||||
"""Does the caller's org have an online GPU to render on? The UI showed
|
||||
'connect a GPU' even with a GPU connected because it checked /v1/engines
|
||||
(the worker-federation) — but `hanzo gpu connect` registers with the FLEET
|
||||
(gpu-jobs), which is exactly what dispatch uses. Report the SAME source of
|
||||
truth as dispatch (_has_online_gpu) so the UI matches what actually works."""
|
||||
try:
|
||||
from middleware.gpu_dispatch import _bearer, _has_online_gpu
|
||||
except ImportError:
|
||||
from .middleware.gpu_dispatch import _bearer, _has_online_gpu
|
||||
try:
|
||||
tok = _bearer(request)
|
||||
online = bool(tok and _has_online_gpu(tok))
|
||||
except Exception:
|
||||
online = False
|
||||
return web.json_response({"online": online})
|
||||
|
||||
@routes.get("/v1/render-queue")
|
||||
async def render_queue(request: web.Request):
|
||||
"""The org's IN-FLIGHT BYO-GPU renders (fix/compose/rerun dispatched to a
|
||||
connected GPU). These run on the worker's gpu-jobs queue, NOT the pod's
|
||||
local /queue, so the UI could not see them — this is what makes a queued
|
||||
fix visible. Drops entries whose output has landed in the library or that
|
||||
have aged out (~25 min). Cheap: reads one small per-org JSON."""
|
||||
org = _org_of(request)
|
||||
root = _library_root(org)
|
||||
if root is None:
|
||||
return web.json_response({"jobs": []})
|
||||
jf = root / "render_jobs.json"
|
||||
try:
|
||||
jobs = json.loads(jf.read_text())
|
||||
except Exception:
|
||||
jobs = []
|
||||
now = int(time.time())
|
||||
live = []
|
||||
for j in jobs:
|
||||
age = now - int(j.get("ts", 0))
|
||||
if age > 1500: # aged out (worker deadline is 1200s)
|
||||
continue
|
||||
# Completed only when the ACTUAL output for THIS job exists: the render
|
||||
# saves to "<outPrefix>_NNNNN_.png". Check precisely (glob the exact
|
||||
# output prefix) — not a fuzzy base match, which collided with the source
|
||||
# image and made the job disappear from the queue after 30s.
|
||||
out_prefix = j.get("outPrefix") or j.get("prefix") or ""
|
||||
landed = False
|
||||
if out_prefix:
|
||||
try:
|
||||
parent = root / os.path.dirname(out_prefix)
|
||||
stem = os.path.basename(out_prefix)
|
||||
landed = any(parent.glob(stem + "*.png"))
|
||||
except Exception:
|
||||
landed = False
|
||||
if landed and age > 20:
|
||||
continue
|
||||
j["age"] = age
|
||||
live.append(j)
|
||||
return web.json_response({"jobs": live})
|
||||
|
||||
@routes.post("/v1/library/status")
|
||||
async def set_status(request: web.Request):
|
||||
body = await request.json()
|
||||
rel, status = body.get("path", ""), body.get("status", "")
|
||||
if status not in _STATUSES:
|
||||
return web.json_response({"error": f"status must be one of {_STATUSES}"}, status=400)
|
||||
org = _org_of(request)
|
||||
root = _library_root(org)
|
||||
if root is None or _safe_asset(root, rel) is None:
|
||||
return web.json_response({"error": "unknown asset"}, status=404)
|
||||
lib = _load_library(root)
|
||||
for a in lib.get("assets", []):
|
||||
if a.get("path") == rel:
|
||||
a["status"] = status
|
||||
a["updatedAt"] = int(time.time())
|
||||
_save_library(root, lib)
|
||||
return web.json_response({"ok": True, "path": rel, "status": status})
|
||||
return web.json_response({"error": "asset not in library"}, status=404)
|
||||
|
||||
@routes.post("/v1/library/rerun")
|
||||
async def rerun(request: web.Request):
|
||||
body = await request.json()
|
||||
org = _org_of(request)
|
||||
root = _library_root(org)
|
||||
p = _safe_asset(root, body.get("path", "")) if root else None
|
||||
if p is None or p.suffix.lower() != ".png":
|
||||
return web.json_response({"error": "unknown asset"}, status=404)
|
||||
try:
|
||||
from PIL import Image
|
||||
graph = json.loads(Image.open(p).info.get("prompt", "null"))
|
||||
except Exception:
|
||||
graph = None
|
||||
if not isinstance(graph, dict):
|
||||
return web.json_response({"error": "no embedded workflow in this image"}, status=422)
|
||||
rel = body.get("path", "")
|
||||
out_prefix = ""
|
||||
instr = ""
|
||||
for n in graph.values():
|
||||
if not isinstance(n, dict):
|
||||
continue
|
||||
if n.get("class_type") == "SaveImage":
|
||||
out_prefix = n.get("inputs", {}).get("filename_prefix", "") or out_prefix
|
||||
elif "TextEncode" in n.get("class_type", "") and not instr:
|
||||
t = n.get("inputs", {}).get("prompt") or n.get("inputs", {}).get("text") or ""
|
||||
if t and t != _NEG:
|
||||
instr = t
|
||||
ids = []
|
||||
try:
|
||||
for _ in range(max(1, min(int(body.get("count", 1)), 4))):
|
||||
ids.append(await _dispatch(
|
||||
request, server, org, _reseed(json.loads(json.dumps(graph))),
|
||||
kind="rerun", prompt=instr, refs=[], uploads=[], parents=[rel],
|
||||
output_prefix=out_prefix))
|
||||
except DispatchError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
return web.json_response({"ok": True, "prompt_ids": ids})
|
||||
|
||||
@routes.post("/v1/queue/cancel")
|
||||
async def cancel_job(request: web.Request):
|
||||
"""Cancel one of the caller's OWN queued/running jobs (local pod queue or
|
||||
BYO-GPU lane). Ownership is resolved server-side; a prompt_id that isn't the
|
||||
caller's 404s — never confirming another tenant's job exists."""
|
||||
org = _org_of(request)
|
||||
pid = (await request.json()).get("prompt_id", "")
|
||||
if not pid:
|
||||
return web.json_response({"error": "prompt_id required"}, status=400)
|
||||
if not _owns_job(server, org, pid):
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
return web.json_response({"ok": True, "canceled": _cancel_job(server, org, pid)})
|
||||
|
||||
@routes.post("/v1/queue/amend")
|
||||
async def amend_job(request: web.Request):
|
||||
"""Amend a still-queued job (atomic cancel + re-dispatch with more context)."""
|
||||
body = await request.json()
|
||||
try:
|
||||
return web.json_response(await dispatch_amend(
|
||||
request, server, _org_of(request), body.get("prompt_id", ""),
|
||||
body.get("instruction", ""), body.get("paths"), body.get("uploads")))
|
||||
except NotOwned as e:
|
||||
return web.json_response({"error": str(e)}, status=404)
|
||||
except DispatchError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
|
||||
@routes.get("/v1/queue/status")
|
||||
async def queue_status(request: web.Request):
|
||||
"""This org's LIVE queue with where + when: per-lane position N of M, the
|
||||
node each job runs on, elapsed for the running item, and an ETA computed
|
||||
server-side from the org's OWN measured render medians. Tenant-scoped — only
|
||||
the caller's active jobs and their positions. Cheap: reads the org work log,
|
||||
stamps started/finished as it observes them, no fleet call."""
|
||||
org = _org_of(request)
|
||||
root = _library_root(org)
|
||||
now = int(time.time())
|
||||
rows = worklog.load(org)
|
||||
# finalize any job whose output has landed (real finish time = file mtime)
|
||||
for r in rows:
|
||||
if r.get("status") in ("done", "failed", "cancelled"):
|
||||
continue
|
||||
landed = _landed_output(root, r.get("output_prefix"))
|
||||
if landed:
|
||||
mt = _output_mtime(root, landed) or now
|
||||
worklog.mark_finished(org, r["id"], mt)
|
||||
r["status"], r["finished_at"] = "done", mt # mirror in-memory
|
||||
active = [r for r in rows if r.get("status") not in ("done", "failed", "cancelled")
|
||||
and now - int(r.get("ts", now)) <= 1800]
|
||||
# the oldest job in each lane is the one rendering — stamp its start once
|
||||
by_lane: dict[str, list[dict]] = {}
|
||||
for r in active:
|
||||
by_lane.setdefault(r.get("lane") or "local", []).append(r)
|
||||
for jobs in by_lane.values():
|
||||
head = min(jobs, key=lambda j: j.get("ts", 0))
|
||||
if not head.get("started_at"):
|
||||
worklog.mark_started(org, head["id"], now)
|
||||
head["started_at"] = now
|
||||
head["status"] = "running"
|
||||
medians = worklog.median_durations(rows)
|
||||
est, counts = worklog.estimate_lanes(active, medians, now)
|
||||
items = {}
|
||||
for r in active:
|
||||
items[r["id"]] = {**est.get(r["id"], {}), "id": r["id"], "kind": r.get("kind", ""),
|
||||
"prompt": r.get("prompt", ""), "status": r.get("status"),
|
||||
"node": r.get("node")}
|
||||
return web.json_response({"items": items, "medians": medians, "lanes": counts, "now": now})
|
||||
|
||||
@routes.get("/v1/nodes")
|
||||
async def get_nodes(request: web.Request):
|
||||
"""The caller-org's GPU nodes (online/offline) for the queue header badges,
|
||||
plus this studio pod. The fleet is token-scoped, so only the caller's org's
|
||||
nodes are ever returned."""
|
||||
try:
|
||||
from middleware.gpu_dispatch import _bearer, _fleet_machines, _node_label
|
||||
except ImportError:
|
||||
from .middleware.gpu_dispatch import _bearer, _fleet_machines, _node_label
|
||||
nodes = []
|
||||
try:
|
||||
tok = _bearer(request)
|
||||
for m in (_fleet_machines(tok) if tok else []):
|
||||
if not m.get("gpu"):
|
||||
continue
|
||||
nodes.append({"name": _node_label(m), "online": m.get("status") == "online"})
|
||||
except Exception:
|
||||
nodes = []
|
||||
return web.json_response({"nodes": nodes, "pod": {"name": "studio pod", "online": True}})
|
||||
|
||||
@routes.get("/v1/worklog")
|
||||
async def get_worklog(request: web.Request):
|
||||
"""This org's dispatched renders (fix/compose/rerun) with REAL status — the
|
||||
queue/history backbone. Reverse-chronological; ?q= filters over prompt+kind.
|
||||
Server-resolved org: a caller only ever sees its OWN log file."""
|
||||
org = _org_of(request)
|
||||
root = _library_root(org)
|
||||
rows = worklog.load(org)
|
||||
out = []
|
||||
for r in reversed(rows):
|
||||
row = dict(r)
|
||||
landed = _landed_output(root, r.get("output_prefix"))
|
||||
row["output"] = landed
|
||||
if landed and r.get("status") not in ("failed", "cancelled"):
|
||||
row["status"] = "done"
|
||||
out.append(row)
|
||||
q = (request.query.get("q") or "").lower().strip()
|
||||
if q:
|
||||
out = [r for r in out if q in (r.get("prompt", "") or "").lower() or q in (r.get("kind", "") or "")]
|
||||
return web.json_response({"org": org, "items": out[:200]})
|
||||
|
||||
@routes.get("/v1/worklog/lineage")
|
||||
async def get_lineage(request: web.Request):
|
||||
"""The version chain for one library asset — ancestors (what it was derived
|
||||
from, with the prompt at each step) and descendants (later fixes of it).
|
||||
Pure over THIS org's rows; cross-tenant lineage is impossible by construction."""
|
||||
org = _org_of(request)
|
||||
root = _library_root(org)
|
||||
target = request.query.get("path", "")
|
||||
rows = worklog.load(org)
|
||||
for r in rows:
|
||||
r["output"] = _landed_output(root, r.get("output_prefix"))
|
||||
return web.json_response(worklog.build_lineage(rows, target))
|
||||
|
||||
@routes.post("/v1/library/fix")
|
||||
async def fix(request: web.Request):
|
||||
body = await request.json()
|
||||
try:
|
||||
return web.json_response(await dispatch_fix(
|
||||
request, server, _org_of(request), body.get("path", ""),
|
||||
body.get("instruction", ""), body.get("upload")))
|
||||
except DispatchError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
|
||||
@routes.post("/v1/library/compose")
|
||||
async def compose(request: web.Request):
|
||||
"""Select N references (library assets and/or freshly uploaded images) + say
|
||||
HOW to combine them → a new generated output. The multi-input case of the
|
||||
proven Qwen reference-conditioned graph."""
|
||||
body = await request.json()
|
||||
try:
|
||||
return web.json_response(await dispatch_compose(
|
||||
request, server, _org_of(request), body.get("paths") or [],
|
||||
body.get("instruction", ""), body.get("uploads")))
|
||||
except DispatchError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
|
||||
# ── Ratings + notes: the curation feedback loop (HIP-0506 §5) ──────────────
|
||||
# Stored per-org in orgs/<org>/output/ratings.json keyed by asset path OR run
|
||||
# prompt_id. 0-5 stars + free notes; the copilot reads these before assembling
|
||||
# a workflow so generations get closer to intent each pass.
|
||||
@routes.get("/v1/library/ratings")
|
||||
async def get_ratings(request: web.Request):
|
||||
root = _library_root(_org_of(request))
|
||||
if root is None:
|
||||
return web.json_response({})
|
||||
try:
|
||||
return web.json_response(json.loads((root / "ratings.json").read_text()))
|
||||
except Exception:
|
||||
return web.json_response({})
|
||||
|
||||
@routes.post("/v1/library/rate")
|
||||
async def rate(request: web.Request):
|
||||
body = await request.json()
|
||||
key = (body.get("key") or "").strip() # asset path or run prompt_id
|
||||
if not key:
|
||||
return web.json_response({"error": "key required"}, status=400)
|
||||
stars = body.get("stars")
|
||||
if stars is not None and not (isinstance(stars, int) and 0 <= stars <= 5):
|
||||
return web.json_response({"error": "stars must be 0-5"}, status=400)
|
||||
root = _library_root(_org_of(request))
|
||||
if root is None:
|
||||
return web.json_response({"error": "no library for org"}, status=404)
|
||||
rf = root / "ratings.json"
|
||||
try:
|
||||
data = json.loads(rf.read_text())
|
||||
except Exception:
|
||||
data = {}
|
||||
entry = data.get(key, {})
|
||||
if stars is not None:
|
||||
entry["stars"] = stars
|
||||
if "notes" in body:
|
||||
entry["notes"] = str(body.get("notes") or "")[:2000]
|
||||
if body.get("favorite") is not None:
|
||||
entry["favorite"] = bool(body.get("favorite"))
|
||||
entry["updatedAt"] = int(time.time())
|
||||
data[key] = entry
|
||||
tmp = root / "ratings.json.tmp"
|
||||
tmp.write_text(json.dumps(data, indent=1))
|
||||
tmp.replace(rf)
|
||||
return web.json_response({"ok": True, "key": key, "entry": entry})
|
||||
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
SqlitePromptQueue — Hanzo Studio's crash-durable render queue.
|
||||
|
||||
Drop-in for execution.PromptQueue, backed by a single SQLite file (stdlib
|
||||
sqlite3, WAL). When STUDIO_QUEUE_DB is set, render jobs survive a process
|
||||
crash: pending and in-flight prompts live in the DB, and a killed render is
|
||||
rescheduled for another claim instead of being lost. Zero external processes.
|
||||
|
||||
The PromptQueue seam maps straight onto a durable job state machine:
|
||||
|
||||
put(item) → INSERT job (pending) # idempotent on prompt_id
|
||||
get(timeout) → claim next pending (claimed, leased)
|
||||
task_done(...success) → job → done
|
||||
task_done(...error) → job → pending (retry) | failed (per max attempts)
|
||||
|
||||
The existing prompt_worker thread is the claim loop unchanged: it calls get()
|
||||
(claim) and task_done() (report). A background heartbeat renews the lease of
|
||||
the in-flight job while the render runs; if this process dies, the lease
|
||||
expires and the reap step (run on every get() and by the heartbeat thread)
|
||||
returns the job to `pending` so it is re-claimed. A render retry is
|
||||
idempotent — SaveImage suffixes increment.
|
||||
|
||||
Multiple Studio processes pointing at the same STUDIO_QUEUE_DB compete safely:
|
||||
claim runs under BEGIN IMMEDIATE, so exactly one process claims each job.
|
||||
|
||||
History and flags stay in the in-memory base (the Studio UI reads them); only
|
||||
the queue mechanics become durable. This supersedes STUDIO_PERSIST_QUEUE (JSON
|
||||
snapshot) as the local durable path; that remains the zero-dep fallback.
|
||||
Standard library only — no new hard dependency.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
|
||||
from execution import PromptQueue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_QUEUE = "studio-render"
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS render_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
queue TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
state TEXT NOT NULL, -- pending|claimed|done|failed
|
||||
attempt INTEGER NOT NULL DEFAULT 1,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 3,
|
||||
worker TEXT,
|
||||
lease_expiry REAL,
|
||||
result TEXT,
|
||||
cause TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_render_jobs_ready ON render_jobs(queue, state, created_at);
|
||||
"""
|
||||
|
||||
|
||||
def serialize_item(item: tuple) -> str:
|
||||
"""Render-queue tuple → JSON job payload."""
|
||||
number, prompt_id, prompt, extra_data, outputs, sensitive = item
|
||||
return json.dumps({
|
||||
"number": number,
|
||||
"prompt_id": prompt_id,
|
||||
"prompt": prompt,
|
||||
"extra_data": extra_data,
|
||||
"outputs": outputs,
|
||||
"sensitive": sensitive,
|
||||
})
|
||||
|
||||
|
||||
def deserialize_item(payload: str) -> tuple:
|
||||
"""JSON job payload → render-queue tuple (inverse of serialize_item)."""
|
||||
d = json.loads(payload)
|
||||
return (
|
||||
d.get("number", 0),
|
||||
d["prompt_id"],
|
||||
d["prompt"],
|
||||
d.get("extra_data") or {},
|
||||
d.get("outputs") or [],
|
||||
d.get("sensitive") or {},
|
||||
)
|
||||
|
||||
|
||||
def _worker_identity() -> str:
|
||||
return os.environ.get("STUDIO_WORKER_ID") or f"{socket.gethostname()}-{os.getpid()}"
|
||||
|
||||
|
||||
class SqlitePromptQueue(PromptQueue):
|
||||
"""PromptQueue whose durability lives in a SQLite file."""
|
||||
|
||||
def __init__(self, server, db_path: str | None = None):
|
||||
super().__init__(server)
|
||||
# SQLite is the durable backend — disable the JSON snapshot path and
|
||||
# drop anything a stray STUDIO_PERSIST_QUEUE restore left behind
|
||||
# (precedence: SQLite > PERSIST > memory).
|
||||
self._persist_path = None
|
||||
self.queue = []
|
||||
|
||||
self.db_path = db_path or os.environ["STUDIO_QUEUE_DB"]
|
||||
self.queue_name = os.environ.get("STUDIO_TASKS_QUEUE", DEFAULT_QUEUE)
|
||||
self.worker_id = _worker_identity()
|
||||
self.max_attempts = int(os.environ.get("STUDIO_TASKS_MAX_ATTEMPTS", "3"))
|
||||
self.lease_s = float(os.environ.get("STUDIO_TASKS_LEASE_MS", "60000")) / 1000.0
|
||||
self.poll_interval = float(os.environ.get("STUDIO_TASKS_POLL_INTERVAL", "1.0"))
|
||||
|
||||
os.makedirs(os.path.dirname(os.path.abspath(self.db_path)), exist_ok=True)
|
||||
self._db_lock = threading.Lock()
|
||||
self._conn = sqlite3.connect(self.db_path, check_same_thread=False, isolation_level=None)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._conn.execute("PRAGMA busy_timeout=5000")
|
||||
self._conn.executescript(_SCHEMA)
|
||||
|
||||
# item_id → job_id so task_done reports to the right row.
|
||||
self._job_by_item: dict[int, str] = {}
|
||||
self._pending = 0
|
||||
self._reap() # recover leases orphaned by a previous crash
|
||||
|
||||
logger.info(
|
||||
"[sqlite-queue] durable render queue: db=%s queue=%s worker=%s lease=%.0fs",
|
||||
self.db_path, self.queue_name, self.worker_id, self.lease_s,
|
||||
)
|
||||
|
||||
self._hb_interval = max(self.lease_s / 3.0, 2.0)
|
||||
self._hb_stop = threading.Event()
|
||||
self._hb_thread = threading.Thread(target=self._heartbeat_loop, daemon=True)
|
||||
self._hb_thread.start()
|
||||
|
||||
# ── producer: /prompt → durable job ──────────────────────────────
|
||||
def put(self, item):
|
||||
prompt_id = item[1]
|
||||
now = time.time()
|
||||
with self._db_lock:
|
||||
self._conn.execute(
|
||||
"INSERT OR IGNORE INTO render_jobs"
|
||||
"(id, queue, payload, state, attempt, max_attempts, created_at, updated_at)"
|
||||
" VALUES(?,?,?,'pending',1,?,?,?)",
|
||||
(prompt_id, self.queue_name, serialize_item(item), self.max_attempts, now, now),
|
||||
)
|
||||
self.server.queue_updated()
|
||||
|
||||
# ── consumer: prompt_worker → claim ──────────────────────────────
|
||||
def get(self, timeout=None):
|
||||
deadline = None if timeout is None else time.monotonic() + timeout
|
||||
while True:
|
||||
self._reap()
|
||||
claimed = self._claim()
|
||||
if claimed is not None:
|
||||
return claimed
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
return None
|
||||
wait = self.poll_interval
|
||||
if deadline is not None:
|
||||
wait = min(wait, max(deadline - time.monotonic(), 0.0))
|
||||
if self._hb_stop.wait(wait):
|
||||
return None
|
||||
|
||||
def _claim(self):
|
||||
now = time.time()
|
||||
with self._db_lock:
|
||||
self._conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
row = self._conn.execute(
|
||||
"SELECT id, payload FROM render_jobs"
|
||||
" WHERE queue=? AND state='pending' ORDER BY created_at LIMIT 1",
|
||||
(self.queue_name,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
self._conn.execute("COMMIT")
|
||||
return None
|
||||
job_id, payload = row
|
||||
self._conn.execute(
|
||||
"UPDATE render_jobs SET state='claimed', worker=?, lease_expiry=?, updated_at=?"
|
||||
" WHERE id=?",
|
||||
(self.worker_id, now + self.lease_s, now, job_id),
|
||||
)
|
||||
self._conn.execute("COMMIT")
|
||||
except Exception:
|
||||
self._conn.execute("ROLLBACK")
|
||||
raise
|
||||
try:
|
||||
item = deserialize_item(payload)
|
||||
except Exception as e: # corrupt payload — fail terminally, don't loop.
|
||||
logger.error("[sqlite-queue] undecodable job %s: %s", job_id, e)
|
||||
self._terminal(job_id, "failed", cause=f"undecodable payload: {e}")
|
||||
return None
|
||||
with self.mutex:
|
||||
item_id = self.task_counter
|
||||
self.currently_running[item_id] = copy.deepcopy(item)
|
||||
self._job_by_item[item_id] = job_id
|
||||
self.task_counter += 1
|
||||
self.server.queue_updated()
|
||||
return (item, item_id)
|
||||
|
||||
# ── completion → durable state ───────────────────────────────────
|
||||
def task_done(self, item_id, history_result, status, process_item=None):
|
||||
with self.mutex:
|
||||
job_id = self._job_by_item.pop(item_id, None)
|
||||
if job_id is not None:
|
||||
success = status is None or status.status_str == "success"
|
||||
if success:
|
||||
self._terminal(job_id, "done",
|
||||
result=json.dumps({"outputs": _summarize_outputs(history_result)}))
|
||||
else:
|
||||
cause = "; ".join(status.messages) if status and status.messages else "render error"
|
||||
self._fail(job_id, cause)
|
||||
super().task_done(item_id, history_result, status, process_item)
|
||||
|
||||
def _terminal(self, job_id, state, result=None, cause=None):
|
||||
with self._db_lock:
|
||||
self._conn.execute(
|
||||
"UPDATE render_jobs SET state=?, result=?, cause=?, worker=NULL,"
|
||||
" lease_expiry=NULL, updated_at=? WHERE id=?",
|
||||
(state, result, cause, time.time(), job_id),
|
||||
)
|
||||
|
||||
def _fail(self, job_id, cause):
|
||||
# Retry-aware: another attempt if the policy allows, else terminal.
|
||||
with self._db_lock:
|
||||
self._conn.execute(
|
||||
"UPDATE render_jobs SET"
|
||||
" attempt = attempt + 1,"
|
||||
" state = CASE WHEN attempt + 1 > max_attempts THEN 'failed' ELSE 'pending' END,"
|
||||
" worker=NULL, lease_expiry=NULL, cause=?, updated_at=?"
|
||||
" WHERE id=? AND state='claimed'",
|
||||
(cause, time.time(), job_id),
|
||||
)
|
||||
|
||||
# ── crash-recovery: reclaim expired leases ───────────────────────
|
||||
def _reap(self):
|
||||
now = time.time()
|
||||
with self._db_lock:
|
||||
self._conn.execute(
|
||||
"UPDATE render_jobs SET"
|
||||
" attempt = attempt + 1,"
|
||||
" state = CASE WHEN attempt + 1 > max_attempts THEN 'failed' ELSE 'pending' END,"
|
||||
" cause = CASE WHEN attempt + 1 > max_attempts THEN 'lease expired' ELSE cause END,"
|
||||
" worker=NULL, lease_expiry=NULL, updated_at=?"
|
||||
" WHERE state='claimed' AND lease_expiry IS NOT NULL AND lease_expiry < ?",
|
||||
(now, now),
|
||||
)
|
||||
|
||||
# ── heartbeat / pending refresh ──────────────────────────────────
|
||||
def _heartbeat_loop(self):
|
||||
while not self._hb_stop.wait(self._hb_interval):
|
||||
now = time.time()
|
||||
with self.mutex:
|
||||
inflight = list(self._job_by_item.values())
|
||||
if inflight:
|
||||
with self._db_lock:
|
||||
for job_id in inflight:
|
||||
self._conn.execute(
|
||||
"UPDATE render_jobs SET lease_expiry=?, updated_at=?"
|
||||
" WHERE id=? AND state='claimed'",
|
||||
(now + self.lease_s, now, job_id),
|
||||
)
|
||||
self._reap()
|
||||
with self._db_lock:
|
||||
(self._pending,) = self._conn.execute(
|
||||
"SELECT COUNT(*) FROM render_jobs WHERE queue=? AND state='pending'",
|
||||
(self.queue_name,),
|
||||
).fetchone()
|
||||
|
||||
def get_tasks_remaining(self):
|
||||
with self.mutex:
|
||||
return len(self.currently_running) + self._pending
|
||||
|
||||
def job_state(self, job_id: str):
|
||||
"""Diagnostic: current (state, attempt) of a job, or None."""
|
||||
with self._db_lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT state, attempt FROM render_jobs WHERE id=?", (job_id,)
|
||||
).fetchone()
|
||||
return row
|
||||
|
||||
def stop(self):
|
||||
self._hb_stop.set()
|
||||
with self._db_lock:
|
||||
self._conn.close()
|
||||
|
||||
|
||||
def _summarize_outputs(history_result) -> list:
|
||||
"""Extract output filenames from an execution's history_result."""
|
||||
files = []
|
||||
try:
|
||||
outputs = (history_result or {}).get("outputs", {})
|
||||
for node_output in outputs.values():
|
||||
for key in ("images", "gifs", "audio", "video"):
|
||||
for entry in node_output.get(key, []) or []:
|
||||
name = entry.get("filename")
|
||||
if name:
|
||||
files.append({"filename": name, "subfolder": entry.get("subfolder", ""),
|
||||
"type": entry.get("type", "output")})
|
||||
except Exception:
|
||||
pass
|
||||
return files
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Per-org work log — the ONE record of every render this org dispatched.
|
||||
|
||||
The single dispatch funnel (``studio_home.dispatch_fix`` / ``dispatch_compose`` /
|
||||
``rerun``) writes one row here at dispatch time, so the studio surface can show:
|
||||
|
||||
* the queue/history with REAL status — queued · running · done · failed ·
|
||||
cancelled — including gpu-lane jobs the local /queue can't see (that was the
|
||||
'nothing showed as queued' report) and dispatches that failed before queuing;
|
||||
* the full context that produced each item — prompt, reference images/uploads,
|
||||
the workflow family (fix / compose / …), params, and the process trail;
|
||||
* parent → child lineage: each derived asset records the library asset(s) it was
|
||||
derived from, so any item's versions (ancestors + later fixes) form a chain.
|
||||
|
||||
Storage is one JSON array per org at ``orgs/<org>/output/worklog.json`` — the same
|
||||
per-org metadata pattern as ``library.json`` / ``render_jobs.json`` / ``ratings.json``
|
||||
(atomic tmp+replace). No new database. No backfill: history starts when this ships;
|
||||
items generated earlier have no recorded row (and derived items no recorded parent).
|
||||
|
||||
Tenant isolation: every function takes an already-resolved ``org`` (the caller's
|
||||
IAM org, resolved server-side by ``studio_home._org_of``). A row lives only in its
|
||||
org's file, so one org can never read or mutate another's log.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import folder_paths
|
||||
|
||||
# Cap the file so a busy org can't grow it without bound; oldest rows drop off.
|
||||
_CAP = 1000
|
||||
_TERMINAL = ("done", "failed", "cancelled")
|
||||
|
||||
|
||||
def _dir(org: str) -> Path:
|
||||
return Path(folder_paths.get_org_output_directory(org))
|
||||
|
||||
|
||||
def _path(org: str) -> Path:
|
||||
return _dir(org) / "worklog.json"
|
||||
|
||||
|
||||
def load(org: str) -> list[dict]:
|
||||
try:
|
||||
rows = json.loads(_path(org).read_text())
|
||||
return rows if isinstance(rows, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _save(org: str, rows: list[dict]) -> None:
|
||||
d = _dir(org)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
p = _path(org)
|
||||
tmp = p.with_name(p.name + ".tmp")
|
||||
tmp.write_text(json.dumps(rows[-_CAP:]))
|
||||
tmp.replace(p)
|
||||
|
||||
|
||||
def record(org: str, *, kind: str, prompt: str, refs: list, uploads: list,
|
||||
parents: list, output_prefix: str, params: dict | None = None,
|
||||
pid: str | None = None, lane: str | None = None, node: str | None = None,
|
||||
status: str = "queued", error: str | None = None) -> dict:
|
||||
"""Append one dispatch row. ``pid`` is the /prompt id on success (used to match
|
||||
live queue/history); a failed dispatch has no id, so a synthetic one is minted
|
||||
and the row is marked failed with the reason so it STILL shows (never silent)."""
|
||||
now = int(time.time())
|
||||
row: dict = {
|
||||
"id": pid or f"failed-{uuid.uuid4().hex[:12]}",
|
||||
"ts": now,
|
||||
"kind": kind,
|
||||
"prompt": (prompt or "")[:800],
|
||||
"refs": [str(x) for x in (refs or [])][:8],
|
||||
"uploads": [str(x) for x in (uploads or [])][:8],
|
||||
"parents": [str(x) for x in (parents or [])][:8],
|
||||
"output_prefix": output_prefix or "",
|
||||
"params": params or {},
|
||||
"lane": lane or ("failed" if status == "failed" else "local"),
|
||||
"node": node or ("studio pod" if (lane in (None, "local")) else "gpu"),
|
||||
"status": status,
|
||||
"updatedAt": now,
|
||||
"events": [{"s": "queued", "ts": now}],
|
||||
}
|
||||
if error:
|
||||
row["error"] = str(error)[:400]
|
||||
row["events"].append({"s": "failed", "ts": now, "note": row["error"]})
|
||||
rows = load(org)
|
||||
rows.append(row)
|
||||
_save(org, rows)
|
||||
return row
|
||||
|
||||
|
||||
def set_status(org: str, pid: str, status: str, note: str | None = None) -> bool:
|
||||
"""Advance a row's status by prompt_id (running/done/failed/cancelled). Returns
|
||||
True if a row was updated. Idempotent-safe; terminal rows are left untouched."""
|
||||
if not pid:
|
||||
return False
|
||||
rows = load(org)
|
||||
hit = False
|
||||
now = int(time.time())
|
||||
for r in rows:
|
||||
if r.get("id") == pid and r.get("status") not in _TERMINAL:
|
||||
r["status"] = status
|
||||
r["updatedAt"] = now
|
||||
r.setdefault("events", []).append(
|
||||
{"s": status, "ts": now, **({"note": note} if note else {})})
|
||||
hit = True
|
||||
if hit:
|
||||
_save(org, rows)
|
||||
return hit
|
||||
|
||||
|
||||
def mark_started(org: str, pid: str, at: int) -> bool:
|
||||
"""Stamp when a job first begins rendering (first seen at the head of its lane).
|
||||
Idempotent: only the first observation sets started_at. Feeds duration = finished
|
||||
− started, the basis of the ETA median."""
|
||||
if not pid:
|
||||
return False
|
||||
rows = load(org)
|
||||
hit = False
|
||||
for r in rows:
|
||||
if r.get("id") == pid and not r.get("started_at") and r.get("status") not in _TERMINAL:
|
||||
r["started_at"] = int(at)
|
||||
r["status"] = "running"
|
||||
r["updatedAt"] = int(time.time())
|
||||
r.setdefault("events", []).append({"s": "running", "ts": int(at)})
|
||||
hit = True
|
||||
if hit:
|
||||
_save(org, rows)
|
||||
return hit
|
||||
|
||||
|
||||
def mark_finished(org: str, pid: str, at: int) -> bool:
|
||||
"""Stamp real completion (the output file's mtime) and close the row as done."""
|
||||
if not pid:
|
||||
return False
|
||||
rows = load(org)
|
||||
hit = False
|
||||
for r in rows:
|
||||
if r.get("id") == pid and r.get("status") not in _TERMINAL:
|
||||
r["finished_at"] = int(at)
|
||||
r["status"] = "done"
|
||||
r["updatedAt"] = int(time.time())
|
||||
r.setdefault("events", []).append({"s": "done", "ts": int(at)})
|
||||
hit = True
|
||||
if hit:
|
||||
_save(org, rows)
|
||||
return hit
|
||||
|
||||
|
||||
def _duration(r: dict) -> float | None:
|
||||
"""Measured render seconds for a completed row: finished − started when both
|
||||
exist (true render time), else finished − queued (turnaround, an upper bound)."""
|
||||
fin = r.get("finished_at")
|
||||
if fin is None:
|
||||
return None
|
||||
start = r.get("started_at")
|
||||
if start is None:
|
||||
start = r.get("ts")
|
||||
if start is None:
|
||||
return None
|
||||
return float(fin - start) if fin > start else None
|
||||
|
||||
|
||||
def median_durations(rows: list[dict]) -> dict:
|
||||
"""Rolling median render seconds per kind (fix/compose/rerun) over completed
|
||||
rows — the ETA basis. Pure. Only kinds with a measured sample appear."""
|
||||
buckets: dict[str, list[float]] = {}
|
||||
for r in rows:
|
||||
d = _duration(r)
|
||||
if d and d > 0:
|
||||
buckets.setdefault(r.get("kind", ""), []).append(d)
|
||||
out: dict[str, int] = {}
|
||||
for k, v in buckets.items():
|
||||
v.sort()
|
||||
n = len(v)
|
||||
out[k] = int(v[n // 2] if n % 2 else (v[n // 2 - 1] + v[n // 2]) / 2)
|
||||
return out
|
||||
|
||||
|
||||
def estimate_lanes(active: list[dict], medians: dict, now: int,
|
||||
default_median: int = 180) -> tuple[dict, dict]:
|
||||
"""Pure position + ETA math, per lane. ``active`` items are
|
||||
``{id, kind, lane, ts, status, started_at?}``. Within a lane the running item
|
||||
(head) is position 1; each queued item's wait ≈ jobs-ahead × their medians +
|
||||
the running item's remaining time (median − elapsed). ETA to done = wait +
|
||||
its own median. Returns (per-id info, per-lane count). No client math over
|
||||
another tenant's data — the caller passes only THIS org's active jobs."""
|
||||
lanes: dict[str, list[dict]] = {}
|
||||
for j in active:
|
||||
lanes.setdefault(j.get("lane") or "local", []).append(j)
|
||||
info: dict[str, dict] = {}
|
||||
counts: dict[str, int] = {}
|
||||
for lane, jobs in lanes.items():
|
||||
jobs.sort(key=lambda j: (0 if j.get("status") == "running" else 1, j.get("ts", 0)))
|
||||
counts[lane] = len(jobs)
|
||||
cum = 0.0 # seconds until the START of the current item
|
||||
for idx, j in enumerate(jobs):
|
||||
m = medians.get(j.get("kind", ""), default_median)
|
||||
row = {"lane": lane, "position": idx + 1, "of": len(jobs),
|
||||
"node": j.get("node") or ("studio pod" if lane == "local" else "gpu")}
|
||||
if idx == 0 and j.get("status") == "running":
|
||||
started = j.get("started_at") or j.get("ts") or now
|
||||
elapsed = max(0, now - started)
|
||||
remaining = max(0.0, m - elapsed)
|
||||
row.update(elapsed_seconds=int(elapsed), wait_seconds=0,
|
||||
remaining_seconds=int(remaining), eta_seconds=int(remaining))
|
||||
cum = remaining
|
||||
else:
|
||||
row.update(elapsed_seconds=0, wait_seconds=int(cum),
|
||||
remaining_seconds=None, eta_seconds=int(cum + m))
|
||||
cum += m
|
||||
info[j["id"]] = row
|
||||
return info, counts
|
||||
|
||||
|
||||
def build_lineage(rows: list[dict], target: str) -> dict:
|
||||
"""Pure: given work-log rows (each with ``parents`` = source library paths and
|
||||
``output`` = the landed library path, resolved by the caller) and a target
|
||||
library asset path, return its version chain:
|
||||
|
||||
{"ancestors": [older … immediate parent], "node": <target>,
|
||||
"descendants": [later derivations, chronological]}
|
||||
|
||||
Each entry is ``{path, prompt, kind, ts}``. Ancestors walk the first-parent edge
|
||||
up from the row that PRODUCED the target; descendants are rows that name the
|
||||
target among their parents (and, transitively, their outputs). Cycles and
|
||||
missing rows terminate the walk — never loops."""
|
||||
by_output: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
out = r.get("output")
|
||||
if out and out not in by_output:
|
||||
by_output[out] = r
|
||||
|
||||
def entry(path: str, r: dict | None) -> dict:
|
||||
return {
|
||||
"path": path,
|
||||
"prompt": (r or {}).get("prompt", ""),
|
||||
"kind": (r or {}).get("kind", ""),
|
||||
"ts": (r or {}).get("ts", 0),
|
||||
}
|
||||
|
||||
# Ancestors: from the row that produced `target`, follow its first parent up.
|
||||
ancestors: list[dict] = []
|
||||
seen = {target}
|
||||
cur = target
|
||||
while True:
|
||||
producer = by_output.get(cur)
|
||||
if not producer:
|
||||
break
|
||||
parents = producer.get("parents") or []
|
||||
if not parents:
|
||||
break
|
||||
parent = parents[0]
|
||||
if parent in seen:
|
||||
break
|
||||
seen.add(parent)
|
||||
ancestors.append(entry(parent, by_output.get(parent)))
|
||||
cur = parent
|
||||
ancestors.reverse() # oldest first
|
||||
|
||||
# Descendants: rows that derive FROM target, then rows deriving from those, …
|
||||
descendants: list[dict] = []
|
||||
frontier = [target]
|
||||
seen_out = {target}
|
||||
while frontier:
|
||||
nxt: list[str] = []
|
||||
kids = [r for r in rows if any(p in frontier for p in (r.get("parents") or []))]
|
||||
kids.sort(key=lambda r: r.get("ts", 0))
|
||||
for r in kids:
|
||||
out = r.get("output")
|
||||
key = out or r.get("id")
|
||||
if key in seen_out:
|
||||
continue
|
||||
seen_out.add(key)
|
||||
descendants.append(entry(out or "", r))
|
||||
if out:
|
||||
nxt.append(out)
|
||||
frontier = nxt
|
||||
|
||||
return {"ancestors": ancestors, "node": target, "descendants": descendants}
|
||||
@@ -2258,9 +2258,14 @@ async def load_custom_node(module_path: str, ignore=set(), module_parent="custom
|
||||
if hasattr(module, "NODE_DISPLAY_NAME_MAPPINGS") and getattr(module, "NODE_DISPLAY_NAME_MAPPINGS") is not None:
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(module.NODE_DISPLAY_NAME_MAPPINGS)
|
||||
return True
|
||||
# V3 Extension Definition
|
||||
elif hasattr(module, "studio_entrypoint"):
|
||||
entrypoint = getattr(module, "studio_entrypoint")
|
||||
# V3 Extension Definition. Honor the upstream ComfyUI `comfy_entrypoint`
|
||||
# name too, so vendored packs register through the V3 extension API
|
||||
# unmodified (their ComfyExtension/io.ComfyNode are aliased to the
|
||||
# Studio* classes by studio_compat).
|
||||
elif hasattr(module, "studio_entrypoint") or hasattr(module, "comfy_entrypoint"):
|
||||
entrypoint = getattr(module, "studio_entrypoint", None)
|
||||
if entrypoint is None:
|
||||
entrypoint = getattr(module, "comfy_entrypoint")
|
||||
if not callable(entrypoint):
|
||||
logging.warning(f"studio_entrypoint in {module_path} is not callable, skipping.")
|
||||
return False
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "hanzo-studio"
|
||||
version = "0.14.1"
|
||||
version = "0.17.3"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
requires-python = ">=3.10"
|
||||
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# install_custom_nodes.sh -- vendor the Hanzo Studio custom-node packs.
|
||||
#
|
||||
# Reads custom_nodes/hanzo-packs.txt, clones each pack at its pinned commit (git,
|
||||
# idempotent), then installs their hand-resolved Python deps
|
||||
# (custom_nodes-requirements.txt) with the studio torch/numpy/transformers stack
|
||||
# LOCKED so nothing gets downgraded. The lock is DERIVED FROM THE LIVE ENV (not a
|
||||
# hardcoded file), so it is correct in both contexts:
|
||||
# - Docker image build (CPU torch): STUDIO_PY=python pip install
|
||||
# - Local uv venv (GB10, cu130 torch):
|
||||
# STUDIO_PY=.venv/bin/python \
|
||||
# STUDIO_PIP="uv pip install --python .venv/bin/python" \
|
||||
# scripts/install_custom_nodes.sh
|
||||
#
|
||||
# Upstream `comfy.*` imports are aliased to `studio.*` at boot by studio_compat,
|
||||
# so packs run unmodified. Idempotent; safe to re-run to reconcile pins.
|
||||
set -euo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CN="$REPO/custom_nodes"
|
||||
MANIFEST="$CN/hanzo-packs.txt"
|
||||
REQS="$REPO/custom_nodes-requirements.txt"
|
||||
PY="${STUDIO_PY:-python}"
|
||||
PIP="${STUDIO_PIP:-python -m pip install}"
|
||||
CGEN="$(mktemp)"
|
||||
trap 'rm -f "$CGEN"' EXIT
|
||||
|
||||
log() { printf '[packs] %s\n' "$*"; }
|
||||
|
||||
# 0. Derive a constraints file locking the studio-critical packages to whatever
|
||||
# versions are ALREADY installed, so pack deps can never move the torch stack,
|
||||
# NumPy 2.x or Transformers 5.x. A dep that is incompatible surfaces as a
|
||||
# resolver error instead of silently breaking the runtime.
|
||||
"$PY" - > "$CGEN" <<'PY'
|
||||
import importlib.metadata as md
|
||||
CRIT = ["torch", "torchvision", "torchaudio", "torchsde", "numpy",
|
||||
"transformers", "tokenizers", "pillow", "scipy", "timm",
|
||||
"safetensors", "huggingface-hub"]
|
||||
for name in CRIT:
|
||||
try:
|
||||
print(f"{name}=={md.version(name)}")
|
||||
except md.PackageNotFoundError:
|
||||
pass
|
||||
PY
|
||||
log "locked stack:"; sed 's/^/[packs] /' "$CGEN"
|
||||
|
||||
# 1. Clone / pin every pack from the manifest (comment + blank lines skipped),
|
||||
# then apply any local compat patches (scripts/patches/<pack>-*.patch). The
|
||||
# forced checkout discards previously-applied patches so the pin stays
|
||||
# authoritative and re-runs are idempotent.
|
||||
log "reconciling packs from $MANIFEST"
|
||||
while read -r name url sha _rest; do
|
||||
case "$name" in ''|\#*) continue ;; esac
|
||||
dir="$CN/$name"
|
||||
if [ -d "$dir/.git" ]; then
|
||||
git -C "$dir" fetch -q origin "$sha" 2>/dev/null || git -C "$dir" fetch -q --all --tags
|
||||
else
|
||||
git clone -q --filter=blob:none "$url" "$dir"
|
||||
fi
|
||||
git -C "$dir" checkout -qf "$sha"
|
||||
log "pinned $name @ $(git -C "$dir" rev-parse --short HEAD)"
|
||||
for p in "$REPO/scripts/patches/$name"-*.patch; do
|
||||
[ -f "$p" ] || continue
|
||||
git -C "$dir" apply "$p"
|
||||
log "applied $(basename "$p")"
|
||||
done
|
||||
done < "$MANIFEST"
|
||||
|
||||
# 2. Install pack dependencies with the derived stack lock.
|
||||
log "installing pack dependencies"
|
||||
$PIP -c "$CGEN" -r "$REQS"
|
||||
|
||||
# 3. ultralytics (AGPL detectors for Impact-Subpack): --no-deps so it cannot pull
|
||||
# the non-headless opencv-python that would fight opencv-*-headless.
|
||||
log "installing ultralytics (--no-deps)"
|
||||
$PIP -c "$CGEN" --no-deps ultralytics
|
||||
|
||||
log "done -- $(grep -cvE '^\s*(#|$)' "$MANIFEST") packs pinned"
|
||||
Executable
+223
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env python3
|
||||
"""library_manifest.py — regenerate the ONE canonical asset-library index.
|
||||
|
||||
The Karma content pipeline has a single source of truth: a `library.json`
|
||||
manifest that sits at the root of an org's studio-output subtree (any tenant; pass --root) and
|
||||
indexes every asset Antje has rendered. The storefront (karma.style) and the
|
||||
socials/blog queue both read THIS file — there is no second index.
|
||||
|
||||
library root (== S3 prefix s3://hanzo-studio/orgs/karma/output/
|
||||
== studio pod /app/orgs/karma/output/
|
||||
== spark local /home/z/work/hanzo/studio/output/orgs/karma/output/)
|
||||
designs/<slug>/<kind>_<role>.png catalog assets (ecom|product|lifestyle|hover)
|
||||
marketing/<channel>/<name>.png queue assets (social|blog|campaign)
|
||||
library.json <- this file, regenerated here
|
||||
|
||||
What this does, and nothing more:
|
||||
* walk the tree, classify each image by path -> {design, kind, role}
|
||||
* PRESERVE status/caption/tags from the existing library.json (keyed by path)
|
||||
* brand-new files enter as status="draft"; deleted files drop out
|
||||
* write library.json atomically (tmp + rename) so a reader never sees a
|
||||
half-written file, and the S3 mirror sidecar carries it up on its next pass
|
||||
|
||||
It writes ONLY library.json. It never touches images and never regenerates
|
||||
products.json (that file carries human-curated pricing/copy the site owns).
|
||||
|
||||
Runs one-shot, or with --watch as the studio pod's `build-manifest` sidecar.
|
||||
Pure stdlib — no PIL, no deps — so it runs in the studio image or anywhere.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
|
||||
# Written marketing content (blog posts, campaign briefs, social ad copy) is
|
||||
# first-class in the ONE index too — a .md under marketing/ is a queue asset
|
||||
# whose body is the post/brief and whose caption/hashtags(=tags) are set with
|
||||
# karma-queue.py. Restricted to marketing/ so catalog stays image-only.
|
||||
MARKETING_TEXT_EXTS = {".md"}
|
||||
MANIFEST_NAME = "library.json"
|
||||
|
||||
# The kinds a catalog asset may carry (designs/<slug>/<kind>_<role>.png). The
|
||||
# storefront consumes ecom + product + lifestyle; hover is a card-hover alt.
|
||||
CATALOG_KINDS = {"ecom", "product", "lifestyle", "hover"}
|
||||
# Marketing channels double as the kind for marketing/<channel>/* assets.
|
||||
MARKETING_KINDS = {"social", "blog", "campaign"}
|
||||
|
||||
|
||||
def iso(ts: float) -> str:
|
||||
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def classify(relpath: str, slugs: set[str]) -> dict | None:
|
||||
"""Map a path relative to the library root -> {design, kind, role}.
|
||||
|
||||
Returns None for anything that is not a manifestable asset.
|
||||
"""
|
||||
parts = relpath.split("/")
|
||||
stem = os.path.splitext(parts[-1])[0]
|
||||
|
||||
if parts[0] == "marketing" and len(parts) >= 3:
|
||||
channel = parts[1]
|
||||
kind = channel if channel in MARKETING_KINDS else "campaign"
|
||||
# design = leading slug token if it names a real design, else cross-cut.
|
||||
head, _, rest = stem.partition("_")
|
||||
if head in slugs and rest:
|
||||
design, role = head, rest
|
||||
else:
|
||||
design, role = None, stem
|
||||
return {"design": design, "kind": kind, "role": role}
|
||||
|
||||
if parts[0] == "designs" and len(parts) >= 3:
|
||||
# 4k/print masters live alongside but are not catalog/queue items.
|
||||
if "4k" in parts[2:-1]:
|
||||
return None
|
||||
design = parts[1]
|
||||
kind, sep, role = stem.partition("_")
|
||||
if not sep: # e.g. bare "front.png" -> treat stem as role, kind unknown
|
||||
kind, role = "other", stem
|
||||
if kind not in CATALOG_KINDS:
|
||||
kind = kind if kind in ("other",) else "other"
|
||||
return {"design": design, "kind": kind, "role": role}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def scan(root: str) -> list[str]:
|
||||
"""All manifestable image paths (relative to root), sorted for stable diffs."""
|
||||
found: list[str] = []
|
||||
for base, _dirs, files in os.walk(root):
|
||||
for f in files:
|
||||
ext = os.path.splitext(f)[1].lower()
|
||||
full = os.path.join(base, f)
|
||||
rel = os.path.relpath(full, root)
|
||||
is_image = ext in IMAGE_EXTS
|
||||
is_mktg_text = (ext in MARKETING_TEXT_EXTS
|
||||
and rel.replace(os.sep, "/").startswith("marketing/"))
|
||||
if not (is_image or is_mktg_text):
|
||||
continue
|
||||
try:
|
||||
if os.path.getsize(full) == 0: # skip a render caught mid-write
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
found.append(rel)
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def load_prior(path: str) -> dict[str, dict]:
|
||||
"""Existing entries keyed by path, so status/caption/tags survive a rebuild."""
|
||||
try:
|
||||
with open(path, "r") as fh:
|
||||
doc = json.load(fh)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
return {a["path"]: a for a in doc.get("assets", []) if "path" in a}
|
||||
|
||||
|
||||
def build(root: str) -> dict:
|
||||
manifest_path = os.path.join(root, MANIFEST_NAME)
|
||||
prior = load_prior(manifest_path)
|
||||
|
||||
designs_dir = os.path.join(root, "designs")
|
||||
slugs = {d for d in os.listdir(designs_dir)} if os.path.isdir(designs_dir) else set()
|
||||
|
||||
assets: list[dict] = []
|
||||
for rel in scan(root):
|
||||
norm = rel.replace(os.sep, "/")
|
||||
if norm == MANIFEST_NAME:
|
||||
continue
|
||||
info = classify(norm, slugs)
|
||||
if info is None:
|
||||
continue
|
||||
prev = prior.get(norm, {})
|
||||
entry = {
|
||||
"path": norm,
|
||||
"design": info["design"],
|
||||
"kind": info["kind"],
|
||||
"role": info["role"],
|
||||
"status": prev.get("status", "draft"),
|
||||
"tags": prev.get("tags", []),
|
||||
"updatedAt": iso(os.path.getmtime(os.path.join(root, rel))),
|
||||
}
|
||||
caption = prev.get("caption")
|
||||
if caption:
|
||||
entry["caption"] = caption
|
||||
assets.append(entry)
|
||||
|
||||
by_status: dict[str, int] = {}
|
||||
by_kind: dict[str, int] = {}
|
||||
for a in assets:
|
||||
by_status[a["status"]] = by_status.get(a["status"], 0) + 1
|
||||
by_kind[a["kind"]] = by_kind.get(a["kind"], 0) + 1
|
||||
|
||||
return {
|
||||
"_meta": {
|
||||
"note": "Canonical Karma asset library. ONE index the storefront + "
|
||||
"socials queue read. Regenerated by library_manifest.py; edit "
|
||||
"status/caption/tags with karma-queue.py (they are preserved).",
|
||||
"prefix": "orgs/karma/output",
|
||||
"generatedAt": iso(time.time()),
|
||||
"count": len(assets),
|
||||
"byStatus": by_status,
|
||||
"byKind": by_kind,
|
||||
},
|
||||
"assets": assets,
|
||||
}
|
||||
|
||||
|
||||
def write_atomic(root: str, doc: dict) -> str:
|
||||
manifest_path = os.path.join(root, MANIFEST_NAME)
|
||||
tmp = os.path.join(root, ".library.json.tmp")
|
||||
with open(tmp, "w") as fh:
|
||||
json.dump(doc, fh, indent=2, ensure_ascii=False)
|
||||
fh.write("\n")
|
||||
os.replace(tmp, manifest_path)
|
||||
return manifest_path
|
||||
|
||||
|
||||
def run_once(root: str, quiet: bool = False, strict: bool = True) -> dict | None:
|
||||
if not os.path.isdir(root):
|
||||
msg = f"library_manifest: root does not exist yet: {root}"
|
||||
if strict:
|
||||
sys.exit(msg)
|
||||
if not quiet:
|
||||
print(msg, file=sys.stderr)
|
||||
return None # watch mode: tolerate a tenant with no renders yet
|
||||
doc = build(root)
|
||||
path = write_atomic(root, doc)
|
||||
if not quiet:
|
||||
m = doc["_meta"]
|
||||
print(f"library_manifest: {m['count']} assets -> {path} {m['byStatus']}")
|
||||
return doc
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="Regenerate the Karma library.json manifest.")
|
||||
ap.add_argument("--root", default=os.environ.get(
|
||||
"KARMA_LIBRARY_ROOT", "/app/orgs/karma/output"),
|
||||
help="Library root (contains designs/ and marketing/).")
|
||||
ap.add_argument("--watch", action="store_true", help="Loop forever.")
|
||||
ap.add_argument("--interval", type=int, default=30, help="--watch seconds.")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not args.watch:
|
||||
run_once(args.root)
|
||||
return
|
||||
|
||||
print(f"library_manifest: watching {args.root} every {args.interval}s", flush=True)
|
||||
while True:
|
||||
try:
|
||||
run_once(args.root, quiet=True, strict=False)
|
||||
except Exception as e: # a bad cycle must never kill the sidecar
|
||||
print(f"library_manifest: cycle error: {e}", file=sys.stderr, flush=True)
|
||||
time.sleep(args.interval)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,262 @@
|
||||
diff --git i/local_groundingdino/models/GroundingDINO/bertwarper.py w/local_groundingdino/models/GroundingDINO/bertwarper.py
|
||||
index e209a39..2a87d42 100644
|
||||
--- i/local_groundingdino/models/GroundingDINO/bertwarper.py
|
||||
+++ w/local_groundingdino/models/GroundingDINO/bertwarper.py
|
||||
@@ -7,22 +7,23 @@
|
||||
|
||||
import torch
|
||||
from torch import nn
|
||||
-from transformers.modeling_outputs import BaseModelOutputWithPoolingAndCrossAttentions
|
||||
|
||||
|
||||
class BertModelWarper(nn.Module):
|
||||
+ """Thin delegating wrapper around a HuggingFace ``BertModel``.
|
||||
+
|
||||
+ GroundingDINO feeds BERT a custom 3D ``[bs, seq, seq]`` self-attention mask
|
||||
+ plus explicit ``position_ids``. Modern ``transformers`` handle both natively,
|
||||
+ so we delegate to the real ``BertModel.forward`` instead of reimplementing
|
||||
+ BERT internals (whose private call signatures drift between releases). The
|
||||
+ fine-tuned checkpoint weights are remapped ``bert.*`` -> ``bert.bert_model.*``
|
||||
+ at load time (see ``load_groundingdino_model`` in node.py).
|
||||
+ """
|
||||
+
|
||||
def __init__(self, bert_model):
|
||||
super().__init__()
|
||||
- # self.bert = bert_modelc
|
||||
-
|
||||
+ self.bert_model = bert_model
|
||||
self.config = bert_model.config
|
||||
- self.embeddings = bert_model.embeddings
|
||||
- self.encoder = bert_model.encoder
|
||||
- self.pooler = bert_model.pooler
|
||||
-
|
||||
- self.get_extended_attention_mask = bert_model.get_extended_attention_mask
|
||||
- self.invert_attention_mask = bert_model.invert_attention_mask
|
||||
- self.get_head_mask = bert_model.get_head_mask
|
||||
|
||||
def forward(
|
||||
self,
|
||||
@@ -30,135 +31,26 @@ class BertModelWarper(nn.Module):
|
||||
attention_mask=None,
|
||||
token_type_ids=None,
|
||||
position_ids=None,
|
||||
- head_mask=None,
|
||||
- inputs_embeds=None,
|
||||
- encoder_hidden_states=None,
|
||||
- encoder_attention_mask=None,
|
||||
- past_key_values=None,
|
||||
- use_cache=None,
|
||||
- output_attentions=None,
|
||||
output_hidden_states=None,
|
||||
- return_dict=None,
|
||||
+ **kwargs,
|
||||
):
|
||||
- r"""
|
||||
- encoder_hidden_states (:obj:`torch.FloatTensor` of shape :obj:`(batch_size, sequence_length, hidden_size)`, `optional`):
|
||||
- Sequence of hidden-states at the output of the last layer of the encoder. Used in the cross-attention if
|
||||
- the model is configured as a decoder.
|
||||
- encoder_attention_mask (:obj:`torch.FloatTensor` of shape :obj:`(batch_size, sequence_length)`, `optional`):
|
||||
- Mask to avoid performing attention on the padding token indices of the encoder input. This mask is used in
|
||||
- the cross-attention if the model is configured as a decoder. Mask values selected in ``[0, 1]``:
|
||||
-
|
||||
- - 1 for tokens that are **not masked**,
|
||||
- - 0 for tokens that are **masked**.
|
||||
- past_key_values (:obj:`tuple(tuple(torch.FloatTensor))` of length :obj:`config.n_layers` with each tuple having 4 tensors of shape :obj:`(batch_size, num_heads, sequence_length - 1, embed_size_per_head)`):
|
||||
- Contains precomputed key and value hidden states of the attention blocks. Can be used to speed up decoding.
|
||||
-
|
||||
- If :obj:`past_key_values` are used, the user can optionally input only the last :obj:`decoder_input_ids`
|
||||
- (those that don't have their past key value states given to this model) of shape :obj:`(batch_size, 1)`
|
||||
- instead of all :obj:`decoder_input_ids` of shape :obj:`(batch_size, sequence_length)`.
|
||||
- use_cache (:obj:`bool`, `optional`):
|
||||
- If set to :obj:`True`, :obj:`past_key_values` key value states are returned and can be used to speed up
|
||||
- decoding (see :obj:`past_key_values`).
|
||||
- """
|
||||
- output_attentions = (
|
||||
- output_attentions if output_attentions is not None else self.config.output_attentions
|
||||
- )
|
||||
- output_hidden_states = (
|
||||
- output_hidden_states
|
||||
- if output_hidden_states is not None
|
||||
- else self.config.output_hidden_states
|
||||
- )
|
||||
- return_dict = return_dict if return_dict is not None else self.config.use_return_dict
|
||||
-
|
||||
- if self.config.is_decoder:
|
||||
- use_cache = use_cache if use_cache is not None else self.config.use_cache
|
||||
- else:
|
||||
- use_cache = False
|
||||
-
|
||||
- if input_ids is not None and inputs_embeds is not None:
|
||||
- raise ValueError("You cannot specify both input_ids and inputs_embeds at the same time")
|
||||
- elif input_ids is not None:
|
||||
- input_shape = input_ids.size()
|
||||
- batch_size, seq_length = input_shape
|
||||
- elif inputs_embeds is not None:
|
||||
- input_shape = inputs_embeds.size()[:-1]
|
||||
- batch_size, seq_length = input_shape
|
||||
- else:
|
||||
- raise ValueError("You have to specify either input_ids or inputs_embeds")
|
||||
-
|
||||
- device = input_ids.device if input_ids is not None else inputs_embeds.device
|
||||
-
|
||||
- # past_key_values_length
|
||||
- past_key_values_length = (
|
||||
- past_key_values[0][0].shape[2] if past_key_values is not None else 0
|
||||
- )
|
||||
-
|
||||
- if attention_mask is None:
|
||||
- attention_mask = torch.ones(
|
||||
- ((batch_size, seq_length + past_key_values_length)), device=device
|
||||
+ if attention_mask is not None and attention_mask.dim() == 3:
|
||||
+ # GroundingDINO supplies a custom [bs, seq, seq] self-attention mask
|
||||
+ # (True = attend) to block cross-phrase attention. transformers>=5
|
||||
+ # accepts an already-prepared 4D additive float mask
|
||||
+ # [bs, 1, q_len, kv_len] and returns it as-is, so we convert here.
|
||||
+ dtype = self.bert_model.dtype
|
||||
+ keep = attention_mask[:, None, :, :].bool()
|
||||
+ attention_mask = torch.zeros_like(keep, dtype=dtype).masked_fill(
|
||||
+ ~keep, torch.finfo(dtype).min
|
||||
)
|
||||
- if token_type_ids is None:
|
||||
- token_type_ids = torch.zeros(input_shape, dtype=torch.long, device=device)
|
||||
-
|
||||
- # We can provide a self-attention mask of dimensions [batch_size, from_seq_length, to_seq_length]
|
||||
- # ourselves in which case we just need to make it broadcastable to all heads.
|
||||
- extended_attention_mask: torch.Tensor = self.get_extended_attention_mask(
|
||||
- attention_mask, input_shape, device
|
||||
- )
|
||||
-
|
||||
- # If a 2D or 3D attention mask is provided for the cross-attention
|
||||
- # we need to make broadcastable to [batch_size, num_heads, seq_length, seq_length]
|
||||
- if self.config.is_decoder and encoder_hidden_states is not None:
|
||||
- encoder_batch_size, encoder_sequence_length, _ = encoder_hidden_states.size()
|
||||
- encoder_hidden_shape = (encoder_batch_size, encoder_sequence_length)
|
||||
- if encoder_attention_mask is None:
|
||||
- encoder_attention_mask = torch.ones(encoder_hidden_shape, device=device)
|
||||
- encoder_extended_attention_mask = self.invert_attention_mask(encoder_attention_mask)
|
||||
- else:
|
||||
- encoder_extended_attention_mask = None
|
||||
- # if os.environ.get('IPDB_SHILONG_DEBUG', None) == 'INFO':
|
||||
- # import ipdb; ipdb.set_trace()
|
||||
-
|
||||
- # Prepare head mask if needed
|
||||
- # 1.0 in head_mask indicate we keep the head
|
||||
- # attention_probs has shape bsz x n_heads x N x N
|
||||
- # input head_mask has shape [num_heads] or [num_hidden_layers x num_heads]
|
||||
- # and head_mask is converted to shape [num_hidden_layers x batch x num_heads x seq_length x seq_length]
|
||||
- head_mask = self.get_head_mask(head_mask, self.config.num_hidden_layers)
|
||||
-
|
||||
- embedding_output = self.embeddings(
|
||||
+ return self.bert_model(
|
||||
input_ids=input_ids,
|
||||
- position_ids=position_ids,
|
||||
+ attention_mask=attention_mask,
|
||||
token_type_ids=token_type_ids,
|
||||
- inputs_embeds=inputs_embeds,
|
||||
- past_key_values_length=past_key_values_length,
|
||||
- )
|
||||
-
|
||||
- encoder_outputs = self.encoder(
|
||||
- embedding_output,
|
||||
- attention_mask=extended_attention_mask,
|
||||
- head_mask=head_mask,
|
||||
- encoder_hidden_states=encoder_hidden_states,
|
||||
- encoder_attention_mask=encoder_extended_attention_mask,
|
||||
- past_key_values=past_key_values,
|
||||
- use_cache=use_cache,
|
||||
- output_attentions=output_attentions,
|
||||
+ position_ids=position_ids,
|
||||
output_hidden_states=output_hidden_states,
|
||||
- return_dict=return_dict,
|
||||
- )
|
||||
- sequence_output = encoder_outputs[0]
|
||||
- pooled_output = self.pooler(sequence_output) if self.pooler is not None else None
|
||||
-
|
||||
- if not return_dict:
|
||||
- return (sequence_output, pooled_output) + encoder_outputs[1:]
|
||||
-
|
||||
- return BaseModelOutputWithPoolingAndCrossAttentions(
|
||||
- last_hidden_state=sequence_output,
|
||||
- pooler_output=pooled_output,
|
||||
- past_key_values=encoder_outputs.past_key_values,
|
||||
- hidden_states=encoder_outputs.hidden_states,
|
||||
- attentions=encoder_outputs.attentions,
|
||||
- cross_attentions=encoder_outputs.cross_attentions,
|
||||
+ return_dict=True,
|
||||
)
|
||||
|
||||
|
||||
diff --git i/node.py w/node.py
|
||||
index cd88718..0cc48ed 100644
|
||||
--- i/node.py
|
||||
+++ w/node.py
|
||||
@@ -12,7 +12,7 @@ import logging
|
||||
from torch.hub import download_url_to_file
|
||||
from urllib.parse import urlparse
|
||||
import folder_paths
|
||||
-import comfy.model_management
|
||||
+import studio.model_management
|
||||
from sam_hq.predictor import SamPredictorHQ
|
||||
from sam_hq.build_sam_hq import sam_model_registry
|
||||
from local_groundingdino.datasets import transforms as T
|
||||
@@ -84,7 +84,7 @@ def load_sam_model(model_name):
|
||||
if 'hq' not in model_type and 'mobile' not in model_type:
|
||||
model_type = '_'.join(model_type.split('_')[:-1])
|
||||
sam = sam_model_registry[model_type](checkpoint=sam_checkpoint_path)
|
||||
- sam_device = comfy.model_management.get_torch_device()
|
||||
+ sam_device = studio.model_management.get_torch_device()
|
||||
sam.to(device=sam_device)
|
||||
sam.eval()
|
||||
sam.model_name = model_file_name
|
||||
@@ -129,10 +129,17 @@ def load_groundingdino_model(model_name):
|
||||
groundingdino_model_list[model_name]["model_url"],
|
||||
groundingdino_model_dir_name,
|
||||
),
|
||||
+ weights_only=False,
|
||||
)
|
||||
- dino.load_state_dict(local_groundingdino_clean_state_dict(
|
||||
- checkpoint['model']), strict=False)
|
||||
- device = comfy.model_management.get_torch_device()
|
||||
+ state_dict = local_groundingdino_clean_state_dict(checkpoint['model'])
|
||||
+ # BertModelWarper now delegates to a nested `bert_model`; remap the
|
||||
+ # fine-tuned BERT weights `bert.*` -> `bert.bert_model.*` so they load.
|
||||
+ state_dict = {
|
||||
+ (f'bert.bert_model.{k[len("bert."):]}' if k.startswith('bert.') else k): v
|
||||
+ for k, v in state_dict.items()
|
||||
+ }
|
||||
+ dino.load_state_dict(state_dict, strict=False)
|
||||
+ device = studio.model_management.get_torch_device()
|
||||
dino.to(device=device)
|
||||
dino.eval()
|
||||
return dino
|
||||
@@ -164,7 +171,7 @@ def groundingdino_predict(
|
||||
caption = caption.strip()
|
||||
if not caption.endswith("."):
|
||||
caption = caption + "."
|
||||
- device = comfy.model_management.get_torch_device()
|
||||
+ device = studio.model_management.get_torch_device()
|
||||
image = image.to(device)
|
||||
with torch.no_grad():
|
||||
outputs = model(image[None], captions=[caption])
|
||||
@@ -243,7 +250,7 @@ def sam_segment(
|
||||
predictor.set_image(image_np_rgb)
|
||||
transformed_boxes = predictor.transform.apply_boxes_torch(
|
||||
boxes, image_np.shape[:2])
|
||||
- sam_device = comfy.model_management.get_torch_device()
|
||||
+ sam_device = studio.model_management.get_torch_device()
|
||||
masks, _, _ = predictor.predict_torch(
|
||||
point_coords=None,
|
||||
point_labels=None,
|
||||
diff --git i/sam_hq/build_sam_hq.py w/sam_hq/build_sam_hq.py
|
||||
index c8f41f2..153b819 100644
|
||||
--- i/sam_hq/build_sam_hq.py
|
||||
+++ w/sam_hq/build_sam_hq.py
|
||||
@@ -64,7 +64,7 @@ def _load_sam_checkpoint(sam: Sam, checkpoint=None):
|
||||
sam.eval()
|
||||
if checkpoint is not None:
|
||||
with open(checkpoint, "rb") as f:
|
||||
- state_dict = torch.load(f)
|
||||
+ state_dict = torch.load(f, weights_only=False)
|
||||
info = sam.load_state_dict(state_dict, strict=False)
|
||||
print(info)
|
||||
for _, p in sam.named_parameters():
|
||||
@@ -64,6 +64,41 @@ def _remove_sensitive_from_queue(queue: list) -> list:
|
||||
return [item[:5] for item in queue]
|
||||
|
||||
|
||||
# ── Multi-tenant scoping for the inherited core endpoints (HIP-0506 security) ──
|
||||
# /history and /queue are process-global in upstream ComfyUI. In a multi-tenant
|
||||
# deployment that is cross-tenant disclosure (one org reads another's prompts,
|
||||
# graphs, seeds) and a cross-tenant DoS (one org's "clear queue" wipes another's).
|
||||
# Every item carries its owner in extra_data["org_id"] (bound at enqueue). These
|
||||
# filter each list/dict to the caller's org. `org is None` means auth is OFF
|
||||
# (single-tenant/local dev) → return everything unchanged (no behavior change).
|
||||
def _item_org(item) -> str | None:
|
||||
"""org_id from a queue-item tuple (index 3 = extra_data)."""
|
||||
try:
|
||||
return item[3].get("org_id") if len(item) > 3 and isinstance(item[3], dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _scope_queue_to_org(items: list, org: str | None) -> list:
|
||||
if not org:
|
||||
return items
|
||||
return [it for it in items if _item_org(it) == org]
|
||||
|
||||
|
||||
def _scope_history_to_org(hist: dict, org: str | None) -> dict:
|
||||
if not org:
|
||||
return hist
|
||||
out = {}
|
||||
for k, entry in (hist or {}).items():
|
||||
try:
|
||||
ed = entry.get("prompt", [None, None, None, {}])[3]
|
||||
if isinstance(ed, dict) and ed.get("org_id") == org:
|
||||
out[k] = entry
|
||||
except Exception:
|
||||
continue # malformed/legacy entry with no org → excluded (fail-closed)
|
||||
return out
|
||||
|
||||
|
||||
async def send_socket_catch_exception(function, message):
|
||||
try:
|
||||
await function(message)
|
||||
@@ -215,13 +250,22 @@ class PromptServer():
|
||||
self.node_replace_manager = NodeReplaceManager()
|
||||
self.internal_routes = InternalRoutes(self)
|
||||
self.supports = ["custom_nodes_from_web"]
|
||||
self.prompt_queue = execution.PromptQueue(self)
|
||||
# Queue backend precedence: SQLite (durable, zero external process) >
|
||||
# PERSIST (JSON snapshot) > memory. STUDIO_QUEUE_DB opts into the
|
||||
# crash-durable SQLite queue; without it Studio behaves exactly as
|
||||
# before (in-memory + optional JSON snapshot).
|
||||
if os.environ.get("STUDIO_QUEUE_DB"):
|
||||
from middleware.tasks_queue import SqlitePromptQueue
|
||||
self.prompt_queue = SqlitePromptQueue(self)
|
||||
else:
|
||||
self.prompt_queue = execution.PromptQueue(self)
|
||||
self.loop = loop
|
||||
self.messages = asyncio.Queue()
|
||||
self.client_session:Optional[aiohttp.ClientSession] = None
|
||||
self.number = 0
|
||||
|
||||
middlewares = [cache_control, deprecation_warning]
|
||||
from middleware.studio_home import home_redirect
|
||||
middlewares = [cache_control, deprecation_warning, home_redirect]
|
||||
|
||||
self._iam_auth = None
|
||||
if args.enable_iam_auth:
|
||||
@@ -251,8 +295,10 @@ class PromptServer():
|
||||
|
||||
max_upload_size = round(args.max_upload_size * 1024 * 1024)
|
||||
self.app = web.Application(client_max_size=max_upload_size, middlewares=middlewares)
|
||||
# OIDC Authorization Code callback (standard code flow, standalone app).
|
||||
# OIDC Authorization Code flow (standard code flow, standalone app):
|
||||
# /login begins it (the login page's sole entry point), /callback ends it.
|
||||
if self._iam_auth is not None:
|
||||
self.app.router.add_get("/login", self._iam_auth.handle_login)
|
||||
self.app.router.add_get("/callback", self._iam_auth.handle_callback)
|
||||
self.sockets = dict()
|
||||
self.sockets_metadata = dict()
|
||||
@@ -379,7 +425,6 @@ class PromptServer():
|
||||
if os.path.isfile(login_page):
|
||||
with open(login_page, "r") as f:
|
||||
html = f.read()
|
||||
html = html.replace("{{IAM_URL}}", args.iam_url.rstrip("/"))
|
||||
return web.Response(
|
||||
text=html,
|
||||
content_type="text/html",
|
||||
@@ -463,13 +508,15 @@ class PromptServer():
|
||||
return a.hexdigest() == b.hexdigest()
|
||||
return False
|
||||
|
||||
def image_upload(post, image_save_function=None):
|
||||
def image_upload(post, image_save_function=None, request=None):
|
||||
image = post.get("image")
|
||||
overwrite = post.get("overwrite")
|
||||
image_is_duplicate = False
|
||||
|
||||
image_upload_type = post.get("type")
|
||||
upload_dir, image_upload_type = get_dir_by_type(image_upload_type)
|
||||
# Forward the request so uploads land in the caller's org-scoped
|
||||
# directory (orgs/{org}/output) in multi-tenant mode.
|
||||
upload_dir, image_upload_type = get_dir_by_type(image_upload_type, request)
|
||||
|
||||
if image and image.file:
|
||||
filename = image.filename
|
||||
@@ -514,8 +561,23 @@ class PromptServer():
|
||||
@routes.post("/upload/image")
|
||||
async def upload_image(request):
|
||||
post = await request.post()
|
||||
return image_upload(post)
|
||||
return image_upload(post, request=request)
|
||||
|
||||
@routes.post("/upload/output")
|
||||
async def upload_output(request):
|
||||
# Token-authorized gallery ingest for BYO-GPU workers. A remote worker
|
||||
# (hanzod's gpu-fleet loop) renders a studio.render job on its own GPU
|
||||
# and POSTs each finished image here with the user's IAM bearer. The IAM
|
||||
# middleware validates the token and sets request["iam_user"]; org is
|
||||
# derived from that (owner/groups), so the file lands in this org's
|
||||
# orgs/{org}/output — which the PVC + S3 mirror persist into the gallery.
|
||||
# No S3/rclone credentials ever touch the worker box: the user's session
|
||||
# token is the only credential. Replaces the old rclone/S3 sidecar sync.
|
||||
post = await request.post()
|
||||
# Force type=output regardless of what the client sent.
|
||||
forced = dict(post)
|
||||
forced["type"] = "output"
|
||||
return image_upload(forced, request=request)
|
||||
|
||||
@routes.post("/upload/mask")
|
||||
async def upload_mask(request):
|
||||
@@ -561,7 +623,7 @@ class PromptServer():
|
||||
original_pil.putalpha(new_alpha)
|
||||
original_pil.save(filepath, compress_level=4, pnginfo=metadata)
|
||||
|
||||
return image_upload(post, image_save_function)
|
||||
return image_upload(post, image_save_function, request=request)
|
||||
|
||||
@routes.get("/view")
|
||||
async def view_image(request):
|
||||
@@ -942,19 +1004,24 @@ class PromptServer():
|
||||
else:
|
||||
offset = -1
|
||||
|
||||
return web.json_response(self.prompt_queue.get_history(max_items=max_items, offset=offset))
|
||||
hist = self.prompt_queue.get_history(max_items=max_items, offset=offset)
|
||||
hist = _scope_history_to_org(hist, self._caller_org(request))
|
||||
return web.json_response(hist)
|
||||
|
||||
@routes.get("/history/{prompt_id}")
|
||||
async def get_history_prompt_id(request):
|
||||
prompt_id = request.match_info.get("prompt_id", None)
|
||||
return web.json_response(self.prompt_queue.get_history(prompt_id=prompt_id))
|
||||
hist = self.prompt_queue.get_history(prompt_id=prompt_id)
|
||||
hist = _scope_history_to_org(hist, self._caller_org(request))
|
||||
return web.json_response(hist)
|
||||
|
||||
@routes.get("/queue")
|
||||
async def get_queue(request):
|
||||
org = self._caller_org(request)
|
||||
queue_info = {}
|
||||
current_queue = self.prompt_queue.get_current_queue_volatile()
|
||||
queue_info['queue_running'] = _remove_sensitive_from_queue(current_queue[0])
|
||||
queue_info['queue_pending'] = _remove_sensitive_from_queue(current_queue[1])
|
||||
queue_info['queue_running'] = _scope_queue_to_org(_remove_sensitive_from_queue(current_queue[0]), org)
|
||||
queue_info['queue_pending'] = _scope_queue_to_org(_remove_sensitive_from_queue(current_queue[1]), org)
|
||||
return web.json_response(queue_info)
|
||||
|
||||
@routes.post("/prompt")
|
||||
@@ -1024,6 +1091,21 @@ class PromptServer():
|
||||
|
||||
self.node_replace_manager.apply_replacements(prompt)
|
||||
|
||||
org_id = self._get_org_id(request) # IAM org — scopes outputs + billing
|
||||
# BYO-GPU dispatch: cloud pods only. A worker-mode node IS the
|
||||
# render backend — it must queue locally, never re-dispatch.
|
||||
# Runs BEFORE local validation: the job executes on the org's GPU
|
||||
# box, which validates against ITS node classes and model files. A
|
||||
# GPU-less pod has an empty model list and would wrongly reject
|
||||
# every checkpoint-referencing graph ("not in []").
|
||||
if not args.worker_mode:
|
||||
try:
|
||||
from middleware.gpu_dispatch import dispatch_if_worker
|
||||
except ImportError: # package-style checkout
|
||||
from .middleware.gpu_dispatch import dispatch_if_worker
|
||||
if dispatch_if_worker(request, org_id, prompt_id, prompt):
|
||||
return web.json_response({"prompt_id": prompt_id, "number": number, "node_errors": {}})
|
||||
|
||||
valid = await execution.validate_prompt(prompt_id, prompt, partial_execution_targets)
|
||||
extra_data = {}
|
||||
if "extra_data" in json_data:
|
||||
@@ -1038,7 +1120,14 @@ class PromptServer():
|
||||
if sensitive_val in extra_data:
|
||||
sensitive[sensitive_val] = extra_data.pop(sensitive_val)
|
||||
extra_data["create_time"] = int(time.time() * 1000) # timestamp in milliseconds
|
||||
extra_data["org_id"] = self._get_org_id(request) # tenancy: scope outputs to org
|
||||
extra_data["org_id"] = org_id
|
||||
# Carry the requester's verified IAM token to the worker so the
|
||||
# finished render is recorded in the content lane as this user,
|
||||
# into this user's org. `sensitive` is stripped from history (see
|
||||
# SENSITIVE_EXTRA_DATA_KEYS) — the token is never persisted.
|
||||
iam_token = request.get("iam_token")
|
||||
if iam_token:
|
||||
sensitive["iam_token"] = iam_token
|
||||
self.prompt_queue.put((number, prompt_id, prompt, extra_data, outputs_to_execute, sensitive))
|
||||
response = {"prompt_id": prompt_id, "number": number, "node_errors": valid[3]}
|
||||
return web.json_response(response)
|
||||
@@ -1057,13 +1146,20 @@ class PromptServer():
|
||||
@routes.post("/queue")
|
||||
async def post_queue(request):
|
||||
json_data = await request.json()
|
||||
org = self._caller_org(request) # None = auth off → upstream behavior
|
||||
if "clear" in json_data:
|
||||
if json_data["clear"]:
|
||||
self.prompt_queue.wipe_queue()
|
||||
if org is None:
|
||||
self.prompt_queue.wipe_queue()
|
||||
else:
|
||||
# SCOPED clear: only THIS org's pending items, never others'.
|
||||
self.prompt_queue.delete_queue_item(lambda a: _item_org(a) == org)
|
||||
if "delete" in json_data:
|
||||
to_delete = json_data['delete']
|
||||
for id_to_delete in to_delete:
|
||||
delete_func = lambda a: a[1] == id_to_delete
|
||||
# Only delete an item the caller owns (org must match when auth on).
|
||||
delete_func = (lambda a, i=id_to_delete: a[1] == i) if org is None \
|
||||
else (lambda a, i=id_to_delete: a[1] == i and _item_org(a) == org)
|
||||
self.prompt_queue.delete_queue_item(delete_func)
|
||||
|
||||
return web.Response(status=200)
|
||||
@@ -1075,16 +1171,19 @@ class PromptServer():
|
||||
except json.JSONDecodeError:
|
||||
json_data = {}
|
||||
|
||||
org = self._caller_org(request) # None = auth off → upstream behavior
|
||||
# Check if a specific prompt_id was provided for targeted interruption
|
||||
prompt_id = json_data.get('prompt_id')
|
||||
if prompt_id:
|
||||
currently_running, _ = self.prompt_queue.get_current_queue()
|
||||
|
||||
# Check if the prompt_id matches any currently running prompt
|
||||
# Check if the prompt_id matches any currently running prompt — and
|
||||
# (multi-tenant) that it belongs to the caller's org, so one tenant
|
||||
# cannot interrupt another tenant's render.
|
||||
should_interrupt = False
|
||||
for item in currently_running:
|
||||
# item structure: (number, prompt_id, prompt, extra_data, outputs_to_execute)
|
||||
if item[1] == prompt_id:
|
||||
if item[1] == prompt_id and (org is None or _item_org(item) == org):
|
||||
logging.info(f"Interrupting prompt {prompt_id}")
|
||||
should_interrupt = True
|
||||
break
|
||||
@@ -1092,11 +1191,18 @@ class PromptServer():
|
||||
if should_interrupt:
|
||||
nodes.interrupt_processing()
|
||||
else:
|
||||
logging.info(f"Prompt {prompt_id} is not currently running, skipping interrupt")
|
||||
else:
|
||||
# No prompt_id provided, do a global interrupt
|
||||
logging.info(f"Prompt {prompt_id} is not currently running / not owned by caller, skipping interrupt")
|
||||
elif org is None:
|
||||
# No prompt_id + auth off → upstream global interrupt.
|
||||
logging.info("Global interrupt (no prompt_id specified)")
|
||||
nodes.interrupt_processing()
|
||||
else:
|
||||
# Multi-tenant: a global interrupt would kill another tenant's render.
|
||||
# Only interrupt if the running job belongs to the caller's org.
|
||||
currently_running, _ = self.prompt_queue.get_current_queue()
|
||||
if any(_item_org(item) == org for item in currently_running):
|
||||
logging.info(f"Interrupting current render for org {org}")
|
||||
nodes.interrupt_processing()
|
||||
|
||||
return web.Response(status=200)
|
||||
|
||||
@@ -1273,6 +1379,16 @@ class PromptServer():
|
||||
return iam_user.get("org_id", "default")
|
||||
return os.environ.get("STUDIO_ORG_ID", "default")
|
||||
|
||||
def _caller_org(self, request) -> str | None:
|
||||
"""The verified caller org to SCOPE cross-tenant reads by — or None when
|
||||
multi-tenant auth is OFF (single-tenant/local dev) so /history + /queue
|
||||
behave exactly as upstream. Only returns a value when there is a real IAM
|
||||
user, so scoping never hides another tenant's data by accident."""
|
||||
iam_user = request.get("iam_user")
|
||||
if iam_user and iam_user.get("org_id"):
|
||||
return iam_user.get("org_id")
|
||||
return None
|
||||
|
||||
async def setup(self):
|
||||
timeout = aiohttp.ClientTimeout(total=None) # no timeout
|
||||
self.client_session = aiohttp.ClientSession(timeout=timeout)
|
||||
@@ -1281,6 +1397,8 @@ class PromptServer():
|
||||
async def _on_shutdown(app):
|
||||
await prompt_router.close_session()
|
||||
await visor_client.close_session()
|
||||
from middleware import copilot
|
||||
await copilot.close_session()
|
||||
wc = getattr(self, "_worker_client", None)
|
||||
if wc:
|
||||
await wc.stop()
|
||||
@@ -1292,6 +1410,16 @@ class PromptServer():
|
||||
self.custom_node_manager.add_routes(self.routes, self.app, nodes.LOADED_MODULE_DIRS.items())
|
||||
self.subgraph_manager.add_routes(self.routes, nodes.LOADED_MODULE_DIRS.items())
|
||||
self.node_replace_manager.add_routes(self.routes)
|
||||
from middleware.engine_selector import add_engine_routes
|
||||
add_engine_routes(self.routes, self)
|
||||
from middleware.copilot import add_copilot_routes
|
||||
add_copilot_routes(self.routes, self)
|
||||
from middleware.session import add_session_routes
|
||||
add_session_routes(self.routes, self)
|
||||
from middleware.studio_home import add_studio_home_routes
|
||||
add_studio_home_routes(self.routes, self)
|
||||
from middleware.mcp import add_mcp_routes
|
||||
add_mcp_routes(self.routes, self)
|
||||
self.app.add_subapp('/internal', self.internal_routes.get_app())
|
||||
|
||||
# Prefix every route with /api for easier matching for delegation.
|
||||
|
||||
@@ -224,6 +224,7 @@ parser.add_argument("--enable-compress-response-body", action="store_true", help
|
||||
# --- IAM Authentication ---
|
||||
parser.add_argument("--enable-iam-auth", action="store_true", help="Enable IAM authentication via hanzo.id. Requires valid Bearer token for API access. Localhost is exempt by default.")
|
||||
parser.add_argument("--iam-url", type=str, default=os.environ.get("STUDIO_IAM_URL", "https://hanzo.id"), help="IAM server URL for token validation (default: https://hanzo.id).")
|
||||
parser.add_argument("--console-url", type=str, default=os.environ.get("STUDIO_CONSOLE_URL", "https://console.hanzo.ai"), help="Hanzo Console URL the Studio user menu links back to (default: https://console.hanzo.ai).")
|
||||
parser.add_argument("--no-localhost-bypass", action="store_true", help="Disable localhost auth bypass (require IAM tokens even for local connections).")
|
||||
|
||||
# --- Multi-tenant ---
|
||||
|
||||
+1
-49410
File diff suppressed because one or more lines are too long
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
studio_compat -- import-compat shim for upstream ComfyUI custom nodes.
|
||||
|
||||
Hanzo Studio renamed ComfyUI's internal packages (``comfy`` -> ``studio``,
|
||||
``comfy_extras`` -> ``studio_extras``, ... and ``comfy/comfy_types`` ->
|
||||
``studio/node_types``; see ``scripts/rename_modules.py``). Third-party custom
|
||||
node packs import the upstream ``comfy*`` names, so they fail to import against
|
||||
this fork.
|
||||
|
||||
This module installs ONE meta-path finder that lazily aliases every ``comfy*``
|
||||
import to its ``studio*`` counterpart, so vendored packs run unmodified -- no
|
||||
forking, no source edits, one place, one way. It is the compatibility redirect
|
||||
that ``scripts/rename_modules.py`` deferred (its phase 4), implemented with the
|
||||
modern ``find_spec`` API (``find_module``/``load_module`` were removed in
|
||||
Python 3.12).
|
||||
|
||||
Activated once, from the top of ``main.py``::
|
||||
|
||||
import studio_compat # noqa: F401
|
||||
"""
|
||||
import importlib
|
||||
import importlib.abc
|
||||
import importlib.machinery
|
||||
import sys
|
||||
|
||||
# Root package aliases: upstream ComfyUI name -> Hanzo Studio name.
|
||||
_ROOTS = {
|
||||
"comfy": "studio",
|
||||
"comfy_extras": "studio_extras",
|
||||
"comfy_api": "studio_api",
|
||||
"comfy_api_nodes": "studio_api_nodes",
|
||||
"comfy_config": "studio_config",
|
||||
"comfy_execution": "studio_execution",
|
||||
}
|
||||
|
||||
# Submodule renames applied to the target name after the root swap.
|
||||
# ComfyUI's ``comfy/comfy_types`` lives at ``studio/node_types`` in this fork.
|
||||
_SUBMODULE_RENAMES = {
|
||||
"studio.comfy_types": "studio.node_types",
|
||||
}
|
||||
|
||||
# External pip packages that share the ``comfy*``/``comfyui*`` prefix but are
|
||||
# NOT part of the renamed core -- never alias these (they resolve normally).
|
||||
# Mirrors the EXTERNAL_PACKAGES set in scripts/rename_modules.py.
|
||||
_EXTERNAL = frozenset({
|
||||
"comfy_kitchen",
|
||||
"comfy_aimdo",
|
||||
"comfyui_manager",
|
||||
"comfyui_frontend_package",
|
||||
"comfyui_workflow_templates",
|
||||
"comfyui_embedded_docs",
|
||||
})
|
||||
|
||||
|
||||
def _target_name(fullname):
|
||||
"""Return the ``studio*`` module name that ``fullname`` aliases, or None."""
|
||||
root = fullname.split(".", 1)[0]
|
||||
if root in _EXTERNAL or root not in _ROOTS:
|
||||
return None
|
||||
target = _ROOTS[root] + fullname[len(root):]
|
||||
for src, dst in _SUBMODULE_RENAMES.items():
|
||||
if target == src or target.startswith(src + "."):
|
||||
target = dst + target[len(src):]
|
||||
break
|
||||
return target
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Class-name compatibility.
|
||||
#
|
||||
# The fork mechanically swapped the ``Comfy`` prefix for ``Studio`` on the
|
||||
# public node-API classes (comfy_api.latest.ComfyExtension -> StudioExtension;
|
||||
# comfy_api.latest.io.ComfyNode -> studio_api.latest.io.StudioNode, ...).
|
||||
# Newer packs subclass ``io.ComfyNode`` / return ``ComfyExtension``. We apply
|
||||
# the inverse swap as attribute aliases, lazily, the first time a pack imports
|
||||
# the aliased module -- by which point studio_api.latest is safely importable.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mirror_comfy_prefix(module):
|
||||
"""Bind ``Comfy<X>`` aliases for every public ``Studio<X>`` in ``module``."""
|
||||
for name in list(vars(module)):
|
||||
if name.startswith("Studio"):
|
||||
alias = "Comfy" + name[len("Studio"):]
|
||||
if not hasattr(module, alias):
|
||||
setattr(module, alias, getattr(module, name))
|
||||
|
||||
|
||||
def _mirror_studio_api_latest(module):
|
||||
_mirror_comfy_prefix(module)
|
||||
io_mod = getattr(module, "io", None) # studio_api.latest._io_public
|
||||
if io_mod is not None:
|
||||
_mirror_comfy_prefix(io_mod)
|
||||
|
||||
|
||||
# target module name -> hook(module) run once, right after it is aliased.
|
||||
_POST_IMPORT = {
|
||||
"studio_api.latest": _mirror_studio_api_latest,
|
||||
}
|
||||
|
||||
|
||||
class _AliasLoader(importlib.abc.Loader):
|
||||
def __init__(self, target):
|
||||
self._target = target
|
||||
|
||||
def create_module(self, spec):
|
||||
# Import (or reuse) the real module and hand it back as the alias.
|
||||
# Returning an already-initialized module is why exec_module is a no-op;
|
||||
# _init_module_attrs(override=False) preserves the target's __name__ etc.
|
||||
module = importlib.import_module(self._target)
|
||||
hook = _POST_IMPORT.get(self._target)
|
||||
if hook is not None:
|
||||
hook(module)
|
||||
return module
|
||||
|
||||
def exec_module(self, module):
|
||||
pass
|
||||
|
||||
|
||||
class _AliasFinder(importlib.abc.MetaPathFinder):
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
name = _target_name(fullname)
|
||||
if name is None:
|
||||
return None
|
||||
return importlib.machinery.ModuleSpec(fullname, _AliasLoader(name))
|
||||
|
||||
|
||||
def install():
|
||||
"""Insert the alias finder at the head of ``sys.meta_path`` (idempotent)."""
|
||||
if not any(isinstance(f, _AliasFinder) for f in sys.meta_path):
|
||||
sys.meta_path.insert(0, _AliasFinder())
|
||||
|
||||
|
||||
install()
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
# This file is automatically generated by the build process when version is
|
||||
# updated in pyproject.toml.
|
||||
__version__ = "0.14.1"
|
||||
__version__ = "0.17.3"
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# Studio watchdog: keep :8188 alive through the overnight karma render run.
|
||||
# Emits a line ONLY on a restart event (so it's a signal, not a firehose).
|
||||
# Detects: process gone, port unresponsive, or output-log silent >12min while a
|
||||
# render is supposedly in flight (the 6h-hang failure mode from the QC log).
|
||||
set -u
|
||||
STUDIO=/home/z/work/hanzo/studio
|
||||
LOG=$STUDIO/studio-local.log
|
||||
launch() {
|
||||
cd "$STUDIO"
|
||||
# Free the port first: a stale/duplicate main.py holding :8188 makes the new
|
||||
# one crash on bind (EADDRINUSE). Kill any existing listener, wait for release.
|
||||
pkill -f 'main.py --listen' 2>/dev/null || true
|
||||
for _ in $(seq 1 15); do
|
||||
ss -ltn 2>/dev/null | grep -q ':8188 ' || break
|
||||
sleep 1
|
||||
done
|
||||
HF_HOME=/home/z/.cache/huggingface \
|
||||
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \
|
||||
STUDIO_PERSIST_QUEUE=1 \
|
||||
nohup .venv/bin/python main.py --listen 0.0.0.0 --port 8188 --normalvram \
|
||||
--disable-auto-launch --output-directory "$STUDIO/output" >> "$LOG" 2>&1 &
|
||||
sleep 1
|
||||
local pid
|
||||
pid=$(pgrep -f 'main.py --listen' | head -1)
|
||||
[ -n "$pid" ] && sudo -n sh -c "echo -700 > /proc/$pid/oom_score_adj" 2>/dev/null || true
|
||||
echo "$pid"
|
||||
}
|
||||
while true; do
|
||||
code=$(curl -s -m 5 http://127.0.0.1:8188/system_stats -o /dev/null -w "%{http_code}" 2>/dev/null || echo 000)
|
||||
if [ "$code" != "200" ]; then
|
||||
# give it one grace re-check (could be mid-render, momentarily busy)
|
||||
sleep 8
|
||||
code=$(curl -s -m 5 http://127.0.0.1:8188/system_stats -o /dev/null -w "%{http_code}" 2>/dev/null || echo 000)
|
||||
if [ "$code" != "200" ]; then
|
||||
pid=$(launch)
|
||||
# wait for it to answer
|
||||
for i in $(seq 1 45); do
|
||||
c=$(curl -s -m 4 http://127.0.0.1:8188/system_stats -o /dev/null -w "%{http_code}" 2>/dev/null || echo 000)
|
||||
[ "$c" = "200" ] && break
|
||||
sleep 4
|
||||
done
|
||||
echo "STUDIO_RESTARTED pid=$pid health=$c"
|
||||
fi
|
||||
fi
|
||||
sleep 45
|
||||
done
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Unit tests for the render-completion billing hook (middleware.billing_middleware).
|
||||
|
||||
No network, no torch, no GPU: aiohttp.ClientSession is replaced with a fake that
|
||||
records every POST and lets each test dictate the response (200/4xx/5xx) or raise
|
||||
a timeout. Covers the four things that matter for money-safe fire-and-forget
|
||||
metering: a clean success emits exactly one correctly-shaped meter event, the
|
||||
prompt_id rides through as the idempotency key, a timeout is swallowed (never
|
||||
raises, never blocks) and retried once, and — the local-dev guarantee — an
|
||||
unconfigured environment emits nothing at all.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from middleware import billing_middleware as bm
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
def __init__(self, status=201, body=""):
|
||||
self.status = status
|
||||
self._body = body
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
async def text(self):
|
||||
return self._body
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Drop-in for aiohttp.ClientSession. Captures posts; behavior is class state."""
|
||||
calls: list = []
|
||||
respond = staticmethod(lambda payload: _FakeResp(201)) # payload -> _FakeResp | raises
|
||||
|
||||
def __init__(self, *a, **kw):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
def post(self, url, json=None, headers=None):
|
||||
_FakeSession.calls.append({"url": url, "json": json, "headers": headers})
|
||||
return _FakeSession.respond(json) # may return a resp CM or raise
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_http(monkeypatch):
|
||||
_FakeSession.calls = []
|
||||
_FakeSession.respond = staticmethod(lambda payload: _FakeResp(201))
|
||||
monkeypatch.setattr(bm.aiohttp, "ClientSession", _FakeSession)
|
||||
return _FakeSession
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def configured(monkeypatch):
|
||||
monkeypatch.setenv("STUDIO_BILLING_URL", "http://commerce.test/v1/billing/meter-events")
|
||||
monkeypatch.setenv("STUDIO_COMMERCE_TOKEN", "tok-123")
|
||||
monkeypatch.setenv("STUDIO_BILLING_METER", "meter-render")
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# count_outputs — quantity is the number of artifacts a completed render made
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_count_outputs_sums_media_across_nodes():
|
||||
hist = {
|
||||
"outputs": {
|
||||
"9": {"images": [{"filename": "a.png"}, {"filename": "b.png"}]},
|
||||
"12": {"gifs": [{"filename": "c.webp"}]},
|
||||
}
|
||||
}
|
||||
assert bm.count_outputs(hist) == 3
|
||||
|
||||
|
||||
def test_count_outputs_handles_empty_and_none():
|
||||
assert bm.count_outputs(None) == 0
|
||||
assert bm.count_outputs({}) == 0
|
||||
assert bm.count_outputs({"outputs": {"1": {"text": "no media here"}}}) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# record_render — the fire-and-forget metered event
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_success_emits_one_correct_event(fake_http, configured):
|
||||
_run(bm.record_render(org_id="acme", prompt_id="p-1", n_outputs=2))
|
||||
|
||||
assert len(fake_http.calls) == 1
|
||||
call = fake_http.calls[0]
|
||||
assert call["url"] == "http://commerce.test/v1/billing/meter-events"
|
||||
assert call["headers"]["Authorization"] == "Bearer tok-123"
|
||||
|
||||
events = call["json"]["events"]
|
||||
assert len(events) == 1
|
||||
evt = events[0]
|
||||
assert evt["meterId"] == "meter-render"
|
||||
assert evt["userId"] == "acme" # billing subject = IAM org
|
||||
assert evt["value"] == 2 # quantity = n_outputs
|
||||
assert evt["timestamp"].endswith("Z") # RFC3339 UTC
|
||||
|
||||
|
||||
def test_idempotency_key_is_prompt_id(fake_http, configured):
|
||||
_run(bm.record_render(org_id="acme", prompt_id="prompt-abc", n_outputs=1))
|
||||
assert fake_http.calls[0]["json"]["events"][0]["idempotency"] == "prompt-abc"
|
||||
|
||||
|
||||
def test_timeout_does_not_block_and_retries_once(fake_http, configured):
|
||||
def _boom(payload):
|
||||
raise asyncio.TimeoutError("simulated commerce timeout")
|
||||
|
||||
fake_http.respond = staticmethod(_boom)
|
||||
|
||||
# Must NOT raise — a render is never failed for billing.
|
||||
_run(bm.record_render(org_id="acme", prompt_id="p-2", n_outputs=1))
|
||||
|
||||
# Fired the initial attempt + exactly one retry, then dropped.
|
||||
assert len(fake_http.calls) == 2
|
||||
|
||||
|
||||
def test_client_error_is_not_retried(fake_http, configured):
|
||||
fake_http.respond = staticmethod(lambda payload: _FakeResp(400, "bad meter"))
|
||||
_run(bm.record_render(org_id="acme", prompt_id="p-3", n_outputs=1))
|
||||
assert len(fake_http.calls) == 1 # 4xx is deterministic — no retry
|
||||
|
||||
|
||||
def test_server_error_is_retried_once(fake_http, configured):
|
||||
fake_http.respond = staticmethod(lambda payload: _FakeResp(503, "unavailable"))
|
||||
_run(bm.record_render(org_id="acme", prompt_id="p-4", n_outputs=1))
|
||||
assert len(fake_http.calls) == 2 # 5xx is transient — retry once, then drop
|
||||
|
||||
|
||||
def test_unconfigured_env_is_a_noop(fake_http, monkeypatch):
|
||||
# Local dev: none of the STUDIO_BILLING_* vars set → zero behavior change.
|
||||
monkeypatch.delenv("STUDIO_BILLING_URL", raising=False)
|
||||
monkeypatch.delenv("STUDIO_COMMERCE_TOKEN", raising=False)
|
||||
monkeypatch.delenv("STUDIO_BILLING_METER", raising=False)
|
||||
|
||||
_run(bm.record_render(org_id="acme", prompt_id="p-5", n_outputs=9))
|
||||
assert fake_http.calls == [] # no HTTP attempted
|
||||
|
||||
|
||||
def test_partial_config_is_a_noop(fake_http, monkeypatch):
|
||||
# URL + token but no meter id → still a no-op (all three are required).
|
||||
monkeypatch.setenv("STUDIO_BILLING_URL", "http://commerce.test/v1/billing/meter-events")
|
||||
monkeypatch.setenv("STUDIO_COMMERCE_TOKEN", "tok-123")
|
||||
monkeypatch.delenv("STUDIO_BILLING_METER", raising=False)
|
||||
|
||||
_run(bm.record_render(org_id="acme", prompt_id="p-6", n_outputs=1))
|
||||
assert fake_http.calls == []
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Tests for recording a completed render as an Asset in the Hanzo content lane.
|
||||
|
||||
These are CONTRACT tests. They stand up a real aiohttp server that speaks the
|
||||
exact contract hanzoai/cloud serves — the generic framework surface
|
||||
(POST /v1/framework/Asset) — and drive the real publisher against it over real
|
||||
HTTP. The server side of that contract (the Asset DocType, its draft default, the
|
||||
lifecycle hook that owns every status edge, cross-org isolation) is proven
|
||||
independently against the real engine and a real SQLite by the content lane's own
|
||||
Go tests in hanzoai/cloud (clients/content/content_test.go).
|
||||
|
||||
What is asserted here is Studio's half: that a finished render produces exactly one
|
||||
Asset per output artifact, in the SAME field shape clients/content itself writes
|
||||
(so a studio-initiated render is indistinguishable from a lane-initiated one); that
|
||||
`file` is the org-scoped output key (the bytes Studio already persisted — this
|
||||
module uploads nothing); that every call is authenticated AS THE REQUESTING USER;
|
||||
that assets land as drafts (never auto-published to a storefront); and that a
|
||||
render is never failed when the lane is unreachable.
|
||||
"""
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from middleware import content_publish
|
||||
|
||||
pytestmark = pytest.mark.asyncio # module is all-async
|
||||
|
||||
TOKEN = "the-users-iam-access-token"
|
||||
PROMPT_ID = "b6f1c0e2-0000-4000-8000-000000000001"
|
||||
WORKFLOW = {"3": {"class_type": "KSampler", "inputs": {"steps": 30}},
|
||||
"4": {"class_type": "UNETLoader", "inputs": {"unet_name": "qwen-image-edit-2511.safetensors"}}}
|
||||
HISTORY = {"outputs": {"9": {"images": [
|
||||
{"filename": "life1_00001_.png", "subfolder": "swimwear/lifestyle2/valentina", "type": "output"},
|
||||
{"filename": "life1_00002_.png", "subfolder": "swimwear/lifestyle2/valentina", "type": "output"},
|
||||
]}, "10": {"images": [
|
||||
{"filename": "preview.png", "subfolder": "", "type": "temp"}, # a preview is never an artifact
|
||||
]}}}
|
||||
EXTRA = {"design": "valentina", "kind": "lifestyle", "role": "life1",
|
||||
"prompt": "poolside, golden hour", "project": "fashion", "tags": "valentina,life1"}
|
||||
|
||||
|
||||
class Content:
|
||||
"""A stand-in for hanzoai/cloud's framework surface, module 'marketing'."""
|
||||
|
||||
def __init__(self, fail: bool = False):
|
||||
self.assets: list[dict] = []
|
||||
self.fail = fail
|
||||
|
||||
def app(self) -> web.Application:
|
||||
app = web.Application()
|
||||
app.router.add_post("/v1/framework/Asset", self._asset)
|
||||
return app
|
||||
|
||||
async def _asset(self, request):
|
||||
if request.headers.get("Authorization") != f"Bearer {TOKEN}":
|
||||
return web.json_response({"error": "valid principal required"}, status=403)
|
||||
body = await request.json()
|
||||
if self.fail:
|
||||
return web.json_response({"error": "boom"}, status=500)
|
||||
# The lane defaults status to draft; the client does not (and must not) set it.
|
||||
doc = {**body, "name": "hash-id", "doctype": "Asset", "status": "draft"}
|
||||
self.assets.append(doc)
|
||||
return web.json_response(doc, status=201)
|
||||
|
||||
|
||||
async def _publish(content, aiohttp_client, monkeypatch, **kw):
|
||||
monkeypatch.setenv("STUDIO_CONTENT_PUBLISH", "1")
|
||||
client: TestClient = await aiohttp_client(TestServer(content.app()))
|
||||
monkeypatch.setattr(content_publish, "CLOUD_URL", str(client.make_url("")).rstrip("/"))
|
||||
args = {"org_id": "karma", "prompt_id": PROMPT_ID, "workflow": WORKFLOW,
|
||||
"history_result": HISTORY, "iam_token": TOKEN, "extra_data": EXTRA}
|
||||
args.update(kw)
|
||||
return await content_publish.publish_render(**args)
|
||||
|
||||
|
||||
# --- pure helpers --------------------------------------------------------
|
||||
|
||||
async def test_artifacts_are_outputs_only_never_previews():
|
||||
refs = content_publish.artifacts(HISTORY)
|
||||
assert [r["filename"] for r in refs] == ["life1_00001_.png", "life1_00002_.png"]
|
||||
|
||||
|
||||
async def test_object_key_is_the_org_scoped_output_key():
|
||||
ref = {"filename": "life1_00001_.png", "subfolder": "swimwear/lifestyle2/valentina", "type": "output"}
|
||||
assert content_publish.object_key("karma", ref) == \
|
||||
"orgs/karma/output/swimwear/lifestyle2/valentina/life1_00001_.png"
|
||||
# no subfolder collapses cleanly (no doubled slash)
|
||||
assert content_publish.object_key("karma", {"filename": "x.png", "subfolder": "", "type": "output"}) == \
|
||||
"orgs/karma/output/x.png"
|
||||
|
||||
|
||||
async def test_kind_must_be_a_valid_select_value():
|
||||
assert content_publish.kind_of({"kind": "lifestyle"}) == "lifestyle"
|
||||
assert content_publish.kind_of({"kind": "nonsense"}) == "product" # off-list -> the default
|
||||
assert content_publish.kind_of({}) == "product"
|
||||
|
||||
|
||||
# --- the happy path ------------------------------------------------------
|
||||
|
||||
async def test_records_one_asset_per_artifact(aiohttp_client, monkeypatch):
|
||||
content = Content()
|
||||
assert await _publish(content, aiohttp_client, monkeypatch) == 2
|
||||
assert len(content.assets) == 2
|
||||
assert [a["title"] for a in content.assets] == ["life1_00001_.png", "life1_00002_.png"]
|
||||
|
||||
|
||||
async def test_asset_matches_the_content_lane_field_shape(aiohttp_client, monkeypatch):
|
||||
content = Content()
|
||||
await _publish(content, aiohttp_client, monkeypatch)
|
||||
a = content.assets[0]
|
||||
# The exact fields clients/content writes (studio_render.go draftAsset).
|
||||
assert a["file"] == "orgs/karma/output/swimwear/lifestyle2/valentina/life1_00001_.png"
|
||||
assert a["kind"] == "lifestyle"
|
||||
assert a["design"] == "valentina"
|
||||
assert a["role"] == "life1"
|
||||
assert a["source_prompt_id"] == PROMPT_ID
|
||||
assert a["render_params"] == WORKFLOW # the graph reproduces it
|
||||
assert a["generator"] == "qwen-image-edit-2511.safetensors"
|
||||
assert a["project"] == "fashion" and a["tags"] == "valentina,life1"
|
||||
|
||||
|
||||
async def test_assets_land_as_draft_never_auto_published(aiohttp_client, monkeypatch):
|
||||
"""A render must never push an image to a storefront. The client never sends a
|
||||
status; the lane defaults it to draft; a human moves it through the lifecycle."""
|
||||
content = Content()
|
||||
await _publish(content, aiohttp_client, monkeypatch)
|
||||
assert all("status" not in c for c in [content_publish.asset_doc(
|
||||
org_id="karma", prompt_id=PROMPT_ID, workflow=WORKFLOW, ref=r, extra_data=EXTRA
|
||||
) for r in content_publish.artifacts(HISTORY)])
|
||||
assert all(a["status"] == "draft" for a in content.assets)
|
||||
|
||||
|
||||
# --- auth ----------------------------------------------------------------
|
||||
|
||||
async def test_written_as_the_requesting_user(aiohttp_client, monkeypatch):
|
||||
# The stub 403s any call without the user's exact bearer, so a full publish is
|
||||
# itself proof every write carried it.
|
||||
content = Content()
|
||||
assert await _publish(content, aiohttp_client, monkeypatch) == 2
|
||||
|
||||
|
||||
async def test_without_a_user_token_nothing_is_written(aiohttp_client, monkeypatch):
|
||||
content = Content()
|
||||
assert await _publish(content, aiohttp_client, monkeypatch, iam_token=None) == 0
|
||||
assert content.assets == []
|
||||
|
||||
|
||||
# --- a render is never failed --------------------------------------------
|
||||
|
||||
async def test_disabled_is_a_no_op(aiohttp_client, monkeypatch):
|
||||
content = Content()
|
||||
monkeypatch.delenv("STUDIO_CONTENT_PUBLISH", raising=False)
|
||||
monkeypatch.setattr(content_publish, "CLOUD_URL", "http://127.0.0.1:1")
|
||||
# enabled() reads the env directly, so call the real entrypoint with it unset.
|
||||
assert await content_publish.publish_render(
|
||||
org_id="karma", prompt_id=PROMPT_ID, workflow=WORKFLOW,
|
||||
history_result=HISTORY, iam_token=TOKEN, extra_data=EXTRA,
|
||||
) == 0
|
||||
assert content.assets == []
|
||||
|
||||
|
||||
async def test_lane_error_is_survivable(aiohttp_client, monkeypatch):
|
||||
content = Content(fail=True)
|
||||
assert await _publish(content, aiohttp_client, monkeypatch) == 0 # nothing landed, no raise
|
||||
|
||||
|
||||
async def test_unreachable_lane_never_raises(monkeypatch):
|
||||
monkeypatch.setenv("STUDIO_CONTENT_PUBLISH", "1")
|
||||
monkeypatch.setattr(content_publish, "CLOUD_URL", "http://127.0.0.1:1") # nothing listening
|
||||
assert await content_publish.publish_render(
|
||||
org_id="karma", prompt_id=PROMPT_ID, workflow=WORKFLOW,
|
||||
history_result=HISTORY, iam_token=TOKEN, extra_data=EXTRA,
|
||||
) == 0
|
||||
|
||||
|
||||
async def test_empty_render_writes_nothing(aiohttp_client, monkeypatch):
|
||||
content = Content()
|
||||
assert await _publish(content, aiohttp_client, monkeypatch, history_result={"outputs": {}}) == 0
|
||||
assert content.assets == []
|
||||
|
||||
|
||||
# --- the token never leaks into history ----------------------------------
|
||||
|
||||
async def test_iam_token_is_a_sensitive_key_so_it_is_stripped_from_history():
|
||||
# Force the CPU device path before the heavy import: model_management probes
|
||||
# CUDA at import time and a GPU-less CI runner has no NVIDIA driver.
|
||||
from studio.cli_args import args
|
||||
args.cpu = True
|
||||
import execution
|
||||
assert "iam_token" in execution.SENSITIVE_EXTRA_DATA_KEYS
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Tests for the Copilot backend: op validation + the tool-calling round trip.
|
||||
|
||||
No network and no node graph — the LLM call (_post_chat) is monkeypatched and
|
||||
node types are passed in as a set, so this stays as light as the other
|
||||
middleware tests. Async paths run via asyncio.run to avoid a plugin dependency.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from middleware import copilot
|
||||
|
||||
VALID = {"KSampler", "SaveImage", "UpscaleModelLoader", "ImageUpscaleWithModel", "CLIPTextEncode"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------- validate_ops
|
||||
def test_valid_ops_pass_through():
|
||||
ops = [
|
||||
{"op": "add_node", "type": "SaveImage", "id": "s1"},
|
||||
{"op": "connect", "from_node": 3, "from_slot": "IMAGE", "to_node": "s1", "to_slot": "images"},
|
||||
{"op": "set_widget", "node_id": 3, "name": "seed", "value": 42},
|
||||
{"op": "layout"},
|
||||
{"op": "queue"},
|
||||
]
|
||||
clean, warnings = copilot.validate_ops(ops, VALID)
|
||||
assert clean == ops
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_unknown_node_type_dropped():
|
||||
clean, warnings = copilot.validate_ops(
|
||||
[{"op": "add_node", "type": "TotallyFakeNode"}], VALID
|
||||
)
|
||||
assert clean == []
|
||||
assert any("TotallyFakeNode" in w for w in warnings)
|
||||
|
||||
|
||||
def test_unknown_op_dropped():
|
||||
clean, warnings = copilot.validate_ops([{"op": "teleport", "node_id": 1}], VALID)
|
||||
assert clean == []
|
||||
assert any("unknown op" in w for w in warnings)
|
||||
|
||||
|
||||
def test_missing_required_field_dropped():
|
||||
clean, warnings = copilot.validate_ops([{"op": "set_widget", "node_id": 1}], VALID)
|
||||
assert clean == []
|
||||
assert any("missing name" in w for w in warnings)
|
||||
|
||||
|
||||
def test_set_widget_zero_value_is_kept():
|
||||
# value 0 / "" is meaningful and must not be treated as missing.
|
||||
ops = [{"op": "set_widget", "node_id": 1, "name": "denoise", "value": 0}]
|
||||
clean, warnings = copilot.validate_ops(ops, VALID)
|
||||
assert clean == ops and warnings == []
|
||||
|
||||
|
||||
def test_non_list_ops_safe():
|
||||
assert copilot.validate_ops(None, VALID) == ([], [])
|
||||
assert copilot.validate_ops("nope", VALID) == ([], [])
|
||||
|
||||
|
||||
# ------------------------------------------------------------- _parse_completion
|
||||
def _tool_completion(reply, ops):
|
||||
return {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"name": "edit_graph",
|
||||
"arguments": json.dumps({"reply": reply, "ops": ops}),
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_parse_tool_call():
|
||||
reply, ops = copilot._parse_completion(
|
||||
_tool_completion("done", [{"op": "queue"}])
|
||||
)
|
||||
assert reply == "done"
|
||||
assert ops == [{"op": "queue"}]
|
||||
|
||||
|
||||
def test_parse_content_json_fallback():
|
||||
content = 'Sure! {"reply": "added", "ops": [{"op": "layout"}]}'
|
||||
data = {"choices": [{"message": {"content": content}}]}
|
||||
reply, ops = copilot._parse_completion(data)
|
||||
assert ops == [{"op": "layout"}]
|
||||
|
||||
|
||||
def test_parse_prose_only():
|
||||
data = {"choices": [{"message": {"content": "That node upscales images."}}]}
|
||||
reply, ops = copilot._parse_completion(data)
|
||||
assert reply == "That node upscales images."
|
||||
assert ops == []
|
||||
|
||||
|
||||
def test_parse_malformed_completion():
|
||||
assert copilot._parse_completion({}) == ("", [])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- run_copilot
|
||||
def test_run_copilot_validates_after_model(monkeypatch):
|
||||
async def fake_post(url, token, payload):
|
||||
# The model asks for a good op and a hallucinated node type.
|
||||
return _tool_completion(
|
||||
"added an upscale",
|
||||
[
|
||||
{"op": "add_node", "type": "ImageUpscaleWithModel", "id": "u1"},
|
||||
{"op": "add_node", "type": "MadeUpNode"},
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(copilot, "_post_chat", fake_post)
|
||||
body = {"messages": [{"role": "user", "content": "add a 4x upscale"}], "graph_json": {}}
|
||||
result = asyncio.run(
|
||||
copilot.run_copilot(body, VALID, url="http://x", model="zen", token="")
|
||||
)
|
||||
assert result["ops"] == [{"op": "add_node", "type": "ImageUpscaleWithModel", "id": "u1"}]
|
||||
assert "MadeUpNode" in result["reply"]
|
||||
assert "added an upscale" in result["reply"]
|
||||
|
||||
|
||||
def test_run_copilot_prose_no_ops(monkeypatch):
|
||||
async def fake_post(url, token, payload):
|
||||
return {"choices": [{"message": {"content": "KSampler denoises latents."}}]}
|
||||
|
||||
monkeypatch.setattr(copilot, "_post_chat", fake_post)
|
||||
body = {"messages": [{"role": "user", "content": "what does ksampler do?"}]}
|
||||
result = asyncio.run(
|
||||
copilot.run_copilot(body, VALID, url="http://x", model="zen", token="")
|
||||
)
|
||||
assert result["ops"] == []
|
||||
assert "KSampler" in result["reply"]
|
||||
|
||||
|
||||
def test_run_copilot_sends_tool_and_system(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_post(url, token, payload):
|
||||
captured.update(payload)
|
||||
return _tool_completion("ok", [])
|
||||
|
||||
monkeypatch.setattr(copilot, "_post_chat", fake_post)
|
||||
body = {
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"graph_json": {"1": {"class_type": "KSampler", "inputs": {}}},
|
||||
"catalog": {"types": ["KSampler"], "nodes": {}},
|
||||
}
|
||||
asyncio.run(copilot.run_copilot(body, VALID, url="http://x", model="zen", token=""))
|
||||
assert captured["tools"][0]["function"]["name"] == "edit_graph"
|
||||
assert captured["messages"][0]["role"] == "system"
|
||||
assert "KSampler" in captured["messages"][0]["content"]
|
||||
# user turn is forwarded after the system prompt
|
||||
assert captured["messages"][-1] == {"role": "user", "content": "hi"}
|
||||
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Standalone tests for the engine selector (/v1/engines backing logic).
|
||||
Thin wrapper over the per-org compute registry — no GPU, no torch, no network.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
import folder_paths
|
||||
from middleware import compute_config, engine_selector
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def org(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(folder_paths, "get_user_directory", lambda: str(tmp_path))
|
||||
return "acme"
|
||||
|
||||
|
||||
def test_local_always_present(org):
|
||||
out = engine_selector.list_engines(org)
|
||||
ids = [e["id"] for e in out["engines"]]
|
||||
assert ids == ["local"]
|
||||
assert out["default"] == "local"
|
||||
|
||||
|
||||
def test_registered_worker_listed(org):
|
||||
compute_config.register_worker(org, {
|
||||
"worker_id": "gb10-1", "url": "http://gb10:8188",
|
||||
"device": "cuda", "status": "ready", "gpu_model": "GB10", "vram_gb": 96,
|
||||
})
|
||||
out = engine_selector.list_engines(org)
|
||||
ids = {e["id"] for e in out["engines"]}
|
||||
assert ids == {"local", "gb10-1"}
|
||||
gb10 = next(e for e in out["engines"] if e["id"] == "gb10-1")
|
||||
assert gb10["type"] == "worker" and gb10["status"] == "ready" and gb10["device"] == "cuda"
|
||||
|
||||
|
||||
def test_set_and_resolve_default(org):
|
||||
compute_config.register_worker(org, {
|
||||
"worker_id": "gb10-1", "url": "http://gb10:8188", "device": "cuda", "status": "ready",
|
||||
})
|
||||
compute_config.update_config(org, {"default_engine": "gb10-1"})
|
||||
assert engine_selector.list_engines(org)["default"] == "gb10-1"
|
||||
w = engine_selector.resolve_engine_worker(org)
|
||||
assert w is not None and w.worker_id == "gb10-1"
|
||||
|
||||
|
||||
def test_default_local_resolves_none(org):
|
||||
assert engine_selector.resolve_engine_worker(org) is None
|
||||
|
||||
|
||||
def test_stale_default_falls_back_to_local(org):
|
||||
# Default names a worker that never registered → list reports local, no 500.
|
||||
compute_config.update_config(org, {"default_engine": "ghost"})
|
||||
assert engine_selector.list_engines(org)["default"] == "local"
|
||||
assert engine_selector.resolve_engine_worker(org) is None
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Unit tests for BYO-GPU dispatch node identity (middleware/gpu_dispatch.py)."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from middleware import gpu_dispatch as g
|
||||
|
||||
|
||||
def test_node_label_prefers_human_name():
|
||||
assert g._node_label({"name": "spark", "hostname": "h", "gpu": True}) == "spark"
|
||||
assert g._node_label({"hostname": "dbc-01", "gpu": True}) == "dbc-01"
|
||||
assert g._node_label({"worker_id": "gpu-acme-123"}) == "gpu-acme-123"
|
||||
assert g._node_label({"gpu_model": "RTX 4090"}) == "RTX 4090"
|
||||
assert g._node_label({}) == "gpu" # never blank
|
||||
|
||||
|
||||
def test_online_gpu_nodes_filters_offline_and_non_gpu(monkeypatch):
|
||||
monkeypatch.setattr(g, "_fleet_machines", lambda tok: [
|
||||
{"name": "spark", "status": "online", "gpu": True},
|
||||
{"name": "idle", "status": "offline", "gpu": True},
|
||||
{"name": "cpu-box", "status": "online", "gpu": False},
|
||||
])
|
||||
assert [m["name"] for m in g._online_gpu_nodes("tok")] == ["spark"]
|
||||
assert g._has_online_gpu("tok") is True
|
||||
|
||||
|
||||
def test_no_online_gpu_when_all_offline(monkeypatch):
|
||||
monkeypatch.setattr(g, "_fleet_machines", lambda tok: [
|
||||
{"name": "spark", "status": "offline", "gpu": True},
|
||||
])
|
||||
assert g._online_gpu_nodes("tok") == []
|
||||
assert g._has_online_gpu("tok") is False
|
||||
@@ -142,3 +142,57 @@ def test_public_paths(path):
|
||||
@pytest.mark.parametrize("path", ["/prompt", "/api/prompt", "/userdata/x", "/v1/compute/config"])
|
||||
def test_protected_paths(path):
|
||||
assert m._is_public_path(path) is False
|
||||
|
||||
|
||||
# --- server-owned login entry point (/login → OIDC code flow) ---
|
||||
|
||||
def test_login_path_is_public():
|
||||
# /login is served by the middleware's own handler, so it must bypass the
|
||||
# token gate (it is the very thing an unauthenticated user clicks).
|
||||
assert m._is_public_path("/login") is True
|
||||
|
||||
|
||||
def test_middleware_exposes_login_and_callback_handlers():
|
||||
mw = m.create_iam_auth_middleware("https://hanzo.id/")
|
||||
assert callable(getattr(mw, "handle_login", None))
|
||||
assert callable(getattr(mw, "handle_callback", None))
|
||||
|
||||
|
||||
def test_handle_login_unauthenticated_starts_oidc_code_flow():
|
||||
"""The SPA button hits /login; the server builds the ONE authorize URL —
|
||||
client_id=hanzo-studio, redirect_uri=<origin>/callback — never a root redirect."""
|
||||
import asyncio
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
|
||||
mw = m.create_iam_auth_middleware("https://hanzo.id")
|
||||
req = make_mocked_request(
|
||||
"GET", "/login",
|
||||
headers={"X-Forwarded-Proto": "https", "X-Forwarded-Host": "studio.hanzo.ai"},
|
||||
)
|
||||
resp = asyncio.run(mw.handle_login(req))
|
||||
assert resp.status == 302
|
||||
loc = resp.headers["Location"]
|
||||
assert loc.startswith("https://hanzo.id/v1/iam/oauth/authorize?")
|
||||
assert "client_id=hanzo-studio" in loc
|
||||
# aiohttp returns the Location header URL-decoded; the wire value is %-encoded.
|
||||
assert "redirect_uri=https://studio.hanzo.ai/callback" in loc
|
||||
# The pending-login state cookie is set so /callback can verify PKCE + CSRF.
|
||||
assert m._STATE_COOKIE in resp.cookies
|
||||
|
||||
|
||||
def test_handle_login_returns_to_app_root_not_the_login_path():
|
||||
"""Post-login must land on the app root ('/'), never bounce back to '/login'
|
||||
(which would re-trigger the flow). The signed return path proves it."""
|
||||
import asyncio
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
|
||||
mw = m.create_iam_auth_middleware("https://hanzo.id")
|
||||
req = make_mocked_request(
|
||||
"GET", "/login",
|
||||
headers={"X-Forwarded-Proto": "https", "X-Forwarded-Host": "studio.hanzo.ai"},
|
||||
)
|
||||
resp = asyncio.run(mw.handle_login(req))
|
||||
cookie = resp.cookies[m._STATE_COOKIE].value
|
||||
# State is HMAC-signed with the client_id key (see _sign_state usage).
|
||||
payload = m._verify_state(cookie, "hanzo-studio")
|
||||
assert payload is not None and payload["t"] == "/"
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Tests for the /v1/mcp MCP-over-HTTP endpoint: initialize, tools/list, the
|
||||
tools/call dispatch, DispatchError -> isError result, and unknown-method error.
|
||||
|
||||
Mirrors session_test.py: a real aiohttp app with a middleware that injects the
|
||||
IAM user the auth layer would have set, so org resolution and the JSON-RPC
|
||||
envelopes are the real thing. The render dispatch (studio_home.dispatch_fix /
|
||||
dispatch_compose) is monkeypatched, so no GPU / self-POST is needed.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from middleware import mcp
|
||||
from middleware import studio_home
|
||||
|
||||
ORG_USER = {"sub": "hanzo/antje", "org_id": "hanzo", "orgs": ["hanzo"]}
|
||||
|
||||
|
||||
def _app(iam_user=ORG_USER):
|
||||
app = web.Application()
|
||||
|
||||
@web.middleware
|
||||
async def inject(request, handler):
|
||||
if iam_user is not None:
|
||||
request["iam_user"] = iam_user
|
||||
return await handler(request)
|
||||
|
||||
app.middlewares.append(inject)
|
||||
routes = web.RouteTableDef()
|
||||
mcp.add_mcp_routes(routes, None)
|
||||
app.add_routes(routes)
|
||||
return app
|
||||
|
||||
|
||||
def _rpc(client, method, params=None, id_=1):
|
||||
payload = {"jsonrpc": "2.0", "id": id_, "method": method}
|
||||
if params is not None:
|
||||
payload["params"] = params
|
||||
return client.post("/v1/mcp", json=payload)
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
# --- initialize ----------------------------------------------------------
|
||||
|
||||
def test_initialize_reports_server_and_tools_capability():
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app())) as c:
|
||||
r = await _rpc(c, "initialize")
|
||||
assert r.status == 200
|
||||
return await r.json()
|
||||
body = _run(go())
|
||||
assert body["jsonrpc"] == "2.0"
|
||||
assert body["id"] == 1
|
||||
assert body["result"]["serverInfo"]["name"] == "hanzo-studio"
|
||||
assert body["result"]["protocolVersion"]
|
||||
assert "tools" in body["result"]["capabilities"]
|
||||
|
||||
|
||||
# --- tools/list ----------------------------------------------------------
|
||||
|
||||
def test_tools_list_returns_the_three_render_tools():
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app())) as c:
|
||||
r = await _rpc(c, "tools/list")
|
||||
return await r.json()
|
||||
body = _run(go())
|
||||
tools = body["result"]["tools"]
|
||||
assert {t["name"] for t in tools} == {"fix", "compose", "ask"}
|
||||
for t in tools:
|
||||
assert t["inputSchema"]["type"] == "object"
|
||||
compose = next(t for t in tools if t["name"] == "compose")
|
||||
assert compose["inputSchema"]["properties"]["paths"]["minItems"] == 2
|
||||
assert compose["inputSchema"]["properties"]["paths"]["maxItems"] == 5
|
||||
|
||||
|
||||
# --- tools/call ----------------------------------------------------------
|
||||
|
||||
def test_tools_call_fix_dispatches(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
async def fake_fix(request, server, org, path, instruction):
|
||||
seen.update(org=org, path=path, instruction=instruction)
|
||||
return {"ok": True, "prompt_id": "p1", "output_prefix": "fixes/x"}
|
||||
|
||||
monkeypatch.setattr(studio_home, "dispatch_fix", fake_fix)
|
||||
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app())) as c:
|
||||
r = await _rpc(c, "tools/call", {
|
||||
"name": "fix",
|
||||
"arguments": {"path": "a/b.png", "instruction": "make it red"},
|
||||
})
|
||||
assert r.status == 200
|
||||
return await r.json()
|
||||
body = _run(go())
|
||||
assert seen == {"org": "hanzo", "path": "a/b.png", "instruction": "make it red"}
|
||||
result = body["result"]
|
||||
assert result.get("isError") is not True
|
||||
payload = json.loads(result["content"][0]["text"])
|
||||
assert payload["prompt_id"] == "p1"
|
||||
assert payload["output_prefix"] == "fixes/x"
|
||||
|
||||
|
||||
def test_tools_call_compose_dispatches(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
async def fake_compose(request, server, org, paths, instruction):
|
||||
seen.update(org=org, paths=paths, instruction=instruction)
|
||||
return {"ok": True, "prompt_id": "p2", "output_prefix": "composes/y", "refs": len(paths)}
|
||||
|
||||
monkeypatch.setattr(studio_home, "dispatch_compose", fake_compose)
|
||||
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app())) as c:
|
||||
r = await _rpc(c, "tools/call", {
|
||||
"name": "compose",
|
||||
"arguments": {"paths": ["a.png", "b.png"], "instruction": "merge them"},
|
||||
})
|
||||
return await r.json()
|
||||
body = _run(go())
|
||||
assert seen["paths"] == ["a.png", "b.png"]
|
||||
payload = json.loads(body["result"]["content"][0]["text"])
|
||||
assert payload["prompt_id"] == "p2"
|
||||
assert payload["refs"] == 2
|
||||
|
||||
|
||||
def test_tools_call_ask_echoes_question():
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app())) as c:
|
||||
r = await _rpc(c, "tools/call", {
|
||||
"name": "ask", "arguments": {"question": "which asset?"},
|
||||
})
|
||||
return await r.json()
|
||||
body = _run(go())
|
||||
payload = json.loads(body["result"]["content"][0]["text"])
|
||||
assert payload["question"] == "which asset?"
|
||||
assert body["result"].get("isError") is not True
|
||||
|
||||
|
||||
def test_dispatch_error_becomes_iserror_result(monkeypatch):
|
||||
async def bad_fix(request, server, org, path, instruction):
|
||||
raise studio_home.DispatchError("unknown asset: nope.png")
|
||||
|
||||
monkeypatch.setattr(studio_home, "dispatch_fix", bad_fix)
|
||||
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app())) as c:
|
||||
r = await _rpc(c, "tools/call", {
|
||||
"name": "fix", "arguments": {"path": "nope.png", "instruction": "x"},
|
||||
})
|
||||
assert r.status == 200 # never a 500
|
||||
return await r.json()
|
||||
body = _run(go())
|
||||
assert "error" not in body # not a JSON-RPC error
|
||||
assert body["result"]["isError"] is True
|
||||
assert "unknown asset" in body["result"]["content"][0]["text"]
|
||||
|
||||
|
||||
def test_unknown_tool_is_iserror_result():
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app())) as c:
|
||||
r = await _rpc(c, "tools/call", {"name": "frobnicate", "arguments": {}})
|
||||
return await r.json()
|
||||
body = _run(go())
|
||||
assert body["result"]["isError"] is True
|
||||
|
||||
|
||||
# --- protocol edges ------------------------------------------------------
|
||||
|
||||
def test_unknown_method_is_jsonrpc_error():
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app())) as c:
|
||||
r = await _rpc(c, "tools/frobnicate")
|
||||
assert r.status == 200
|
||||
return await r.json()
|
||||
body = _run(go())
|
||||
assert "result" not in body
|
||||
assert body["error"]["code"] == -32601
|
||||
assert body["id"] == 1
|
||||
|
||||
|
||||
def test_notification_gets_no_body():
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app())) as c:
|
||||
r = await c.post("/v1/mcp", json={"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||||
return r.status, await r.text()
|
||||
status, text = _run(go())
|
||||
assert status == 202
|
||||
assert text == ""
|
||||
|
||||
|
||||
def test_bad_json_is_parse_error():
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app())) as c:
|
||||
r = await c.post("/v1/mcp", data="not json", headers={"Content-Type": "application/json"})
|
||||
return r.status, await r.json()
|
||||
status, body = _run(go())
|
||||
assert status == 400
|
||||
assert body["error"]["code"] == -32700
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Tests for the identity / session API and the active-org override.
|
||||
|
||||
The pure org-resolution rule is checked directly; the ``/v1/session`` +
|
||||
``/v1/session/org`` + ``/v1/session/project`` + ``/logout`` routes are exercised
|
||||
against a real aiohttp app with a middleware that injects the IAM user the auth
|
||||
layer would have set, so cookie round-trips and status codes are the real thing.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from middleware import iam_auth_middleware as m
|
||||
from middleware import session as s
|
||||
|
||||
|
||||
# --- pure org resolution -------------------------------------------------
|
||||
|
||||
def test_resolve_org_switches_to_member_org():
|
||||
assert s.resolve_org("acme", ["acme", "globex"], "globex") == "globex"
|
||||
|
||||
|
||||
def test_resolve_org_rejects_non_member():
|
||||
assert s.resolve_org("acme", ["acme"], "globex") == "acme"
|
||||
|
||||
|
||||
def test_resolve_org_no_cookie_keeps_base():
|
||||
assert s.resolve_org("acme", ["acme", "globex"], None) == "acme"
|
||||
|
||||
|
||||
# --- org list extraction from claims ------------------------------------
|
||||
|
||||
def test_orgs_home_first_and_deduped():
|
||||
orgs = m._orgs_from_claims({"owner": "acme", "organizations": ["acme", "globex"]})
|
||||
assert orgs == ["acme", "globex"]
|
||||
|
||||
|
||||
def test_orgs_from_comma_string():
|
||||
assert m._orgs_from_claims({"owner": "acme", "orgs": "globex, initech"}) == ["acme", "globex", "initech"]
|
||||
|
||||
|
||||
def test_orgs_from_group_dicts():
|
||||
orgs = m._orgs_from_claims({"organization": "acme", "groups": [{"name": "globex"}, {"name": "acme"}]})
|
||||
assert orgs == ["acme", "globex"]
|
||||
|
||||
|
||||
def test_orgs_defaults_to_default():
|
||||
assert m._orgs_from_claims({}) == ["default"]
|
||||
|
||||
|
||||
def test_claims_to_user_carries_orgs():
|
||||
user = m._claims_to_user({"owner": "acme", "organizations": ["globex"], "name": "A", "email": "a@x"})
|
||||
assert user["org_id"] == "acme"
|
||||
assert user["orgs"] == ["acme", "globex"]
|
||||
|
||||
|
||||
# --- active-org override (auth-on path, copy not mutation) ---------------
|
||||
|
||||
def test_with_active_org_switches_and_copies():
|
||||
base = {"org_id": "acme", "orgs": ["acme", "globex"], "sub": "u"}
|
||||
got = m._with_active_org(base, "globex")
|
||||
assert got["org_id"] == "globex"
|
||||
assert base["org_id"] == "acme" # original never mutated -> cache not poisoned
|
||||
|
||||
|
||||
def test_with_active_org_non_member_is_noop():
|
||||
base = {"org_id": "acme", "orgs": ["acme"]}
|
||||
assert m._with_active_org(base, "globex") is base
|
||||
|
||||
|
||||
# --- route integration ---------------------------------------------------
|
||||
|
||||
def _app(iam_user):
|
||||
app = web.Application()
|
||||
|
||||
@web.middleware
|
||||
async def inject(request, handler):
|
||||
if iam_user is not None:
|
||||
request["iam_user"] = iam_user
|
||||
return await handler(request)
|
||||
|
||||
app.middlewares.append(inject)
|
||||
routes = web.RouteTableDef()
|
||||
s.add_session_routes(routes, None)
|
||||
app.add_routes(routes)
|
||||
return app
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
ANTJE = {"sub": "hanzo/antje", "name": "Antje", "email": "a@hanzo.ai",
|
||||
"org_id": "hanzo", "orgs": ["hanzo", "labs"], "avatar": "https://cdn/a.png"}
|
||||
|
||||
|
||||
def test_session_returns_identity():
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app(ANTJE))) as c:
|
||||
r = await c.get("/v1/session")
|
||||
assert r.status == 200
|
||||
return await r.json()
|
||||
body = _run(go())
|
||||
assert body["authenticated"] is True
|
||||
assert body["user"] == "Antje"
|
||||
assert body["email"] == "a@hanzo.ai"
|
||||
assert body["org"] == "hanzo"
|
||||
assert body["orgs"] == ["hanzo", "labs"]
|
||||
assert body["project"] == "default"
|
||||
assert body["console_url"].startswith("http")
|
||||
|
||||
|
||||
def test_session_local_stub_when_unauthenticated():
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app(None))) as c:
|
||||
r = await c.get("/v1/session")
|
||||
return r.status, await r.json()
|
||||
status, body = _run(go())
|
||||
assert status == 200
|
||||
assert body["authenticated"] is False
|
||||
assert body["user"] == "Local User"
|
||||
|
||||
|
||||
def test_org_switch_sets_cookie_and_rescopes():
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app(ANTJE))) as c:
|
||||
r = await c.post("/v1/session/org", json={"org": "labs"})
|
||||
assert r.status == 200
|
||||
assert (await r.json())["org"] == "labs"
|
||||
# The cookie the switch set now re-scopes the session response.
|
||||
r2 = await c.get("/v1/session")
|
||||
return (await r2.json())["org"]
|
||||
assert _run(go()) == "labs"
|
||||
|
||||
|
||||
def test_org_switch_rejects_non_member():
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app(ANTJE))) as c:
|
||||
r = await c.post("/v1/session/org", json={"org": "evilcorp"})
|
||||
return r.status
|
||||
assert _run(go()) == 403
|
||||
|
||||
|
||||
def test_org_switch_requires_org():
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app(ANTJE))) as c:
|
||||
r = await c.post("/v1/session/org", json={})
|
||||
return r.status
|
||||
assert _run(go()) == 400
|
||||
|
||||
|
||||
def test_project_switch_roundtrips():
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app(ANTJE))) as c:
|
||||
r = await c.post("/v1/session/project", json={"project": "fashion"})
|
||||
assert r.status == 200
|
||||
r2 = await c.get("/v1/session")
|
||||
return (await r2.json())["project"]
|
||||
assert _run(go()) == "fashion"
|
||||
|
||||
|
||||
def test_project_switch_rejects_path_traversal():
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app(ANTJE))) as c:
|
||||
r = await c.post("/v1/session/project", json={"project": "../etc"})
|
||||
return r.status
|
||||
assert _run(go()) == 400
|
||||
|
||||
|
||||
def test_logout_clears_session_cookie_and_redirects():
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app(ANTJE))) as c:
|
||||
r = await c.get("/logout", allow_redirects=False)
|
||||
return r.status, r.headers.get("Location", ""), r.headers
|
||||
status, location, headers = _run(go())
|
||||
assert status == 302
|
||||
assert "/login" in location
|
||||
# The auth cookie is expired (cleared) by the logout response.
|
||||
set_cookies = "; ".join(headers.getall("Set-Cookie", []))
|
||||
assert s.SESSION_COOKIE in set_cookies
|
||||
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Standalone tests for SqlitePromptQueue — Hanzo Studio's crash-durable render
|
||||
queue. A fake torch-free `execution` module supplies the PromptQueue base, so
|
||||
this runs with no GPU, no torch, and no external process (stdlib sqlite3 only).
|
||||
|
||||
Covers the queue seam (put/get/task_done), exactly-once claim, retry-on-error,
|
||||
and — the headline — crash-recovery: a job claimed and then abandoned by a
|
||||
"crashed" queue instance is reaped and re-run to completion by a fresh instance
|
||||
on the same DB file.
|
||||
"""
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
from collections import namedtuple
|
||||
|
||||
import pytest
|
||||
|
||||
ExecStatus = namedtuple("ExecStatus", ["status_str", "completed", "messages"])
|
||||
|
||||
|
||||
def _fake_execution_module():
|
||||
mod = types.ModuleType("execution")
|
||||
|
||||
class PromptQueue:
|
||||
def __init__(self, server):
|
||||
self.server = server
|
||||
self.mutex = threading.RLock()
|
||||
self.task_counter = 0
|
||||
self.queue = []
|
||||
self.currently_running = {}
|
||||
self.history = {}
|
||||
self.flags = {}
|
||||
self._persist_path = None
|
||||
|
||||
def task_done(self, item_id, history_result, status, process_item=None):
|
||||
self.currently_running.pop(item_id, None)
|
||||
|
||||
mod.PromptQueue = PromptQueue
|
||||
return mod
|
||||
|
||||
|
||||
class FakeServer:
|
||||
def __init__(self):
|
||||
self.updates = 0
|
||||
|
||||
def queue_updated(self):
|
||||
self.updates += 1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tqmod(monkeypatch):
|
||||
monkeypatch.setenv("STUDIO_TASKS_POLL_INTERVAL", "0.05")
|
||||
monkeypatch.setenv("STUDIO_TASKS_LEASE_MS", "300") # short lease → fast reap
|
||||
monkeypatch.setenv("STUDIO_TASKS_MAX_ATTEMPTS", "3")
|
||||
saved = {k: sys.modules.get(k) for k in ("execution", "middleware.tasks_queue")}
|
||||
sys.modules["execution"] = _fake_execution_module()
|
||||
sys.modules.pop("middleware.tasks_queue", None)
|
||||
import middleware.tasks_queue as tq
|
||||
try:
|
||||
yield tq
|
||||
finally:
|
||||
for k, v in saved.items():
|
||||
if v is None:
|
||||
sys.modules.pop(k, None)
|
||||
else:
|
||||
sys.modules[k] = v
|
||||
|
||||
|
||||
def _item(pid="prompt-1"):
|
||||
return (0, pid, {"1": {"class_type": "EmptyImage"}}, {"org_id": "acme"}, ["1"], {})
|
||||
|
||||
|
||||
def _queue(tqmod, tmp_path, freeze_hb=True):
|
||||
import os
|
||||
os.environ["STUDIO_QUEUE_DB"] = str(tmp_path / "queue.db")
|
||||
q = tqmod.SqlitePromptQueue(FakeServer())
|
||||
if freeze_hb:
|
||||
q._hb_stop.set() # deterministic: no background reaper/heartbeat
|
||||
return q
|
||||
|
||||
|
||||
def test_serde_roundtrip(tqmod):
|
||||
assert tqmod.deserialize_item(tqmod.serialize_item(_item())) == _item()
|
||||
|
||||
|
||||
def test_put_get_task_done(tqmod, tmp_path):
|
||||
q = _queue(tqmod, tmp_path)
|
||||
q.put(_item())
|
||||
assert q.server.updates == 1
|
||||
item, item_id = q.get(timeout=1.0)
|
||||
assert item == _item()
|
||||
assert q.currently_running[item_id] == _item()
|
||||
assert q.job_state("prompt-1")[0] == "claimed"
|
||||
q.task_done(item_id, {"outputs": {"2": {"images": [{"filename": "x_00001_.png"}]}}},
|
||||
ExecStatus("success", True, []))
|
||||
assert q.job_state("prompt-1")[0] == "done"
|
||||
assert item_id not in q._job_by_item
|
||||
|
||||
|
||||
def test_put_is_idempotent(tqmod, tmp_path):
|
||||
q = _queue(tqmod, tmp_path)
|
||||
q.put(_item())
|
||||
q.put(_item()) # same prompt_id → INSERT OR IGNORE, no duplicate
|
||||
q.get(timeout=1.0)
|
||||
assert q.get(timeout=0.2) is None # only one job existed
|
||||
|
||||
|
||||
def test_exactly_once_claim(tqmod, tmp_path):
|
||||
q = _queue(tqmod, tmp_path)
|
||||
q.put(_item())
|
||||
first = q.get(timeout=1.0)
|
||||
assert first is not None
|
||||
assert q.get(timeout=0.2) is None # already claimed — no double dispatch
|
||||
|
||||
|
||||
def test_error_retries_then_fails(tqmod, tmp_path):
|
||||
q = _queue(tqmod, tmp_path)
|
||||
q.put(_item()) # max_attempts=3
|
||||
# attempt 1 → error → back to pending (attempt 2)
|
||||
_, item_id = q.get(timeout=1.0)
|
||||
q.task_done(item_id, {}, ExecStatus("error", False, ["boom-1"]))
|
||||
assert q.job_state("prompt-1") == ("pending", 2)
|
||||
# attempt 2 → error → pending (attempt 3)
|
||||
_, item_id = q.get(timeout=1.0)
|
||||
q.task_done(item_id, {}, ExecStatus("error", False, ["boom-2"]))
|
||||
assert q.job_state("prompt-1") == ("pending", 3)
|
||||
# attempt 3 → error → terminal failed
|
||||
_, item_id = q.get(timeout=1.0)
|
||||
q.task_done(item_id, {}, ExecStatus("error", False, ["boom-3"]))
|
||||
assert q.job_state("prompt-1")[0] == "failed"
|
||||
|
||||
|
||||
def test_crash_recovery_reap(tqmod, tmp_path):
|
||||
import time
|
||||
q = _queue(tqmod, tmp_path)
|
||||
q.put(_item())
|
||||
_, _item_id = q.get(timeout=1.0) # claimed, leased
|
||||
assert q.job_state("prompt-1")[0] == "claimed"
|
||||
time.sleep(0.4) # lease (0.3s) expires: worker "crashed"
|
||||
assert q.get(timeout=1.0) is not None # get() reaps + re-claims for a new attempt
|
||||
assert q.job_state("prompt-1") == ("claimed", 2)
|
||||
|
||||
|
||||
def test_crash_recovery_across_instances(tqmod, tmp_path):
|
||||
"""The headline: a job in flight when a queue instance dies is picked up
|
||||
and completed by a fresh instance on the same DB file."""
|
||||
import time
|
||||
db = str(tmp_path / "queue.db")
|
||||
import os
|
||||
os.environ["STUDIO_QUEUE_DB"] = db
|
||||
|
||||
# Instance A: submit, claim, then "crash" (drop the instance without completing).
|
||||
a = tqmod.SqlitePromptQueue(FakeServer())
|
||||
a._hb_stop.set()
|
||||
a.put(_item())
|
||||
a.get(timeout=1.0)
|
||||
assert a.job_state("prompt-1")[0] == "claimed"
|
||||
a.stop() # process dies mid-render
|
||||
|
||||
time.sleep(0.4) # lease expires
|
||||
|
||||
# Instance B: same DB. Recovers the orphaned job and finishes it.
|
||||
b = tqmod.SqlitePromptQueue(FakeServer()) # __init__ reaps on boot
|
||||
b._hb_stop.set()
|
||||
item, item_id = b.get(timeout=1.0)
|
||||
assert item == _item()
|
||||
assert b.job_state("prompt-1") == ("claimed", 2) # attempt 2 on the new worker
|
||||
b.task_done(item_id, {"outputs": {}}, ExecStatus("success", True, []))
|
||||
assert b.job_state("prompt-1")[0] == "done"
|
||||
b.stop()
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Unit tests for the per-org work log (middleware/worklog.py)."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from middleware import worklog
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def orgdirs(tmp_path, monkeypatch):
|
||||
"""Map each org to its own output dir, exactly as folder_paths does per-org, so
|
||||
the tests exercise real cross-tenant isolation (one file per org)."""
|
||||
def out_dir(org=None):
|
||||
d = tmp_path / "orgs" / (org or "default") / "output"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return str(d)
|
||||
monkeypatch.setattr(worklog.folder_paths, "get_org_output_directory", out_dir)
|
||||
return out_dir
|
||||
|
||||
|
||||
def test_record_and_load_roundtrip(orgdirs):
|
||||
row = worklog.record("acme", kind="fix", prompt="brighten", refs=["r.png"],
|
||||
uploads=[], parents=["a.png"], output_prefix="fixes/a",
|
||||
pid="pid-1", lane="gpu")
|
||||
assert row["id"] == "pid-1" and row["status"] == "queued" and row["lane"] == "gpu"
|
||||
rows = worklog.load("acme")
|
||||
assert len(rows) == 1 and rows[0]["parents"] == ["a.png"] and rows[0]["kind"] == "fix"
|
||||
|
||||
|
||||
def test_failed_dispatch_still_recorded_with_reason(orgdirs):
|
||||
row = worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[],
|
||||
parents=["a.png"], output_prefix="fixes/a",
|
||||
status="failed", error="no GPU worker online for this org")
|
||||
assert row["status"] == "failed" and row["id"].startswith("failed-")
|
||||
assert "no GPU worker" in row["error"]
|
||||
assert any(e["s"] == "failed" for e in row["events"]) # visible, not swallowed
|
||||
|
||||
|
||||
def test_set_status_advances_but_respects_terminal(orgdirs):
|
||||
worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
|
||||
output_prefix="fixes/a", pid="p1")
|
||||
assert worklog.set_status("acme", "p1", "running")
|
||||
assert worklog.load("acme")[0]["status"] == "running"
|
||||
assert worklog.set_status("acme", "p1", "cancelled")
|
||||
# a terminal row is frozen — a late 'done' can't resurrect a cancelled job
|
||||
assert not worklog.set_status("acme", "p1", "done")
|
||||
assert worklog.load("acme")[0]["status"] == "cancelled"
|
||||
|
||||
|
||||
def test_cross_tenant_isolation(orgdirs):
|
||||
worklog.record("acme", kind="fix", prompt="secret acme prompt", refs=[], uploads=[],
|
||||
parents=["a.png"], output_prefix="fixes/a", pid="p1")
|
||||
# a different org's log is a different file — zero leakage either direction
|
||||
assert worklog.load("acme") and worklog.load("globex") == []
|
||||
worklog.record("globex", kind="fix", prompt="globex prompt", refs=[], uploads=[],
|
||||
parents=["b.png"], output_prefix="fixes/b", pid="p2")
|
||||
assert [r["prompt"] for r in worklog.load("acme")] == ["secret acme prompt"]
|
||||
assert [r["prompt"] for r in worklog.load("globex")] == ["globex prompt"]
|
||||
|
||||
|
||||
def test_cap_keeps_newest(orgdirs, monkeypatch):
|
||||
monkeypatch.setattr(worklog, "_CAP", 5)
|
||||
for i in range(12):
|
||||
worklog.record("acme", kind="fix", prompt=f"p{i}", refs=[], uploads=[],
|
||||
parents=[], output_prefix=f"fixes/{i}", pid=f"p{i}")
|
||||
rows = worklog.load("acme")
|
||||
assert len(rows) == 5 and [r["prompt"] for r in rows] == [f"p{i}" for i in range(7, 12)]
|
||||
|
||||
|
||||
def test_build_lineage_chain():
|
||||
# a.png --fix--> b.png --fix--> c.png ; and a.png --fix--> d.png (a sibling branch)
|
||||
rows = [
|
||||
{"kind": "fix", "prompt": "step1", "ts": 1, "parents": ["a.png"], "output": "b.png"},
|
||||
{"kind": "fix", "prompt": "step2", "ts": 2, "parents": ["b.png"], "output": "c.png"},
|
||||
{"kind": "fix", "prompt": "branch", "ts": 3, "parents": ["a.png"], "output": "d.png"},
|
||||
]
|
||||
lin = worklog.build_lineage(rows, "c.png")
|
||||
assert [a["path"] for a in lin["ancestors"]] == ["a.png", "b.png"] # oldest first
|
||||
assert lin["node"] == "c.png"
|
||||
assert lin["descendants"] == []
|
||||
# from the root, descendants include both branches, chronological
|
||||
lin2 = worklog.build_lineage(rows, "a.png")
|
||||
assert [d["path"] for d in lin2["descendants"]] == ["b.png", "d.png", "c.png"]
|
||||
assert lin2["ancestors"] == []
|
||||
|
||||
|
||||
def test_record_stores_node(orgdirs):
|
||||
r = worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
|
||||
output_prefix="f", pid="p1", lane="gpu", node="spark")
|
||||
assert r["node"] == "spark"
|
||||
r2 = worklog.record("acme", kind="fix", prompt="y", refs=[], uploads=[], parents=[],
|
||||
output_prefix="g", pid="p2", lane="local")
|
||||
assert r2["node"] == "studio pod" # local jobs run on the pod
|
||||
|
||||
|
||||
def test_mark_started_finished_and_median(orgdirs):
|
||||
worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
|
||||
output_prefix="fixes/x", pid="p1")
|
||||
assert worklog.mark_started("acme", "p1", 1000)
|
||||
r = worklog.load("acme")[0]
|
||||
assert r["started_at"] == 1000 and r["status"] == "running"
|
||||
assert not worklog.mark_started("acme", "p1", 2000) # idempotent: keeps first
|
||||
assert worklog.load("acme")[0]["started_at"] == 1000
|
||||
assert worklog.mark_finished("acme", "p1", 1050)
|
||||
r = worklog.load("acme")[0]
|
||||
assert r["finished_at"] == 1050 and r["status"] == "done"
|
||||
assert worklog.median_durations(worklog.load("acme"))["fix"] == 50 # 1050-1000
|
||||
|
||||
|
||||
def test_median_durations_per_kind():
|
||||
rows = [
|
||||
{"kind": "fix", "ts": 100, "started_at": 100, "finished_at": 110}, # 10
|
||||
{"kind": "fix", "ts": 200, "started_at": 200, "finished_at": 220}, # 20
|
||||
{"kind": "fix", "ts": 300, "started_at": 300, "finished_at": 360}, # 60 → median 20
|
||||
{"kind": "compose", "ts": 0, "started_at": 0, "finished_at": 100}, # 100
|
||||
{"kind": "fix", "ts": 400}, # unfinished → ignored
|
||||
]
|
||||
m = worklog.median_durations(rows)
|
||||
assert m == {"fix": 20, "compose": 100}
|
||||
# no started_at → duration falls back to finished − queued(ts)
|
||||
assert worklog.median_durations([{"kind": "rerun", "ts": 0, "finished_at": 30}]) == {"rerun": 30}
|
||||
|
||||
|
||||
def test_estimate_lanes_positions_and_eta():
|
||||
now = 1000
|
||||
active = [
|
||||
{"id": "a", "kind": "fix", "lane": "gpu", "ts": 100, "status": "running", "started_at": now - 60, "node": "spark"},
|
||||
{"id": "b", "kind": "fix", "lane": "gpu", "ts": 200, "status": "queued", "node": "spark"},
|
||||
{"id": "c", "kind": "compose", "lane": "gpu", "ts": 300, "status": "queued", "node": "spark"},
|
||||
{"id": "d", "kind": "fix", "lane": "local", "ts": 150, "status": "queued", "node": "studio pod"},
|
||||
]
|
||||
medians = {"fix": 120, "compose": 300}
|
||||
est, counts = worklog.estimate_lanes(active, medians, now)
|
||||
assert counts == {"gpu": 3, "local": 1}
|
||||
# running: elapsed 60 of median 120 → 60 remaining
|
||||
assert est["a"]["position"] == 1 and est["a"]["of"] == 3
|
||||
assert est["a"]["elapsed_seconds"] == 60 and est["a"]["remaining_seconds"] == 60 and est["a"]["eta_seconds"] == 60
|
||||
assert est["a"]["node"] == "spark"
|
||||
# queued #2: wait = running remaining(60); eta = 60 + own fix median(120)
|
||||
assert est["b"]["position"] == 2 and est["b"]["wait_seconds"] == 60 and est["b"]["eta_seconds"] == 180
|
||||
# queued #3: wait = 60 + b's fix(120) = 180; eta = 180 + compose(300)
|
||||
assert est["c"]["position"] == 3 and est["c"]["wait_seconds"] == 180 and est["c"]["eta_seconds"] == 480
|
||||
# separate lane, single queued item, nothing running → starts ~now
|
||||
assert est["d"]["position"] == 1 and est["d"]["of"] == 1 and est["d"]["wait_seconds"] == 0 and est["d"]["eta_seconds"] == 120
|
||||
|
||||
|
||||
def test_estimate_lanes_default_median_for_unknown_kind():
|
||||
est, _ = worklog.estimate_lanes(
|
||||
[{"id": "x", "kind": "video", "lane": "gpu", "ts": 1, "status": "queued"}], {}, 0, default_median=200)
|
||||
assert est["x"]["eta_seconds"] == 200
|
||||
|
||||
|
||||
def test_build_lineage_terminates_on_cycle():
|
||||
rows = [
|
||||
{"kind": "fix", "prompt": "x", "ts": 1, "parents": ["y.png"], "output": "x.png"},
|
||||
{"kind": "fix", "prompt": "y", "ts": 2, "parents": ["x.png"], "output": "y.png"},
|
||||
]
|
||||
lin = worklog.build_lineage(rows, "x.png") # must not loop forever
|
||||
assert lin["node"] == "x.png"
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Unit tests for the Studio Home surface (middleware/studio_home.py)."""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from middleware import studio_home as sh
|
||||
|
||||
|
||||
def test_reseed_randomizes_every_sampler_seed():
|
||||
graph = {
|
||||
"a": {"class_type": "KSampler", "inputs": {"seed": 7, "steps": 30}},
|
||||
"b": {"class_type": "KSamplerAdvanced", "inputs": {"noise_seed": 7}},
|
||||
"c": {"class_type": "SaveImage", "inputs": {"filename_prefix": "x"}},
|
||||
}
|
||||
out = sh._reseed(json.loads(json.dumps(graph)))
|
||||
assert out["a"]["inputs"]["steps"] == 30
|
||||
# 2**31 space: a collision with the old seed twice over is not a thing.
|
||||
assert (out["a"]["inputs"]["seed"], out["b"]["inputs"]["noise_seed"]) != (7, 7)
|
||||
|
||||
|
||||
def test_fix_graph_keeps_recipe_invariants():
|
||||
g = sh._fix_graph("ref.png", "make the straps thicker", "fixes/test")
|
||||
ks = g["13"]["inputs"]
|
||||
assert ks["cfg"] == 4.0 and ks["steps"] == 30 # proven floor, not the weak 3.0
|
||||
assert g["12"]["class_type"] == "CFGNorm"
|
||||
assert g["8"]["inputs"]["reference_latents_method"] == "index_timestep_zero"
|
||||
assert g["6"]["inputs"]["image1"] == ["2", 0] # scaled ref, never raw
|
||||
assert "straps thicker" in g["6"]["inputs"]["prompt"]
|
||||
assert g["15"]["inputs"]["filename_prefix"] == "fixes/test"
|
||||
|
||||
|
||||
def test_safe_asset_blocks_traversal(tmp_path):
|
||||
root = tmp_path / "out"
|
||||
(root / "designs").mkdir(parents=True)
|
||||
(root / "designs" / "a.png").write_bytes(b"x")
|
||||
(tmp_path / "secret.txt").write_text("no")
|
||||
assert sh._safe_asset(root, "designs/a.png") is not None
|
||||
assert sh._safe_asset(root, "../secret.txt") is None
|
||||
assert sh._safe_asset(root, "designs/missing.png") is None
|
||||
|
||||
|
||||
def test_status_vocab_matches_library_lifecycle():
|
||||
assert sh._STATUSES == ("draft", "approved", "queued", "published", "deleted")
|
||||
|
||||
|
||||
def test_resolve_uploads_keeps_only_safe_existing_images(tmp_path, monkeypatch):
|
||||
in_dir = tmp_path / "orgs" / "acme" / "input"
|
||||
in_dir.mkdir(parents=True)
|
||||
(in_dir / "ref.png").write_bytes(b"\x89PNG")
|
||||
(in_dir / "shot.jpg").write_bytes(b"jpg")
|
||||
(tmp_path / "secret.txt").write_text("no")
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda org=None: str(in_dir))
|
||||
out = sh._resolve_uploads("acme", [
|
||||
"ref.png", # kept
|
||||
"sub/dir/shot.jpg", # basename kept (upload names are flat)
|
||||
"ref.png", # de-duped
|
||||
"../secret.txt", # traversal + non-image -> dropped
|
||||
"missing.png", # absent -> dropped
|
||||
"notes.txt", # non-image -> dropped
|
||||
42, # non-string -> dropped
|
||||
])
|
||||
assert out == ["ref.png", "shot.jpg"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compose_accepts_uploaded_references(tmp_path, monkeypatch):
|
||||
"""A base library asset + one uploaded image is a valid 2-reference compose:
|
||||
the upload is named directly (already in the input dir), the library asset is
|
||||
staged, and both reach the graph — no lower bound violation."""
|
||||
org = "acme"
|
||||
root = tmp_path / "orgs" / org / "output"
|
||||
root.mkdir(parents=True)
|
||||
(root / "library.json").write_text(json.dumps({"assets": [{"path": "a.png"}]}))
|
||||
(root / "a.png").write_bytes(b"\x89PNG-base")
|
||||
in_dir = tmp_path / "orgs" / org / "input"
|
||||
in_dir.mkdir(parents=True)
|
||||
(in_dir / "up_0001_.png").write_bytes(b"\x89PNG-up")
|
||||
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda o=None: str(in_dir))
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
|
||||
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
|
||||
|
||||
captured = {}
|
||||
async def fake_queue(request, server, graph):
|
||||
captured["graph"] = graph
|
||||
return "pid-1"
|
||||
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
|
||||
|
||||
out = await sh.dispatch_compose(None, None, org, ["a.png"], "merge them", uploads=["up_0001_.png"])
|
||||
assert out["ok"] and out["refs"] == 2
|
||||
load_names = [n["inputs"]["image"] for n in captured["graph"].values()
|
||||
if n["class_type"] == "LoadImage"]
|
||||
assert "up_0001_.png" in load_names # upload referenced by its own name
|
||||
assert any(n.startswith("compose_src_") for n in load_names) # library asset staged
|
||||
|
||||
|
||||
class _FakeQueue:
|
||||
def __init__(self, running=None, pending=None):
|
||||
self.running = running or []
|
||||
self.pending = pending or []
|
||||
self.deleted = []
|
||||
|
||||
def get_current_queue_volatile(self):
|
||||
return (self.running, self.pending)
|
||||
|
||||
def delete_queue_item(self, fn):
|
||||
before = len(self.pending)
|
||||
self.pending = [it for it in self.pending if not fn(it)]
|
||||
self.deleted.append(before - len(self.pending))
|
||||
|
||||
|
||||
class _FakeServer:
|
||||
port = 1
|
||||
|
||||
def __init__(self, **kw):
|
||||
self.prompt_queue = _FakeQueue(**kw)
|
||||
|
||||
|
||||
def _orgaware(tmp_path, monkeypatch):
|
||||
def outd(o=None):
|
||||
d = tmp_path / "orgs" / (o or "default") / "output"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return str(d)
|
||||
def ind(o=None):
|
||||
d = tmp_path / "orgs" / (o or "default") / "input"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return str(d)
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", outd)
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", ind)
|
||||
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
|
||||
return outd, ind
|
||||
|
||||
|
||||
def test_cancel_marks_cancelled_and_removes_gpu_row_org_scoped(tmp_path, monkeypatch):
|
||||
outd, _ = _orgaware(tmp_path, monkeypatch)
|
||||
(Path(outd("acme")) / "library.json").write_text('{"assets":[]}')
|
||||
sh.worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
|
||||
output_prefix="fixes/x", pid="p1", lane="gpu")
|
||||
(Path(outd("acme")) / "render_jobs.json").write_text(json.dumps([{"id": "p1", "prefix": "x"}]))
|
||||
srv = _FakeServer()
|
||||
assert sh._owns_job(srv, "acme", "p1")
|
||||
assert not sh._owns_job(srv, "globex", "p1") # cross-tenant: not owned
|
||||
assert sh._cancel_job(srv, "acme", "p1") == "gpu"
|
||||
assert sh.worklog.load("acme")[0]["status"] == "cancelled"
|
||||
assert json.loads((Path(outd("acme")) / "render_jobs.json").read_text()) == []
|
||||
|
||||
|
||||
def test_cancel_deletes_local_pending_only_when_org_matches(tmp_path, monkeypatch):
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
mine = (1, "p1", {}, {"org_id": "acme"}, [])
|
||||
theirs = (2, "p2", {}, {"org_id": "globex"}, [])
|
||||
srv = _FakeServer(pending=[mine, theirs])
|
||||
sh.worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
|
||||
output_prefix="fixes/x", pid="p1")
|
||||
assert sh._cancel_job(srv, "acme", "p1") == "local"
|
||||
assert [it[1] for it in srv.prompt_queue.pending] == ["p2"] # only mine deleted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_amend_atomic_swap_keeps_original_refs_and_adds_new(tmp_path, monkeypatch):
|
||||
outd, ind = _orgaware(tmp_path, monkeypatch)
|
||||
(Path(outd("acme")) / "library.json").write_text('{"assets":[]}')
|
||||
(Path(ind("acme")) / "fix_src_1.png").write_bytes(b"\x89PNG") # original ref, still present
|
||||
(Path(ind("acme")) / "drop_9.png").write_bytes(b"\x89PNG") # new upload
|
||||
sh.worklog.record("acme", kind="fix", prompt="brighten", refs=["fix_src_1.png"],
|
||||
uploads=[], parents=["a.png"], output_prefix="fixes/a", pid="p1", lane="local")
|
||||
captured = {}
|
||||
async def fake_queue(request, server, graph):
|
||||
captured["graph"] = graph
|
||||
return "p2"
|
||||
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
|
||||
out = await sh.dispatch_amend(None, _FakeServer(), "acme", "p1", "brighten a lot more",
|
||||
paths=[], uploads=["drop_9.png"])
|
||||
assert out["amended_from"] == "p1" and out["prompt_id"] == "p2" and out["refs"] == 2
|
||||
rows = {r["id"]: r for r in sh.worklog.load("acme")}
|
||||
assert rows["p1"]["status"] == "cancelled" # original swapped out
|
||||
assert rows["p2"]["status"] == "queued" and rows["p2"]["prompt"] == "brighten a lot more"
|
||||
loads = [n["inputs"]["image"] for n in captured["graph"].values() if n.get("class_type") == "LoadImage"]
|
||||
assert "fix_src_1.png" in loads and "drop_9.png" in loads # original kept + new added
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_amend_unknown_job_is_not_owned(tmp_path, monkeypatch):
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
with pytest.raises(sh.NotOwned):
|
||||
await sh.dispatch_amend(None, _FakeServer(), "acme", "ghost", "change it")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_amend_terminal_job_rejected(tmp_path, monkeypatch):
|
||||
outd, _ = _orgaware(tmp_path, monkeypatch)
|
||||
sh.worklog.record("acme", kind="fix", prompt="x", refs=["r.png"], uploads=[], parents=[],
|
||||
output_prefix="fixes/x", pid="p1")
|
||||
sh.worklog.set_status("acme", "p1", "done")
|
||||
with pytest.raises(sh.DispatchError):
|
||||
await sh.dispatch_amend(None, _FakeServer(), "acme", "p1", "too late")
|
||||
|
||||
|
||||
def test_prompt_error_message_extracts_real_reason():
|
||||
# model-less pod: /prompt returns a validation error — the user must SEE it.
|
||||
body = {"error": {"type": "prompt_outputs_failed_validation",
|
||||
"message": "Prompt outputs failed validation",
|
||||
"details": "UNETLoader: unet_name 'qwen-image-edit-2511.safetensors' not in []"}}
|
||||
msg = sh._prompt_error_message(body)
|
||||
assert "Prompt outputs failed validation" in msg and "not in []" in msg
|
||||
assert sh._prompt_error_message({}) and "silent" not in sh._prompt_error_message({}).lower()
|
||||
assert sh._prompt_error_message({"error": "no GPU worker"}) == "no GPU worker"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_only_fix_dispatches_single_image(tmp_path, monkeypatch):
|
||||
"""REGRESSION LOCK: click Fix, type text, no references → the single-image
|
||||
/v1/library/fix path (base library asset staged, proven Qwen edit graph)."""
|
||||
org = "acme"
|
||||
root = tmp_path / "orgs" / org / "output"
|
||||
root.mkdir(parents=True)
|
||||
(root / "library.json").write_text(json.dumps({"assets": [{"path": "a.png"}]}))
|
||||
(root / "a.png").write_bytes(b"\x89PNG")
|
||||
in_dir = tmp_path / "orgs" / org / "input"
|
||||
in_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda o=None: str(in_dir))
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
|
||||
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
|
||||
captured = {}
|
||||
async def fake_queue(request, server, graph):
|
||||
captured["graph"] = graph
|
||||
return "pid-fix"
|
||||
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
|
||||
out = await sh.dispatch_fix(None, None, org, "a.png", "make the straps thicker")
|
||||
assert out["ok"] and out["prompt_id"] == "pid-fix"
|
||||
g = captured["graph"]
|
||||
assert g["1"]["class_type"] == "LoadImage" and g["1"]["inputs"]["image"].startswith("fix_src_")
|
||||
assert g["15"]["class_type"] == "SaveImage" and "straps thicker" in g["6"]["inputs"]["prompt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fix_accepts_uploaded_base_image(tmp_path, monkeypatch):
|
||||
"""A freshly uploaded photo can be the fix base (upload=) without landing it in
|
||||
the library first — the '+ add a photo' single-ref path."""
|
||||
org = "acme"
|
||||
in_dir = tmp_path / "orgs" / org / "input"
|
||||
in_dir.mkdir(parents=True)
|
||||
(in_dir / "shot_1.png").write_bytes(b"\x89PNG-up")
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda o=None: str(in_dir))
|
||||
captured = {}
|
||||
async def fake_queue(request, server, graph):
|
||||
captured["graph"] = graph
|
||||
return "pid-up"
|
||||
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
|
||||
out = await sh.dispatch_fix(None, None, org, "", "brighten it", upload="shot_1.png")
|
||||
assert out["ok"] and captured["graph"]["1"]["inputs"]["image"] == "shot_1.png"
|
||||
with pytest.raises(sh.DispatchError):
|
||||
await sh.dispatch_fix(None, None, org, "", "brighten it", upload="../etc/passwd")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queue_prompt_surfaces_downstream_error(tmp_path, monkeypatch):
|
||||
"""A non-200 from the self-POST /prompt (model-less pod) becomes a DispatchError
|
||||
carrying the REAL reason — never a raw 502 the dialog swallows into 'Failed:'."""
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
class Srv:
|
||||
port = None
|
||||
srv = Srv()
|
||||
routes = web.RouteTableDef()
|
||||
|
||||
@routes.post("/prompt")
|
||||
async def prompt(request):
|
||||
return web.json_response({"error": {"message": "Prompt outputs failed validation",
|
||||
"details": "UNETLoader: unet_name '…' not in []"}}, status=400)
|
||||
|
||||
async def edit(request):
|
||||
graph = (await request.json())["graph"]
|
||||
try:
|
||||
pid = await sh._queue_prompt(request, srv, graph)
|
||||
return web.json_response({"prompt_id": pid})
|
||||
except sh.DispatchError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
routes.post("/edit")(edit)
|
||||
|
||||
app = web.Application()
|
||||
app.add_routes(routes)
|
||||
ts = TestServer(app)
|
||||
await ts.start_server()
|
||||
srv.port = ts.port
|
||||
client = TestClient(ts)
|
||||
await client.start_server()
|
||||
r = await client.post("/edit", json={"graph": {"1": {"class_type": "SaveImage", "inputs": {}}}})
|
||||
body = await r.json()
|
||||
assert r.status == 400 and "error" in body
|
||||
assert "failed validation" in body["error"] and "not in []" in body["error"] # real reason, shown
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compose_rejects_single_reference(tmp_path, monkeypatch):
|
||||
org = "acme"
|
||||
root = tmp_path / "orgs" / org / "output"
|
||||
root.mkdir(parents=True)
|
||||
(root / "library.json").write_text(json.dumps({"assets": [{"path": "a.png"}]}))
|
||||
(root / "a.png").write_bytes(b"\x89PNG")
|
||||
in_dir = tmp_path / "orgs" / org / "input"
|
||||
in_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda o=None: str(in_dir))
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
|
||||
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
|
||||
with pytest.raises(sh.DispatchError):
|
||||
await sh.dispatch_compose(None, None, org, ["a.png"], "just one", uploads=[])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_home_redirect_only_hits_root_html(monkeypatch):
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
|
||||
monkeypatch.setenv("STUDIO_HOME_DEFAULT", "1")
|
||||
|
||||
async def handler(_):
|
||||
return web.Response(text="spa")
|
||||
|
||||
# root + html accept -> redirect to /studio
|
||||
req = make_mocked_request("GET", "/", headers={"Accept": "text/html"})
|
||||
with pytest.raises(web.HTTPFound) as e:
|
||||
await sh.home_redirect(req, handler)
|
||||
assert e.value.location == "/studio"
|
||||
|
||||
# advanced escape hatch and non-root paths pass through untouched
|
||||
for path, accept in [("/?advanced=1", "text/html"), ("/assets/x.js", "*/*"), ("/", "application/json")]:
|
||||
req = make_mocked_request("GET", path, headers={"Accept": accept})
|
||||
resp = await sh.home_redirect(req, handler)
|
||||
assert resp.text == "spa"
|
||||
|
||||
# env off (local dev / worker pods): root is untouched
|
||||
monkeypatch.delenv("STUDIO_HOME_DEFAULT")
|
||||
req = make_mocked_request("GET", "/", headers={"Accept": "text/html"})
|
||||
resp = await sh.home_redirect(req, handler)
|
||||
assert resp.text == "spa"
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
||||
{"id": "ade0e429-67bf-4e6d-adf4-88747094a690", "revision": 0, "last_node_id": 11, "last_link_id": 10, "nodes": [{"id": 1, "type": "UNETLoader", "pos": [40, 40], "size": [270, 120], "flags": {}, "order": 0, "mode": 0, "inputs": [], "outputs": [{"name": "MODEL", "type": "MODEL", "links": [4], "slot_index": 0}], "properties": {"Node name for S&R": "UNETLoader"}, "widgets_values": ["flux-2-klein-4b.safetensors", "default"]}, {"id": 2, "type": "CLIPLoader", "pos": [40, 200], "size": [270, 120], "flags": {}, "order": 1, "mode": 0, "inputs": [], "outputs": [{"name": "CLIP", "type": "CLIP", "links": [1, 2], "slot_index": 0}], "properties": {"Node name for S&R": "CLIPLoader"}, "widgets_values": ["flux2-text-encoder.safetensors", "flux2", "default"]}, {"id": 3, "type": "VAELoader", "pos": [40, 360], "size": [270, 120], "flags": {}, "order": 2, "mode": 0, "inputs": [], "outputs": [{"name": "VAE", "type": "VAE", "links": [9], "slot_index": 0}], "properties": {"Node name for S&R": "VAELoader"}, "widgets_values": ["flux2-vae.safetensors"]}, {"id": 4, "type": "CLIPTextEncode", "pos": [360, 40], "size": [400, 200], "flags": {}, "order": 3, "mode": 0, "inputs": [{"name": "clip", "type": "CLIP", "link": 1}], "outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [3], "slot_index": 0}], "properties": {"Node name for S&R": "CLIPTextEncode"}, "widgets_values": ["professional swimwear product photography, single-piece designer bikini on a model, beach at golden hour, editorial fashion lighting, crisp detail, high-end catalog, 85mm, shallow depth of field"], "title": "Positive"}, {"id": 5, "type": "FluxGuidance", "pos": [360, 280], "size": [270, 120], "flags": {}, "order": 4, "mode": 0, "inputs": [{"name": "conditioning", "type": "CONDITIONING", "link": 3}], "outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [5], "slot_index": 0}], "properties": {"Node name for S&R": "FluxGuidance"}, "widgets_values": [3.5]}, {"id": 6, "type": "CLIPTextEncode", "pos": [360, 400], "size": [400, 120], "flags": {}, "order": 5, "mode": 0, "inputs": [{"name": "clip", "type": "CLIP", "link": 2}], "outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [6], "slot_index": 0}], "properties": {"Node name for S&R": "CLIPTextEncode"}, "widgets_values": [""], "title": "Negative"}, {"id": 7, "type": "EmptyFlux2LatentImage", "pos": [360, 560], "size": [270, 120], "flags": {}, "order": 6, "mode": 0, "inputs": [], "outputs": [{"name": "LATENT", "type": "LATENT", "links": [7], "slot_index": 0}], "properties": {"Node name for S&R": "EmptyFlux2LatentImage"}, "widgets_values": [1024, 1536, 1]}, {"id": 8, "type": "KSampler", "pos": [800, 40], "size": [270, 120], "flags": {}, "order": 7, "mode": 0, "inputs": [{"name": "model", "type": "MODEL", "link": 4}, {"name": "positive", "type": "CONDITIONING", "link": 5}, {"name": "negative", "type": "CONDITIONING", "link": 6}, {"name": "latent_image", "type": "LATENT", "link": 7}], "outputs": [{"name": "LATENT", "type": "LATENT", "links": [8], "slot_index": 0}], "properties": {"Node name for S&R": "KSampler"}, "widgets_values": [0, "fixed", 24, 1.0, "euler", "simple", 1.0]}, {"id": 9, "type": "VAEDecode", "pos": [1160, 40], "size": [270, 120], "flags": {}, "order": 8, "mode": 0, "inputs": [{"name": "samples", "type": "LATENT", "link": 8}, {"name": "vae", "type": "VAE", "link": 9}], "outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [10], "slot_index": 0}], "properties": {"Node name for S&R": "VAEDecode"}, "widgets_values": []}, {"id": 10, "type": "SaveImage", "pos": [1360, 40], "size": [380, 380], "flags": {}, "order": 9, "mode": 0, "inputs": [{"name": "images", "type": "IMAGE", "link": 10}], "outputs": [], "properties": {"Node name for S&R": "SaveImage"}, "widgets_values": ["swimwear/product"]}, {"id": 11, "type": "Note", "pos": [40, 520], "size": [400, 300], "flags": {}, "order": 10, "mode": 0, "inputs": [], "outputs": [], "properties": {}, "widgets_values": ["FASHION PRODUCT SHOT \u2014 FLUX.2 klein (generic text-to-image)\n\nAll model files present (UNET/text-encoder/VAE). This is the FAST generic\nconcepting model \u2014 for ANTJE'S DESIGNS use the per-design tabs instead:\n shoot_<design> = editorial lifestyle scene from her CAD\n product_<design> = catalog shot from her CAD\nThose run Qwen-Image-Edit-2511 (design-exact). This klein tab is for\nquick mood/layout ideas only \u2014 small model, weak on hard poses."], "color": "#432", "bgcolor": "#653"}], "links": [[1, 2, 0, 4, 0, "CLIP"], [2, 2, 0, 6, 0, "CLIP"], [3, 4, 0, 5, 0, "CONDITIONING"], [4, 1, 0, 8, 0, "MODEL"], [5, 5, 0, 8, 1, "CONDITIONING"], [6, 6, 0, 8, 2, "CONDITIONING"], [7, 7, 0, 8, 3, "LATENT"], [8, 8, 0, 9, 0, "LATENT"], [9, 3, 0, 9, 1, "VAE"], [10, 9, 0, 10, 0, "IMAGE"]], "groups": [], "config": {}, "extra": {"ds": {"scale": 0.8, "offset": [0, 0]}}, "version": 0.4}
|
||||
{"id": "ade0e429-67bf-4e6d-adf4-88747094a690", "revision": 0, "last_node_id": 11, "last_link_id": 10, "nodes": [{"id": 1, "type": "UNETLoader", "pos": [40, 620], "size": [320, 82], "flags": {"collapsed": false}, "order": 0, "mode": 0, "inputs": [], "outputs": [{"name": "MODEL", "type": "MODEL", "links": [4], "slot_index": 0}], "properties": {"Node name for S&R": "UNETLoader"}, "widgets_values": ["flux-2-klein-4b.safetensors", "default"]}, {"id": 2, "type": "CLIPLoader", "pos": [40, 740], "size": [320, 106], "flags": {"collapsed": false}, "order": 1, "mode": 0, "inputs": [], "outputs": [{"name": "CLIP", "type": "CLIP", "links": [1, 2], "slot_index": 0}], "properties": {"Node name for S&R": "CLIPLoader"}, "widgets_values": ["flux2-text-encoder.safetensors", "flux2", "default"]}, {"id": 3, "type": "VAELoader", "pos": [40, 890], "size": [320, 58], "flags": {"collapsed": false}, "order": 2, "mode": 0, "inputs": [], "outputs": [{"name": "VAE", "type": "VAE", "links": [9], "slot_index": 0}], "properties": {"Node name for S&R": "VAELoader"}, "widgets_values": ["flux2-vae.safetensors"]}, {"id": 4, "type": "CLIPTextEncode", "pos": [360, 40], "size": [400, 200], "flags": {}, "order": 3, "mode": 0, "inputs": [{"name": "clip", "type": "CLIP", "link": 1}], "outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [3], "slot_index": 0}], "properties": {"Node name for S&R": "CLIPTextEncode"}, "widgets_values": ["professional swimwear product photography, single-piece designer bikini on a model, beach at golden hour, editorial fashion lighting, crisp detail, high-end catalog, 85mm, shallow depth of field"], "title": "Positive"}, {"id": 5, "type": "FluxGuidance", "pos": [360, 280], "size": [270, 120], "flags": {}, "order": 4, "mode": 0, "inputs": [{"name": "conditioning", "type": "CONDITIONING", "link": 3}], "outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [5], "slot_index": 0}], "properties": {"Node name for S&R": "FluxGuidance"}, "widgets_values": [3.5]}, {"id": 6, "type": "CLIPTextEncode", "pos": [360, 400], "size": [400, 120], "flags": {}, "order": 5, "mode": 0, "inputs": [{"name": "clip", "type": "CLIP", "link": 2}], "outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [6], "slot_index": 0}], "properties": {"Node name for S&R": "CLIPTextEncode"}, "widgets_values": [""], "title": "Negative"}, {"id": 7, "type": "EmptyFlux2LatentImage", "pos": [360, 560], "size": [270, 120], "flags": {}, "order": 6, "mode": 0, "inputs": [], "outputs": [{"name": "LATENT", "type": "LATENT", "links": [7], "slot_index": 0}], "properties": {"Node name for S&R": "EmptyFlux2LatentImage"}, "widgets_values": [1024, 1536, 1]}, {"id": 8, "type": "KSampler", "pos": [1280, 300], "size": [320, 262], "flags": {"collapsed": false}, "order": 7, "mode": 0, "inputs": [{"name": "model", "type": "MODEL", "link": 4}, {"name": "positive", "type": "CONDITIONING", "link": 5}, {"name": "negative", "type": "CONDITIONING", "link": 6}, {"name": "latent_image", "type": "LATENT", "link": 7}], "outputs": [{"name": "LATENT", "type": "LATENT", "links": [8], "slot_index": 0}], "properties": {"Node name for S&R": "KSampler"}, "widgets_values": [0, "fixed", 24, 1.0, "euler", "simple", 1.0]}, {"id": 9, "type": "VAEDecode", "pos": [1680, 300], "size": [240, 58], "flags": {"collapsed": false}, "order": 8, "mode": 0, "inputs": [{"name": "samples", "type": "LATENT", "link": 8}, {"name": "vae", "type": "VAE", "link": 9}], "outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [10], "slot_index": 0}], "properties": {"Node name for S&R": "VAEDecode"}, "widgets_values": []}, {"id": 10, "type": "SaveImage", "pos": [1680, 420], "size": [360, 300], "flags": {"collapsed": false}, "order": 9, "mode": 0, "inputs": [{"name": "images", "type": "IMAGE", "link": 10}], "outputs": [], "properties": {"Node name for S&R": "SaveImage"}, "widgets_values": ["swimwear/product"]}, {"id": 11, "type": "Note", "pos": [40, 0], "size": [340, 220], "flags": {"collapsed": false}, "order": 10, "mode": 0, "inputs": [], "outputs": [], "properties": {}, "widgets_values": ["FASHION PRODUCT SHOT \u2014 FLUX.2 klein (generic text-to-image)\n\nAll model files present (UNET/text-encoder/VAE). This is the FAST generic\nconcepting model \u2014 for ANTJE'S DESIGNS use the per-design tabs instead:\n shoot_<design> = editorial lifestyle scene from her CAD\n product_<design> = catalog shot from her CAD\nThose run Qwen-Image-Edit-2511 (design-exact). This klein tab is for\nquick mood/layout ideas only \u2014 small model, weak on hard poses."], "color": "#432", "bgcolor": "#653"}], "links": [[1, 2, 0, 4, 0, "CLIP"], [2, 2, 0, 6, 0, "CLIP"], [3, 4, 0, 5, 0, "CONDITIONING"], [4, 1, 0, 8, 0, "MODEL"], [5, 5, 0, 8, 1, "CONDITIONING"], [6, 6, 0, 8, 2, "CONDITIONING"], [7, 7, 0, 8, 3, "LATENT"], [8, 8, 0, 9, 0, "LATENT"], [9, 3, 0, 9, 1, "VAE"], [10, 9, 0, 10, 0, "IMAGE"]], "groups": [], "config": {}, "extra": {"ds": {"scale": 0.8, "offset": [0, 0]}}, "version": 0.4}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user