Compare commits
50
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 |
@@ -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:
|
||||
|
||||
+29
@@ -33,6 +33,35 @@ RUN chmod +x scripts/install_custom_nodes.sh && scripts/install_custom_nodes.sh
|
||||
# Create directories for runtime volumes
|
||||
RUN mkdir -p models output input custom_nodes user
|
||||
|
||||
# /app/orgs is the tenant-data PVC; /app/models is ephemeral overlay. Register
|
||||
# .models on the PVC as the default model store (main.py auto-loads this file)
|
||||
# so models survive pod restarts and downloads land durable.
|
||||
RUN printf '%s\n' \
|
||||
'hanzo:' \
|
||||
' base_path: /app/orgs/.models' \
|
||||
' is_default: true' \
|
||||
' checkpoints: checkpoints' \
|
||||
' configs: configs' \
|
||||
' loras: loras' \
|
||||
' vae: vae' \
|
||||
' text_encoders: text_encoders' \
|
||||
' diffusion_models: diffusion_models' \
|
||||
' clip_vision: clip_vision' \
|
||||
' style_models: style_models' \
|
||||
' embeddings: embeddings' \
|
||||
' diffusers: diffusers' \
|
||||
' vae_approx: vae_approx' \
|
||||
' controlnet: controlnet' \
|
||||
' gligen: gligen' \
|
||||
' upscale_models: upscale_models' \
|
||||
' latent_upscale_models: latent_upscale_models' \
|
||||
' hypernetworks: hypernetworks' \
|
||||
' photomaker: photomaker' \
|
||||
' classifiers: classifiers' \
|
||||
' model_patches: model_patches' \
|
||||
' audio_encoders: audio_encoders' \
|
||||
> extra_model_paths.yaml
|
||||
|
||||
EXPOSE 8188
|
||||
|
||||
# Default: listen on all interfaces, CPU mode
|
||||
|
||||
@@ -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/):
|
||||
|
||||
@@ -105,7 +105,13 @@ python3 "$BRANDING_DIR/patch_destring.py" "$STATIC_DIR"
|
||||
# studio.hanzo.ai ships the modern DOM node renderer as the only node design:
|
||||
# default the setting on, hide it from the Settings dialog (type:hidden), and
|
||||
# drop the logo-menu switch. Idempotent; see patch_vuenodes.py.
|
||||
echo "[12/12] Forcing Nodes 2.0 on (no toggle)..."
|
||||
echo "[12/13] Forcing Nodes 2.0 on (no toggle)..."
|
||||
python3 "$BRANDING_DIR/patch_vuenodes.py" "$STATIC_DIR"
|
||||
|
||||
# --- 13. Inject the deep-link opener (open + optionally run a workflow by URL) ---
|
||||
# ?org=<org>&workflow=<path>&run=1 sets the session org, loads the saved workflow,
|
||||
# and queues it (dispatching to the org's BYO-GPU). Idempotent; see patch_deeplink.py.
|
||||
echo "[13/13] Injecting deep-link opener..."
|
||||
python3 "$BRANDING_DIR/patch_deeplink.py" "$STATIC_DIR" "$BRANDING_DIR"
|
||||
|
||||
echo "=== Hanzo Studio branding complete ==="
|
||||
|
||||
@@ -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");
|
||||
})();
|
||||
@@ -110,6 +110,12 @@
|
||||
}
|
||||
|
||||
rows.push(el("div", { class: "hzid-sep" }));
|
||||
// Exit the advanced node editor back to the content-forward Studio Home.
|
||||
// Without this there is NO way out of Advanced mode (the node editor is the
|
||||
// full-screen ComfyUI SPA) — the user gets stuck.
|
||||
rows.push(el("a", { class: "hzid-item hzid-link", href: "/studio" }, [
|
||||
el("span", { class: "hzid-tick", text: "←" }), el("span", { text: "Exit to Studio Home" }),
|
||||
]));
|
||||
rows.push(el("a", { class: "hzid-item hzid-link", href: s.console_url || "https://console.hanzo.ai", target: "_blank", rel: "noopener" }, [
|
||||
el("span", { class: "hzid-tick", text: "↗" }), el("span", { text: "Console" }),
|
||||
]));
|
||||
@@ -151,6 +157,17 @@
|
||||
// ------------------------------------------------------------- boot
|
||||
function mount() {
|
||||
if (document.getElementById("hanzo-identity")) return;
|
||||
// Always-visible "Exit to Studio" button so Advanced mode is never a trap.
|
||||
// Fixed top-left, above the node editor; independent of the user menu.
|
||||
if (!document.getElementById("hanzo-exit-advanced")) {
|
||||
var exit = el("a", {
|
||||
id: "hanzo-exit-advanced", href: "/studio", title: "Exit Advanced — back to Studio Home",
|
||||
style: "position:fixed;top:10px;left:12px;z-index:100000;display:flex;align-items:center;gap:6px;" +
|
||||
"background:rgba(20,20,24,.92);color:#ececf1;border:1px solid #33333c;border-radius:8px;" +
|
||||
"padding:6px 11px;font:600 12px/1 Inter,system-ui,sans-serif;text-decoration:none;cursor:pointer;backdrop-filter:blur(8px)"
|
||||
}, [el("span", { text: "←" }), el("span", { text: "Studio" })]);
|
||||
document.body.appendChild(exit);
|
||||
}
|
||||
var root = el("div", { id: "hanzo-identity", class: "hanzo-identity" });
|
||||
document.body.appendChild(root);
|
||||
getSession().then(function (s) {
|
||||
|
||||
@@ -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()
|
||||
@@ -21,7 +21,7 @@ import sys
|
||||
STATIC_DIR = sys.argv[1]
|
||||
BRANDING_DIR = sys.argv[2]
|
||||
|
||||
BUNDLE_VERSION = "3"
|
||||
BUNDLE_VERSION = "4"
|
||||
JS = "hanzo-identity.js"
|
||||
CSS = "hanzo-identity.css"
|
||||
LINK_TAG = f'<link rel="stylesheet" href="{CSS}?v={BUNDLE_VERSION}"/>'
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
@@ -245,6 +245,7 @@ def prompt_worker(q, server_instance):
|
||||
# Import metrics + billing for prompt tracking
|
||||
from middleware import metrics_middleware
|
||||
from middleware import billing_middleware
|
||||
from middleware import content_publish
|
||||
enable_billing = getattr(args, "enable_billing", False)
|
||||
enable_metrics = getattr(args, "enable_metrics", False)
|
||||
|
||||
@@ -307,6 +308,26 @@ def prompt_worker(q, server_instance):
|
||||
server_instance.loop,
|
||||
)
|
||||
|
||||
# Content: record each output artifact as a draft Asset in the content
|
||||
# lane (clients/content, module "marketing") — the SAME DocType that
|
||||
# lane writes when it drives a render, so a studio-initiated render lands
|
||||
# in the same place. Written as the requesting user via their IAM token
|
||||
# from `sensitive`, so it lands in their org. Assets land draft; a human
|
||||
# moves them through the lifecycle, and published is what a storefront
|
||||
# shows. Fire-and-forget: a render is never failed if the lane is down.
|
||||
if content_publish.enabled() and e.success:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
content_publish.publish_render(
|
||||
org_id=extra_data.get("org_id") or "default",
|
||||
prompt_id=prompt_id,
|
||||
workflow=item[2],
|
||||
history_result=e.history_result,
|
||||
iam_token=extra_data.get("iam_token"),
|
||||
extra_data=extra_data,
|
||||
),
|
||||
server_instance.loop,
|
||||
)
|
||||
|
||||
# Log Time in a more readable way after 10 minutes
|
||||
if execution_time > 600:
|
||||
execution_time = time.strftime("%H:%M:%S", time.gmtime(execution_time))
|
||||
|
||||
@@ -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
|
||||
@@ -76,11 +76,36 @@ def _bearer(request) -> str:
|
||||
return f"Bearer {tok}" if tok else ""
|
||||
|
||||
|
||||
def _has_online_gpu(tok: str) -> bool:
|
||||
"""True if the caller's org has an online GPU machine in the cloud fleet."""
|
||||
# Identity keys a fleet machine might carry, most human-friendly first. We READ
|
||||
# whatever the gpu-jobs lane publishes (home-lab labels like spark/dbc/evo land in
|
||||
# `name`/`hostname`); we never invent a parallel registry.
|
||||
_NODE_KEYS = ("name", "hostname", "label", "node", "worker_id", "machine_id", "id", "gpu_model")
|
||||
|
||||
|
||||
def _node_label(m: dict) -> str:
|
||||
for k in _NODE_KEYS:
|
||||
v = m.get(k)
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
return "gpu"
|
||||
|
||||
|
||||
def _fleet_machines(tok: str) -> list:
|
||||
"""The caller-org's machines from the cloud fleet. The token scopes the fleet to
|
||||
the caller's org, so this is inherently tenant-isolated."""
|
||||
req = urllib.request.Request(f"{CLOUD}/v1/machines", headers={"Authorization": tok})
|
||||
d = json.loads(urllib.request.urlopen(req, timeout=10).read())
|
||||
return any(m.get("status") == "online" and m.get("gpu") for m in d.get("machines", []))
|
||||
ms = d.get("machines", [])
|
||||
return ms if isinstance(ms, list) else []
|
||||
|
||||
|
||||
def _online_gpu_nodes(tok: str) -> list:
|
||||
return [m for m in _fleet_machines(tok) if m.get("status") == "online" and m.get("gpu")]
|
||||
|
||||
|
||||
def _has_online_gpu(tok: str) -> bool:
|
||||
"""True if the caller's org has an online GPU machine in the cloud fleet."""
|
||||
return bool(_online_gpu_nodes(tok))
|
||||
|
||||
|
||||
def dispatch_if_worker(request, org_id: str, prompt_id: str, prompt: dict) -> bool:
|
||||
@@ -89,14 +114,25 @@ def dispatch_if_worker(request, org_id: str, prompt_id: str, prompt: dict) -> bo
|
||||
Never raises — any failure falls back to local."""
|
||||
try:
|
||||
tok = _bearer(request)
|
||||
if not tok or not _has_online_gpu(tok):
|
||||
online = _online_gpu_nodes(tok) if tok else []
|
||||
if not online:
|
||||
return False
|
||||
# Which node runs it: the single online GPU is definitive; with several the
|
||||
# claimer is decided at pick-up time, so record the ambiguous "gpu".
|
||||
node = _node_label(online[0]) if len(online) == 1 else "gpu"
|
||||
body = json.dumps({
|
||||
"activityId": prompt_id,
|
||||
"runId": prompt_id,
|
||||
"activityType": {"name": "studio.render"},
|
||||
"taskQueue": JOBS_NS,
|
||||
"heartbeatTimeout": "600s",
|
||||
# 1200s: a heavy Qwen edit on a cold GB10 (model reload + full 30-step
|
||||
# sample) can exceed 600s; the worker heartbeats each step, so this is
|
||||
# the ceiling for a genuinely long single render, not idle slack.
|
||||
"heartbeatTimeout": "1200s",
|
||||
# Liveness is the heartbeat above; this cap only bounds a live-but-stuck
|
||||
# render. Unset, the tasks default (~1h) reaped real renders mid-run and
|
||||
# the queue re-ran them for hours.
|
||||
"startToCloseTimeout": "14400s",
|
||||
"input": {
|
||||
"prompt": prompt,
|
||||
"org": org_id,
|
||||
@@ -113,6 +149,49 @@ def dispatch_if_worker(request, org_id: str, prompt_id: str, prompt: dict) -> bo
|
||||
headers={"Authorization": tok, "Content-Type": "application/json"},
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=15).read()
|
||||
_track_dispatched(org_id, prompt_id, prompt, node=node)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _track_dispatched(org_id: str, prompt_id: str, prompt: dict, node: str = "gpu") -> None:
|
||||
"""Record a BYO-GPU render dispatched to gpu-jobs so the studio UI can SHOW it
|
||||
in the queue. BYO-GPU jobs render on the worker (gpu-jobs), not the pod's local
|
||||
/queue — so without this the user's fix/compose 'never appears in the queue'.
|
||||
A tiny per-org JSON the /v1/render-queue endpoint reads; entries drop off when
|
||||
their output lands in the library or they age out. Never raises."""
|
||||
try:
|
||||
import time
|
||||
import folder_paths
|
||||
# human title from the SaveImage prefix; refs from LoadImage for a thumbnail
|
||||
full_prefix, refs = "render", []
|
||||
for n in prompt.values():
|
||||
if not isinstance(n, dict):
|
||||
continue
|
||||
ct = n.get("class_type", "")
|
||||
if ct == "SaveImage":
|
||||
full_prefix = n.get("inputs", {}).get("filename_prefix") or "render"
|
||||
elif "LoadImage" in ct:
|
||||
im = n.get("inputs", {}).get("image")
|
||||
if im:
|
||||
refs.append(im)
|
||||
out_dir = folder_paths.get_org_output_directory(org_id)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
jf = os.path.join(out_dir, "render_jobs.json")
|
||||
try:
|
||||
jobs = json.loads(open(jf).read())
|
||||
except Exception:
|
||||
jobs = []
|
||||
jobs = [j for j in jobs if j.get("id") != prompt_id][-40:] # dedupe + cap
|
||||
# store the FULL output prefix (e.g. "fixes/model01_..._00001_") so the queue
|
||||
# endpoint can check for the ACTUAL output file, not a fuzzy base match that
|
||||
# collides with the source image and makes the job vanish after 30s.
|
||||
jobs.append({"id": prompt_id, "prefix": full_prefix.split("/")[-1],
|
||||
"outPrefix": full_prefix, "refs": refs[:1], "node": node,
|
||||
"ts": int(time.time()), "status": "rendering"})
|
||||
tmp = jf + ".tmp"
|
||||
open(tmp, "w").write(json.dumps(jobs))
|
||||
os.replace(tmp, jf)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -446,6 +446,12 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
|
||||
return web.json_response({"error": "Invalid or expired token"}, status=401)
|
||||
|
||||
request["iam_user"] = _with_active_org(user, request.cookies.get(_ACTIVE_ORG_COOKIE))
|
||||
# The verified raw access token. Studio records a completed render in the
|
||||
# content lane AS THE REQUESTING USER (middleware/content_publish.py), so it
|
||||
# lands in that user's org with that user's rights — no service account, no
|
||||
# impersonation. Carried to the worker over the queue's `sensitive` channel,
|
||||
# so it is never persisted into history.
|
||||
request["iam_token"] = token
|
||||
return await handler(request)
|
||||
|
||||
# Expose the login + callback handlers so server.py can register the routes.
|
||||
|
||||
@@ -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)
|
||||
@@ -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,283 @@
|
||||
"""Per-org work log — the ONE record of every render this org dispatched.
|
||||
|
||||
The single dispatch funnel (``studio_home.dispatch_fix`` / ``dispatch_compose`` /
|
||||
``rerun``) writes one row here at dispatch time, so the studio surface can show:
|
||||
|
||||
* the queue/history with REAL status — queued · running · done · failed ·
|
||||
cancelled — including gpu-lane jobs the local /queue can't see (that was the
|
||||
'nothing showed as queued' report) and dispatches that failed before queuing;
|
||||
* the full context that produced each item — prompt, reference images/uploads,
|
||||
the workflow family (fix / compose / …), params, and the process trail;
|
||||
* parent → child lineage: each derived asset records the library asset(s) it was
|
||||
derived from, so any item's versions (ancestors + later fixes) form a chain.
|
||||
|
||||
Storage is one JSON array per org at ``orgs/<org>/output/worklog.json`` — the same
|
||||
per-org metadata pattern as ``library.json`` / ``render_jobs.json`` / ``ratings.json``
|
||||
(atomic tmp+replace). No new database. No backfill: history starts when this ships;
|
||||
items generated earlier have no recorded row (and derived items no recorded parent).
|
||||
|
||||
Tenant isolation: every function takes an already-resolved ``org`` (the caller's
|
||||
IAM org, resolved server-side by ``studio_home._org_of``). A row lives only in its
|
||||
org's file, so one org can never read or mutate another's log.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import folder_paths
|
||||
|
||||
# Cap the file so a busy org can't grow it without bound; oldest rows drop off.
|
||||
_CAP = 1000
|
||||
_TERMINAL = ("done", "failed", "cancelled")
|
||||
|
||||
|
||||
def _dir(org: str) -> Path:
|
||||
return Path(folder_paths.get_org_output_directory(org))
|
||||
|
||||
|
||||
def _path(org: str) -> Path:
|
||||
return _dir(org) / "worklog.json"
|
||||
|
||||
|
||||
def load(org: str) -> list[dict]:
|
||||
try:
|
||||
rows = json.loads(_path(org).read_text())
|
||||
return rows if isinstance(rows, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _save(org: str, rows: list[dict]) -> None:
|
||||
d = _dir(org)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
p = _path(org)
|
||||
tmp = p.with_name(p.name + ".tmp")
|
||||
tmp.write_text(json.dumps(rows[-_CAP:]))
|
||||
tmp.replace(p)
|
||||
|
||||
|
||||
def record(org: str, *, kind: str, prompt: str, refs: list, uploads: list,
|
||||
parents: list, output_prefix: str, params: dict | None = None,
|
||||
pid: str | None = None, lane: str | None = None, node: str | None = None,
|
||||
status: str = "queued", error: str | None = None) -> dict:
|
||||
"""Append one dispatch row. ``pid`` is the /prompt id on success (used to match
|
||||
live queue/history); a failed dispatch has no id, so a synthetic one is minted
|
||||
and the row is marked failed with the reason so it STILL shows (never silent)."""
|
||||
now = int(time.time())
|
||||
row: dict = {
|
||||
"id": pid or f"failed-{uuid.uuid4().hex[:12]}",
|
||||
"ts": now,
|
||||
"kind": kind,
|
||||
"prompt": (prompt or "")[:800],
|
||||
"refs": [str(x) for x in (refs or [])][:8],
|
||||
"uploads": [str(x) for x in (uploads or [])][:8],
|
||||
"parents": [str(x) for x in (parents or [])][:8],
|
||||
"output_prefix": output_prefix or "",
|
||||
"params": params or {},
|
||||
"lane": lane or ("failed" if status == "failed" else "local"),
|
||||
"node": node or ("studio pod" if (lane in (None, "local")) else "gpu"),
|
||||
"status": status,
|
||||
"updatedAt": now,
|
||||
"events": [{"s": "queued", "ts": now}],
|
||||
}
|
||||
if error:
|
||||
row["error"] = str(error)[:400]
|
||||
row["events"].append({"s": "failed", "ts": now, "note": row["error"]})
|
||||
rows = load(org)
|
||||
rows.append(row)
|
||||
_save(org, rows)
|
||||
return row
|
||||
|
||||
|
||||
def set_status(org: str, pid: str, status: str, note: str | None = None) -> bool:
|
||||
"""Advance a row's status by prompt_id (running/done/failed/cancelled). Returns
|
||||
True if a row was updated. Idempotent-safe; terminal rows are left untouched."""
|
||||
if not pid:
|
||||
return False
|
||||
rows = load(org)
|
||||
hit = False
|
||||
now = int(time.time())
|
||||
for r in rows:
|
||||
if r.get("id") == pid and r.get("status") not in _TERMINAL:
|
||||
r["status"] = status
|
||||
r["updatedAt"] = now
|
||||
r.setdefault("events", []).append(
|
||||
{"s": status, "ts": now, **({"note": note} if note else {})})
|
||||
hit = True
|
||||
if hit:
|
||||
_save(org, rows)
|
||||
return hit
|
||||
|
||||
|
||||
def mark_started(org: str, pid: str, at: int) -> bool:
|
||||
"""Stamp when a job first begins rendering (first seen at the head of its lane).
|
||||
Idempotent: only the first observation sets started_at. Feeds duration = finished
|
||||
− started, the basis of the ETA median."""
|
||||
if not pid:
|
||||
return False
|
||||
rows = load(org)
|
||||
hit = False
|
||||
for r in rows:
|
||||
if r.get("id") == pid and not r.get("started_at") and r.get("status") not in _TERMINAL:
|
||||
r["started_at"] = int(at)
|
||||
r["status"] = "running"
|
||||
r["updatedAt"] = int(time.time())
|
||||
r.setdefault("events", []).append({"s": "running", "ts": int(at)})
|
||||
hit = True
|
||||
if hit:
|
||||
_save(org, rows)
|
||||
return hit
|
||||
|
||||
|
||||
def mark_finished(org: str, pid: str, at: int) -> bool:
|
||||
"""Stamp real completion (the output file's mtime) and close the row as done."""
|
||||
if not pid:
|
||||
return False
|
||||
rows = load(org)
|
||||
hit = False
|
||||
for r in rows:
|
||||
if r.get("id") == pid and r.get("status") not in _TERMINAL:
|
||||
r["finished_at"] = int(at)
|
||||
r["status"] = "done"
|
||||
r["updatedAt"] = int(time.time())
|
||||
r.setdefault("events", []).append({"s": "done", "ts": int(at)})
|
||||
hit = True
|
||||
if hit:
|
||||
_save(org, rows)
|
||||
return hit
|
||||
|
||||
|
||||
def _duration(r: dict) -> float | None:
|
||||
"""Measured render seconds for a completed row: finished − started when both
|
||||
exist (true render time), else finished − queued (turnaround, an upper bound)."""
|
||||
fin = r.get("finished_at")
|
||||
if fin is None:
|
||||
return None
|
||||
start = r.get("started_at")
|
||||
if start is None:
|
||||
start = r.get("ts")
|
||||
if start is None:
|
||||
return None
|
||||
return float(fin - start) if fin > start else None
|
||||
|
||||
|
||||
def median_durations(rows: list[dict]) -> dict:
|
||||
"""Rolling median render seconds per kind (fix/compose/rerun) over completed
|
||||
rows — the ETA basis. Pure. Only kinds with a measured sample appear."""
|
||||
buckets: dict[str, list[float]] = {}
|
||||
for r in rows:
|
||||
d = _duration(r)
|
||||
if d and d > 0:
|
||||
buckets.setdefault(r.get("kind", ""), []).append(d)
|
||||
out: dict[str, int] = {}
|
||||
for k, v in buckets.items():
|
||||
v.sort()
|
||||
n = len(v)
|
||||
out[k] = int(v[n // 2] if n % 2 else (v[n // 2 - 1] + v[n // 2]) / 2)
|
||||
return out
|
||||
|
||||
|
||||
def estimate_lanes(active: list[dict], medians: dict, now: int,
|
||||
default_median: int = 180) -> tuple[dict, dict]:
|
||||
"""Pure position + ETA math, per lane. ``active`` items are
|
||||
``{id, kind, lane, ts, status, started_at?}``. Within a lane the running item
|
||||
(head) is position 1; each queued item's wait ≈ jobs-ahead × their medians +
|
||||
the running item's remaining time (median − elapsed). ETA to done = wait +
|
||||
its own median. Returns (per-id info, per-lane count). No client math over
|
||||
another tenant's data — the caller passes only THIS org's active jobs."""
|
||||
lanes: dict[str, list[dict]] = {}
|
||||
for j in active:
|
||||
lanes.setdefault(j.get("lane") or "local", []).append(j)
|
||||
info: dict[str, dict] = {}
|
||||
counts: dict[str, int] = {}
|
||||
for lane, jobs in lanes.items():
|
||||
jobs.sort(key=lambda j: (0 if j.get("status") == "running" else 1, j.get("ts", 0)))
|
||||
counts[lane] = len(jobs)
|
||||
cum = 0.0 # seconds until the START of the current item
|
||||
for idx, j in enumerate(jobs):
|
||||
m = medians.get(j.get("kind", ""), default_median)
|
||||
row = {"lane": lane, "position": idx + 1, "of": len(jobs),
|
||||
"node": j.get("node") or ("studio pod" if lane == "local" else "gpu")}
|
||||
if idx == 0 and j.get("status") == "running":
|
||||
started = j.get("started_at") or j.get("ts") or now
|
||||
elapsed = max(0, now - started)
|
||||
remaining = max(0.0, m - elapsed)
|
||||
row.update(elapsed_seconds=int(elapsed), wait_seconds=0,
|
||||
remaining_seconds=int(remaining), eta_seconds=int(remaining))
|
||||
cum = remaining
|
||||
else:
|
||||
row.update(elapsed_seconds=0, wait_seconds=int(cum),
|
||||
remaining_seconds=None, eta_seconds=int(cum + m))
|
||||
cum += m
|
||||
info[j["id"]] = row
|
||||
return info, counts
|
||||
|
||||
|
||||
def build_lineage(rows: list[dict], target: str) -> dict:
|
||||
"""Pure: given work-log rows (each with ``parents`` = source library paths and
|
||||
``output`` = the landed library path, resolved by the caller) and a target
|
||||
library asset path, return its version chain:
|
||||
|
||||
{"ancestors": [older … immediate parent], "node": <target>,
|
||||
"descendants": [later derivations, chronological]}
|
||||
|
||||
Each entry is ``{path, prompt, kind, ts}``. Ancestors walk the first-parent edge
|
||||
up from the row that PRODUCED the target; descendants are rows that name the
|
||||
target among their parents (and, transitively, their outputs). Cycles and
|
||||
missing rows terminate the walk — never loops."""
|
||||
by_output: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
out = r.get("output")
|
||||
if out and out not in by_output:
|
||||
by_output[out] = r
|
||||
|
||||
def entry(path: str, r: dict | None) -> dict:
|
||||
return {
|
||||
"path": path,
|
||||
"prompt": (r or {}).get("prompt", ""),
|
||||
"kind": (r or {}).get("kind", ""),
|
||||
"ts": (r or {}).get("ts", 0),
|
||||
}
|
||||
|
||||
# Ancestors: from the row that produced `target`, follow its first parent up.
|
||||
ancestors: list[dict] = []
|
||||
seen = {target}
|
||||
cur = target
|
||||
while True:
|
||||
producer = by_output.get(cur)
|
||||
if not producer:
|
||||
break
|
||||
parents = producer.get("parents") or []
|
||||
if not parents:
|
||||
break
|
||||
parent = parents[0]
|
||||
if parent in seen:
|
||||
break
|
||||
seen.add(parent)
|
||||
ancestors.append(entry(parent, by_output.get(parent)))
|
||||
cur = parent
|
||||
ancestors.reverse() # oldest first
|
||||
|
||||
# Descendants: rows that derive FROM target, then rows deriving from those, …
|
||||
descendants: list[dict] = []
|
||||
frontier = [target]
|
||||
seen_out = {target}
|
||||
while frontier:
|
||||
nxt: list[str] = []
|
||||
kids = [r for r in rows if any(p in frontier for p in (r.get("parents") or []))]
|
||||
kids.sort(key=lambda r: r.get("ts", 0))
|
||||
for r in kids:
|
||||
out = r.get("output")
|
||||
key = out or r.get("id")
|
||||
if key in seen_out:
|
||||
continue
|
||||
seen_out.add(key)
|
||||
descendants.append(entry(out or "", r))
|
||||
if out:
|
||||
nxt.append(out)
|
||||
frontier = nxt
|
||||
|
||||
return {"ancestors": ancestors, "node": target, "descendants": descendants}
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -44,7 +44,10 @@ for name in CRIT:
|
||||
PY
|
||||
log "locked stack:"; sed 's/^/[packs] /' "$CGEN"
|
||||
|
||||
# 1. Clone / pin every pack from the manifest (comment + blank lines skipped).
|
||||
# 1. Clone / pin every pack from the manifest (comment + blank lines skipped),
|
||||
# then apply any local compat patches (scripts/patches/<pack>-*.patch). The
|
||||
# forced checkout discards previously-applied patches so the pin stays
|
||||
# authoritative and re-runs are idempotent.
|
||||
log "reconciling packs from $MANIFEST"
|
||||
while read -r name url sha _rest; do
|
||||
case "$name" in ''|\#*) continue ;; esac
|
||||
@@ -54,8 +57,13 @@ while read -r name url sha _rest; do
|
||||
else
|
||||
git clone -q --filter=blob:none "$url" "$dir"
|
||||
fi
|
||||
git -C "$dir" checkout -q "$sha"
|
||||
git -C "$dir" checkout -qf "$sha"
|
||||
log "pinned $name @ $(git -C "$dir" rev-parse --short HEAD)"
|
||||
for p in "$REPO/scripts/patches/$name"-*.patch; do
|
||||
[ -f "$p" ] || continue
|
||||
git -C "$dir" apply "$p"
|
||||
log "applied $(basename "$p")"
|
||||
done
|
||||
done < "$MANIFEST"
|
||||
|
||||
# 2. Install pack dependencies with the derived stack lock.
|
||||
|
||||
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)
|
||||
@@ -229,7 +264,8 @@ class PromptServer():
|
||||
self.client_session:Optional[aiohttp.ClientSession] = None
|
||||
self.number = 0
|
||||
|
||||
middlewares = [cache_control, deprecation_warning]
|
||||
from middleware.studio_home import home_redirect
|
||||
middlewares = [cache_control, deprecation_warning, home_redirect]
|
||||
|
||||
self._iam_auth = None
|
||||
if args.enable_iam_auth:
|
||||
@@ -968,19 +1004,24 @@ class PromptServer():
|
||||
else:
|
||||
offset = -1
|
||||
|
||||
return web.json_response(self.prompt_queue.get_history(max_items=max_items, offset=offset))
|
||||
hist = self.prompt_queue.get_history(max_items=max_items, offset=offset)
|
||||
hist = _scope_history_to_org(hist, self._caller_org(request))
|
||||
return web.json_response(hist)
|
||||
|
||||
@routes.get("/history/{prompt_id}")
|
||||
async def get_history_prompt_id(request):
|
||||
prompt_id = request.match_info.get("prompt_id", None)
|
||||
return web.json_response(self.prompt_queue.get_history(prompt_id=prompt_id))
|
||||
hist = self.prompt_queue.get_history(prompt_id=prompt_id)
|
||||
hist = _scope_history_to_org(hist, self._caller_org(request))
|
||||
return web.json_response(hist)
|
||||
|
||||
@routes.get("/queue")
|
||||
async def get_queue(request):
|
||||
org = self._caller_org(request)
|
||||
queue_info = {}
|
||||
current_queue = self.prompt_queue.get_current_queue_volatile()
|
||||
queue_info['queue_running'] = _remove_sensitive_from_queue(current_queue[0])
|
||||
queue_info['queue_pending'] = _remove_sensitive_from_queue(current_queue[1])
|
||||
queue_info['queue_running'] = _scope_queue_to_org(_remove_sensitive_from_queue(current_queue[0]), org)
|
||||
queue_info['queue_pending'] = _scope_queue_to_org(_remove_sensitive_from_queue(current_queue[1]), org)
|
||||
return web.json_response(queue_info)
|
||||
|
||||
@routes.post("/prompt")
|
||||
@@ -1050,6 +1091,21 @@ class PromptServer():
|
||||
|
||||
self.node_replace_manager.apply_replacements(prompt)
|
||||
|
||||
org_id = self._get_org_id(request) # IAM org — scopes outputs + billing
|
||||
# BYO-GPU dispatch: cloud pods only. A worker-mode node IS the
|
||||
# render backend — it must queue locally, never re-dispatch.
|
||||
# Runs BEFORE local validation: the job executes on the org's GPU
|
||||
# box, which validates against ITS node classes and model files. A
|
||||
# GPU-less pod has an empty model list and would wrongly reject
|
||||
# every checkpoint-referencing graph ("not in []").
|
||||
if not args.worker_mode:
|
||||
try:
|
||||
from middleware.gpu_dispatch import dispatch_if_worker
|
||||
except ImportError: # package-style checkout
|
||||
from .middleware.gpu_dispatch import dispatch_if_worker
|
||||
if dispatch_if_worker(request, org_id, prompt_id, prompt):
|
||||
return web.json_response({"prompt_id": prompt_id, "number": number, "node_errors": {}})
|
||||
|
||||
valid = await execution.validate_prompt(prompt_id, prompt, partial_execution_targets)
|
||||
extra_data = {}
|
||||
if "extra_data" in json_data:
|
||||
@@ -1064,19 +1120,15 @@ class PromptServer():
|
||||
if sensitive_val in extra_data:
|
||||
sensitive[sensitive_val] = extra_data.pop(sensitive_val)
|
||||
extra_data["create_time"] = int(time.time() * 1000) # timestamp in milliseconds
|
||||
org_id = self._get_org_id(request) # IAM org — scopes outputs + billing
|
||||
extra_data["org_id"] = org_id
|
||||
# BYO-GPU dispatch: cloud pods only. A worker-mode node IS the
|
||||
# render backend — it must queue locally, never re-dispatch.
|
||||
dispatched = False
|
||||
if not args.worker_mode:
|
||||
try:
|
||||
from middleware.gpu_dispatch import dispatch_if_worker
|
||||
except ImportError: # package-style checkout
|
||||
from .middleware.gpu_dispatch import dispatch_if_worker
|
||||
dispatched = dispatch_if_worker(request, org_id, prompt_id, prompt)
|
||||
if not dispatched:
|
||||
self.prompt_queue.put((number, prompt_id, prompt, extra_data, outputs_to_execute, sensitive))
|
||||
# Carry the requester's verified IAM token to the worker so the
|
||||
# finished render is recorded in the content lane as this user,
|
||||
# into this user's org. `sensitive` is stripped from history (see
|
||||
# SENSITIVE_EXTRA_DATA_KEYS) — the token is never persisted.
|
||||
iam_token = request.get("iam_token")
|
||||
if iam_token:
|
||||
sensitive["iam_token"] = iam_token
|
||||
self.prompt_queue.put((number, prompt_id, prompt, extra_data, outputs_to_execute, sensitive))
|
||||
response = {"prompt_id": prompt_id, "number": number, "node_errors": valid[3]}
|
||||
return web.json_response(response)
|
||||
else:
|
||||
@@ -1094,13 +1146,20 @@ class PromptServer():
|
||||
@routes.post("/queue")
|
||||
async def post_queue(request):
|
||||
json_data = await request.json()
|
||||
org = self._caller_org(request) # None = auth off → upstream behavior
|
||||
if "clear" in json_data:
|
||||
if json_data["clear"]:
|
||||
self.prompt_queue.wipe_queue()
|
||||
if org is None:
|
||||
self.prompt_queue.wipe_queue()
|
||||
else:
|
||||
# SCOPED clear: only THIS org's pending items, never others'.
|
||||
self.prompt_queue.delete_queue_item(lambda a: _item_org(a) == org)
|
||||
if "delete" in json_data:
|
||||
to_delete = json_data['delete']
|
||||
for id_to_delete in to_delete:
|
||||
delete_func = lambda a: a[1] == id_to_delete
|
||||
# Only delete an item the caller owns (org must match when auth on).
|
||||
delete_func = (lambda a, i=id_to_delete: a[1] == i) if org is None \
|
||||
else (lambda a, i=id_to_delete: a[1] == i and _item_org(a) == org)
|
||||
self.prompt_queue.delete_queue_item(delete_func)
|
||||
|
||||
return web.Response(status=200)
|
||||
@@ -1112,16 +1171,19 @@ class PromptServer():
|
||||
except json.JSONDecodeError:
|
||||
json_data = {}
|
||||
|
||||
org = self._caller_org(request) # None = auth off → upstream behavior
|
||||
# Check if a specific prompt_id was provided for targeted interruption
|
||||
prompt_id = json_data.get('prompt_id')
|
||||
if prompt_id:
|
||||
currently_running, _ = self.prompt_queue.get_current_queue()
|
||||
|
||||
# Check if the prompt_id matches any currently running prompt
|
||||
# Check if the prompt_id matches any currently running prompt — and
|
||||
# (multi-tenant) that it belongs to the caller's org, so one tenant
|
||||
# cannot interrupt another tenant's render.
|
||||
should_interrupt = False
|
||||
for item in currently_running:
|
||||
# item structure: (number, prompt_id, prompt, extra_data, outputs_to_execute)
|
||||
if item[1] == prompt_id:
|
||||
if item[1] == prompt_id and (org is None or _item_org(item) == org):
|
||||
logging.info(f"Interrupting prompt {prompt_id}")
|
||||
should_interrupt = True
|
||||
break
|
||||
@@ -1129,11 +1191,18 @@ class PromptServer():
|
||||
if should_interrupt:
|
||||
nodes.interrupt_processing()
|
||||
else:
|
||||
logging.info(f"Prompt {prompt_id} is not currently running, skipping interrupt")
|
||||
else:
|
||||
# No prompt_id provided, do a global interrupt
|
||||
logging.info(f"Prompt {prompt_id} is not currently running / not owned by caller, skipping interrupt")
|
||||
elif org is None:
|
||||
# No prompt_id + auth off → upstream global interrupt.
|
||||
logging.info("Global interrupt (no prompt_id specified)")
|
||||
nodes.interrupt_processing()
|
||||
else:
|
||||
# Multi-tenant: a global interrupt would kill another tenant's render.
|
||||
# Only interrupt if the running job belongs to the caller's org.
|
||||
currently_running, _ = self.prompt_queue.get_current_queue()
|
||||
if any(_item_org(item) == org for item in currently_running):
|
||||
logging.info(f"Interrupting current render for org {org}")
|
||||
nodes.interrupt_processing()
|
||||
|
||||
return web.Response(status=200)
|
||||
|
||||
@@ -1310,6 +1379,16 @@ class PromptServer():
|
||||
return iam_user.get("org_id", "default")
|
||||
return os.environ.get("STUDIO_ORG_ID", "default")
|
||||
|
||||
def _caller_org(self, request) -> str | None:
|
||||
"""The verified caller org to SCOPE cross-tenant reads by — or None when
|
||||
multi-tenant auth is OFF (single-tenant/local dev) so /history + /queue
|
||||
behave exactly as upstream. Only returns a value when there is a real IAM
|
||||
user, so scoping never hides another tenant's data by accident."""
|
||||
iam_user = request.get("iam_user")
|
||||
if iam_user and iam_user.get("org_id"):
|
||||
return iam_user.get("org_id")
|
||||
return None
|
||||
|
||||
async def setup(self):
|
||||
timeout = aiohttp.ClientTimeout(total=None) # no timeout
|
||||
self.client_session = aiohttp.ClientSession(timeout=timeout)
|
||||
@@ -1337,6 +1416,10 @@ class PromptServer():
|
||||
add_copilot_routes(self.routes, self)
|
||||
from middleware.session import add_session_routes
|
||||
add_session_routes(self.routes, self)
|
||||
from middleware.studio_home import add_studio_home_routes
|
||||
add_studio_home_routes(self.routes, self)
|
||||
from middleware.mcp import add_mcp_routes
|
||||
add_mcp_routes(self.routes, self)
|
||||
self.app.add_subapp('/internal', self.internal_routes.get_app())
|
||||
|
||||
# Prefix every route with /api for easier matching for delegation.
|
||||
|
||||
+1
-1
@@ -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,189 @@
|
||||
"""Tests for recording a completed render as an Asset in the Hanzo content lane.
|
||||
|
||||
These are CONTRACT tests. They stand up a real aiohttp server that speaks the
|
||||
exact contract hanzoai/cloud serves — the generic framework surface
|
||||
(POST /v1/framework/Asset) — and drive the real publisher against it over real
|
||||
HTTP. The server side of that contract (the Asset DocType, its draft default, the
|
||||
lifecycle hook that owns every status edge, cross-org isolation) is proven
|
||||
independently against the real engine and a real SQLite by the content lane's own
|
||||
Go tests in hanzoai/cloud (clients/content/content_test.go).
|
||||
|
||||
What is asserted here is Studio's half: that a finished render produces exactly one
|
||||
Asset per output artifact, in the SAME field shape clients/content itself writes
|
||||
(so a studio-initiated render is indistinguishable from a lane-initiated one); that
|
||||
`file` is the org-scoped output key (the bytes Studio already persisted — this
|
||||
module uploads nothing); that every call is authenticated AS THE REQUESTING USER;
|
||||
that assets land as drafts (never auto-published to a storefront); and that a
|
||||
render is never failed when the lane is unreachable.
|
||||
"""
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from middleware import content_publish
|
||||
|
||||
pytestmark = pytest.mark.asyncio # module is all-async
|
||||
|
||||
TOKEN = "the-users-iam-access-token"
|
||||
PROMPT_ID = "b6f1c0e2-0000-4000-8000-000000000001"
|
||||
WORKFLOW = {"3": {"class_type": "KSampler", "inputs": {"steps": 30}},
|
||||
"4": {"class_type": "UNETLoader", "inputs": {"unet_name": "qwen-image-edit-2511.safetensors"}}}
|
||||
HISTORY = {"outputs": {"9": {"images": [
|
||||
{"filename": "life1_00001_.png", "subfolder": "swimwear/lifestyle2/valentina", "type": "output"},
|
||||
{"filename": "life1_00002_.png", "subfolder": "swimwear/lifestyle2/valentina", "type": "output"},
|
||||
]}, "10": {"images": [
|
||||
{"filename": "preview.png", "subfolder": "", "type": "temp"}, # a preview is never an artifact
|
||||
]}}}
|
||||
EXTRA = {"design": "valentina", "kind": "lifestyle", "role": "life1",
|
||||
"prompt": "poolside, golden hour", "project": "fashion", "tags": "valentina,life1"}
|
||||
|
||||
|
||||
class Content:
|
||||
"""A stand-in for hanzoai/cloud's framework surface, module 'marketing'."""
|
||||
|
||||
def __init__(self, fail: bool = False):
|
||||
self.assets: list[dict] = []
|
||||
self.fail = fail
|
||||
|
||||
def app(self) -> web.Application:
|
||||
app = web.Application()
|
||||
app.router.add_post("/v1/framework/Asset", self._asset)
|
||||
return app
|
||||
|
||||
async def _asset(self, request):
|
||||
if request.headers.get("Authorization") != f"Bearer {TOKEN}":
|
||||
return web.json_response({"error": "valid principal required"}, status=403)
|
||||
body = await request.json()
|
||||
if self.fail:
|
||||
return web.json_response({"error": "boom"}, status=500)
|
||||
# The lane defaults status to draft; the client does not (and must not) set it.
|
||||
doc = {**body, "name": "hash-id", "doctype": "Asset", "status": "draft"}
|
||||
self.assets.append(doc)
|
||||
return web.json_response(doc, status=201)
|
||||
|
||||
|
||||
async def _publish(content, aiohttp_client, monkeypatch, **kw):
|
||||
monkeypatch.setenv("STUDIO_CONTENT_PUBLISH", "1")
|
||||
client: TestClient = await aiohttp_client(TestServer(content.app()))
|
||||
monkeypatch.setattr(content_publish, "CLOUD_URL", str(client.make_url("")).rstrip("/"))
|
||||
args = {"org_id": "karma", "prompt_id": PROMPT_ID, "workflow": WORKFLOW,
|
||||
"history_result": HISTORY, "iam_token": TOKEN, "extra_data": EXTRA}
|
||||
args.update(kw)
|
||||
return await content_publish.publish_render(**args)
|
||||
|
||||
|
||||
# --- pure helpers --------------------------------------------------------
|
||||
|
||||
async def test_artifacts_are_outputs_only_never_previews():
|
||||
refs = content_publish.artifacts(HISTORY)
|
||||
assert [r["filename"] for r in refs] == ["life1_00001_.png", "life1_00002_.png"]
|
||||
|
||||
|
||||
async def test_object_key_is_the_org_scoped_output_key():
|
||||
ref = {"filename": "life1_00001_.png", "subfolder": "swimwear/lifestyle2/valentina", "type": "output"}
|
||||
assert content_publish.object_key("karma", ref) == \
|
||||
"orgs/karma/output/swimwear/lifestyle2/valentina/life1_00001_.png"
|
||||
# no subfolder collapses cleanly (no doubled slash)
|
||||
assert content_publish.object_key("karma", {"filename": "x.png", "subfolder": "", "type": "output"}) == \
|
||||
"orgs/karma/output/x.png"
|
||||
|
||||
|
||||
async def test_kind_must_be_a_valid_select_value():
|
||||
assert content_publish.kind_of({"kind": "lifestyle"}) == "lifestyle"
|
||||
assert content_publish.kind_of({"kind": "nonsense"}) == "product" # off-list -> the default
|
||||
assert content_publish.kind_of({}) == "product"
|
||||
|
||||
|
||||
# --- the happy path ------------------------------------------------------
|
||||
|
||||
async def test_records_one_asset_per_artifact(aiohttp_client, monkeypatch):
|
||||
content = Content()
|
||||
assert await _publish(content, aiohttp_client, monkeypatch) == 2
|
||||
assert len(content.assets) == 2
|
||||
assert [a["title"] for a in content.assets] == ["life1_00001_.png", "life1_00002_.png"]
|
||||
|
||||
|
||||
async def test_asset_matches_the_content_lane_field_shape(aiohttp_client, monkeypatch):
|
||||
content = Content()
|
||||
await _publish(content, aiohttp_client, monkeypatch)
|
||||
a = content.assets[0]
|
||||
# The exact fields clients/content writes (studio_render.go draftAsset).
|
||||
assert a["file"] == "orgs/karma/output/swimwear/lifestyle2/valentina/life1_00001_.png"
|
||||
assert a["kind"] == "lifestyle"
|
||||
assert a["design"] == "valentina"
|
||||
assert a["role"] == "life1"
|
||||
assert a["source_prompt_id"] == PROMPT_ID
|
||||
assert a["render_params"] == WORKFLOW # the graph reproduces it
|
||||
assert a["generator"] == "qwen-image-edit-2511.safetensors"
|
||||
assert a["project"] == "fashion" and a["tags"] == "valentina,life1"
|
||||
|
||||
|
||||
async def test_assets_land_as_draft_never_auto_published(aiohttp_client, monkeypatch):
|
||||
"""A render must never push an image to a storefront. The client never sends a
|
||||
status; the lane defaults it to draft; a human moves it through the lifecycle."""
|
||||
content = Content()
|
||||
await _publish(content, aiohttp_client, monkeypatch)
|
||||
assert all("status" not in c for c in [content_publish.asset_doc(
|
||||
org_id="karma", prompt_id=PROMPT_ID, workflow=WORKFLOW, ref=r, extra_data=EXTRA
|
||||
) for r in content_publish.artifacts(HISTORY)])
|
||||
assert all(a["status"] == "draft" for a in content.assets)
|
||||
|
||||
|
||||
# --- auth ----------------------------------------------------------------
|
||||
|
||||
async def test_written_as_the_requesting_user(aiohttp_client, monkeypatch):
|
||||
# The stub 403s any call without the user's exact bearer, so a full publish is
|
||||
# itself proof every write carried it.
|
||||
content = Content()
|
||||
assert await _publish(content, aiohttp_client, monkeypatch) == 2
|
||||
|
||||
|
||||
async def test_without_a_user_token_nothing_is_written(aiohttp_client, monkeypatch):
|
||||
content = Content()
|
||||
assert await _publish(content, aiohttp_client, monkeypatch, iam_token=None) == 0
|
||||
assert content.assets == []
|
||||
|
||||
|
||||
# --- a render is never failed --------------------------------------------
|
||||
|
||||
async def test_disabled_is_a_no_op(aiohttp_client, monkeypatch):
|
||||
content = Content()
|
||||
monkeypatch.delenv("STUDIO_CONTENT_PUBLISH", raising=False)
|
||||
monkeypatch.setattr(content_publish, "CLOUD_URL", "http://127.0.0.1:1")
|
||||
# enabled() reads the env directly, so call the real entrypoint with it unset.
|
||||
assert await content_publish.publish_render(
|
||||
org_id="karma", prompt_id=PROMPT_ID, workflow=WORKFLOW,
|
||||
history_result=HISTORY, iam_token=TOKEN, extra_data=EXTRA,
|
||||
) == 0
|
||||
assert content.assets == []
|
||||
|
||||
|
||||
async def test_lane_error_is_survivable(aiohttp_client, monkeypatch):
|
||||
content = Content(fail=True)
|
||||
assert await _publish(content, aiohttp_client, monkeypatch) == 0 # nothing landed, no raise
|
||||
|
||||
|
||||
async def test_unreachable_lane_never_raises(monkeypatch):
|
||||
monkeypatch.setenv("STUDIO_CONTENT_PUBLISH", "1")
|
||||
monkeypatch.setattr(content_publish, "CLOUD_URL", "http://127.0.0.1:1") # nothing listening
|
||||
assert await content_publish.publish_render(
|
||||
org_id="karma", prompt_id=PROMPT_ID, workflow=WORKFLOW,
|
||||
history_result=HISTORY, iam_token=TOKEN, extra_data=EXTRA,
|
||||
) == 0
|
||||
|
||||
|
||||
async def test_empty_render_writes_nothing(aiohttp_client, monkeypatch):
|
||||
content = Content()
|
||||
assert await _publish(content, aiohttp_client, monkeypatch, history_result={"outputs": {}}) == 0
|
||||
assert content.assets == []
|
||||
|
||||
|
||||
# --- the token never leaks into history ----------------------------------
|
||||
|
||||
async def test_iam_token_is_a_sensitive_key_so_it_is_stripped_from_history():
|
||||
# Force the CPU device path before the heavy import: model_management probes
|
||||
# CUDA at import time and a GPU-less CI runner has no NVIDIA driver.
|
||||
from studio.cli_args import args
|
||||
args.cpu = True
|
||||
import execution
|
||||
assert "iam_token" in execution.SENSITIVE_EXTRA_DATA_KEYS
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Unit tests for BYO-GPU dispatch node identity (middleware/gpu_dispatch.py)."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from middleware import gpu_dispatch as g
|
||||
|
||||
|
||||
def test_node_label_prefers_human_name():
|
||||
assert g._node_label({"name": "spark", "hostname": "h", "gpu": True}) == "spark"
|
||||
assert g._node_label({"hostname": "dbc-01", "gpu": True}) == "dbc-01"
|
||||
assert g._node_label({"worker_id": "gpu-acme-123"}) == "gpu-acme-123"
|
||||
assert g._node_label({"gpu_model": "RTX 4090"}) == "RTX 4090"
|
||||
assert g._node_label({}) == "gpu" # never blank
|
||||
|
||||
|
||||
def test_online_gpu_nodes_filters_offline_and_non_gpu(monkeypatch):
|
||||
monkeypatch.setattr(g, "_fleet_machines", lambda tok: [
|
||||
{"name": "spark", "status": "online", "gpu": True},
|
||||
{"name": "idle", "status": "offline", "gpu": True},
|
||||
{"name": "cpu-box", "status": "online", "gpu": False},
|
||||
])
|
||||
assert [m["name"] for m in g._online_gpu_nodes("tok")] == ["spark"]
|
||||
assert g._has_online_gpu("tok") is True
|
||||
|
||||
|
||||
def test_no_online_gpu_when_all_offline(monkeypatch):
|
||||
monkeypatch.setattr(g, "_fleet_machines", lambda tok: [
|
||||
{"name": "spark", "status": "offline", "gpu": True},
|
||||
])
|
||||
assert g._online_gpu_nodes("tok") == []
|
||||
assert g._has_online_gpu("tok") is False
|
||||
@@ -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,162 @@
|
||||
"""Unit tests for the per-org work log (middleware/worklog.py)."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from middleware import worklog
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def orgdirs(tmp_path, monkeypatch):
|
||||
"""Map each org to its own output dir, exactly as folder_paths does per-org, so
|
||||
the tests exercise real cross-tenant isolation (one file per org)."""
|
||||
def out_dir(org=None):
|
||||
d = tmp_path / "orgs" / (org or "default") / "output"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return str(d)
|
||||
monkeypatch.setattr(worklog.folder_paths, "get_org_output_directory", out_dir)
|
||||
return out_dir
|
||||
|
||||
|
||||
def test_record_and_load_roundtrip(orgdirs):
|
||||
row = worklog.record("acme", kind="fix", prompt="brighten", refs=["r.png"],
|
||||
uploads=[], parents=["a.png"], output_prefix="fixes/a",
|
||||
pid="pid-1", lane="gpu")
|
||||
assert row["id"] == "pid-1" and row["status"] == "queued" and row["lane"] == "gpu"
|
||||
rows = worklog.load("acme")
|
||||
assert len(rows) == 1 and rows[0]["parents"] == ["a.png"] and rows[0]["kind"] == "fix"
|
||||
|
||||
|
||||
def test_failed_dispatch_still_recorded_with_reason(orgdirs):
|
||||
row = worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[],
|
||||
parents=["a.png"], output_prefix="fixes/a",
|
||||
status="failed", error="no GPU worker online for this org")
|
||||
assert row["status"] == "failed" and row["id"].startswith("failed-")
|
||||
assert "no GPU worker" in row["error"]
|
||||
assert any(e["s"] == "failed" for e in row["events"]) # visible, not swallowed
|
||||
|
||||
|
||||
def test_set_status_advances_but_respects_terminal(orgdirs):
|
||||
worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
|
||||
output_prefix="fixes/a", pid="p1")
|
||||
assert worklog.set_status("acme", "p1", "running")
|
||||
assert worklog.load("acme")[0]["status"] == "running"
|
||||
assert worklog.set_status("acme", "p1", "cancelled")
|
||||
# a terminal row is frozen — a late 'done' can't resurrect a cancelled job
|
||||
assert not worklog.set_status("acme", "p1", "done")
|
||||
assert worklog.load("acme")[0]["status"] == "cancelled"
|
||||
|
||||
|
||||
def test_cross_tenant_isolation(orgdirs):
|
||||
worklog.record("acme", kind="fix", prompt="secret acme prompt", refs=[], uploads=[],
|
||||
parents=["a.png"], output_prefix="fixes/a", pid="p1")
|
||||
# a different org's log is a different file — zero leakage either direction
|
||||
assert worklog.load("acme") and worklog.load("globex") == []
|
||||
worklog.record("globex", kind="fix", prompt="globex prompt", refs=[], uploads=[],
|
||||
parents=["b.png"], output_prefix="fixes/b", pid="p2")
|
||||
assert [r["prompt"] for r in worklog.load("acme")] == ["secret acme prompt"]
|
||||
assert [r["prompt"] for r in worklog.load("globex")] == ["globex prompt"]
|
||||
|
||||
|
||||
def test_cap_keeps_newest(orgdirs, monkeypatch):
|
||||
monkeypatch.setattr(worklog, "_CAP", 5)
|
||||
for i in range(12):
|
||||
worklog.record("acme", kind="fix", prompt=f"p{i}", refs=[], uploads=[],
|
||||
parents=[], output_prefix=f"fixes/{i}", pid=f"p{i}")
|
||||
rows = worklog.load("acme")
|
||||
assert len(rows) == 5 and [r["prompt"] for r in rows] == [f"p{i}" for i in range(7, 12)]
|
||||
|
||||
|
||||
def test_build_lineage_chain():
|
||||
# a.png --fix--> b.png --fix--> c.png ; and a.png --fix--> d.png (a sibling branch)
|
||||
rows = [
|
||||
{"kind": "fix", "prompt": "step1", "ts": 1, "parents": ["a.png"], "output": "b.png"},
|
||||
{"kind": "fix", "prompt": "step2", "ts": 2, "parents": ["b.png"], "output": "c.png"},
|
||||
{"kind": "fix", "prompt": "branch", "ts": 3, "parents": ["a.png"], "output": "d.png"},
|
||||
]
|
||||
lin = worklog.build_lineage(rows, "c.png")
|
||||
assert [a["path"] for a in lin["ancestors"]] == ["a.png", "b.png"] # oldest first
|
||||
assert lin["node"] == "c.png"
|
||||
assert lin["descendants"] == []
|
||||
# from the root, descendants include both branches, chronological
|
||||
lin2 = worklog.build_lineage(rows, "a.png")
|
||||
assert [d["path"] for d in lin2["descendants"]] == ["b.png", "d.png", "c.png"]
|
||||
assert lin2["ancestors"] == []
|
||||
|
||||
|
||||
def test_record_stores_node(orgdirs):
|
||||
r = worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
|
||||
output_prefix="f", pid="p1", lane="gpu", node="spark")
|
||||
assert r["node"] == "spark"
|
||||
r2 = worklog.record("acme", kind="fix", prompt="y", refs=[], uploads=[], parents=[],
|
||||
output_prefix="g", pid="p2", lane="local")
|
||||
assert r2["node"] == "studio pod" # local jobs run on the pod
|
||||
|
||||
|
||||
def test_mark_started_finished_and_median(orgdirs):
|
||||
worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
|
||||
output_prefix="fixes/x", pid="p1")
|
||||
assert worklog.mark_started("acme", "p1", 1000)
|
||||
r = worklog.load("acme")[0]
|
||||
assert r["started_at"] == 1000 and r["status"] == "running"
|
||||
assert not worklog.mark_started("acme", "p1", 2000) # idempotent: keeps first
|
||||
assert worklog.load("acme")[0]["started_at"] == 1000
|
||||
assert worklog.mark_finished("acme", "p1", 1050)
|
||||
r = worklog.load("acme")[0]
|
||||
assert r["finished_at"] == 1050 and r["status"] == "done"
|
||||
assert worklog.median_durations(worklog.load("acme"))["fix"] == 50 # 1050-1000
|
||||
|
||||
|
||||
def test_median_durations_per_kind():
|
||||
rows = [
|
||||
{"kind": "fix", "ts": 100, "started_at": 100, "finished_at": 110}, # 10
|
||||
{"kind": "fix", "ts": 200, "started_at": 200, "finished_at": 220}, # 20
|
||||
{"kind": "fix", "ts": 300, "started_at": 300, "finished_at": 360}, # 60 → median 20
|
||||
{"kind": "compose", "ts": 0, "started_at": 0, "finished_at": 100}, # 100
|
||||
{"kind": "fix", "ts": 400}, # unfinished → ignored
|
||||
]
|
||||
m = worklog.median_durations(rows)
|
||||
assert m == {"fix": 20, "compose": 100}
|
||||
# no started_at → duration falls back to finished − queued(ts)
|
||||
assert worklog.median_durations([{"kind": "rerun", "ts": 0, "finished_at": 30}]) == {"rerun": 30}
|
||||
|
||||
|
||||
def test_estimate_lanes_positions_and_eta():
|
||||
now = 1000
|
||||
active = [
|
||||
{"id": "a", "kind": "fix", "lane": "gpu", "ts": 100, "status": "running", "started_at": now - 60, "node": "spark"},
|
||||
{"id": "b", "kind": "fix", "lane": "gpu", "ts": 200, "status": "queued", "node": "spark"},
|
||||
{"id": "c", "kind": "compose", "lane": "gpu", "ts": 300, "status": "queued", "node": "spark"},
|
||||
{"id": "d", "kind": "fix", "lane": "local", "ts": 150, "status": "queued", "node": "studio pod"},
|
||||
]
|
||||
medians = {"fix": 120, "compose": 300}
|
||||
est, counts = worklog.estimate_lanes(active, medians, now)
|
||||
assert counts == {"gpu": 3, "local": 1}
|
||||
# running: elapsed 60 of median 120 → 60 remaining
|
||||
assert est["a"]["position"] == 1 and est["a"]["of"] == 3
|
||||
assert est["a"]["elapsed_seconds"] == 60 and est["a"]["remaining_seconds"] == 60 and est["a"]["eta_seconds"] == 60
|
||||
assert est["a"]["node"] == "spark"
|
||||
# queued #2: wait = running remaining(60); eta = 60 + own fix median(120)
|
||||
assert est["b"]["position"] == 2 and est["b"]["wait_seconds"] == 60 and est["b"]["eta_seconds"] == 180
|
||||
# queued #3: wait = 60 + b's fix(120) = 180; eta = 180 + compose(300)
|
||||
assert est["c"]["position"] == 3 and est["c"]["wait_seconds"] == 180 and est["c"]["eta_seconds"] == 480
|
||||
# separate lane, single queued item, nothing running → starts ~now
|
||||
assert est["d"]["position"] == 1 and est["d"]["of"] == 1 and est["d"]["wait_seconds"] == 0 and est["d"]["eta_seconds"] == 120
|
||||
|
||||
|
||||
def test_estimate_lanes_default_median_for_unknown_kind():
|
||||
est, _ = worklog.estimate_lanes(
|
||||
[{"id": "x", "kind": "video", "lane": "gpu", "ts": 1, "status": "queued"}], {}, 0, default_median=200)
|
||||
assert est["x"]["eta_seconds"] == 200
|
||||
|
||||
|
||||
def test_build_lineage_terminates_on_cycle():
|
||||
rows = [
|
||||
{"kind": "fix", "prompt": "x", "ts": 1, "parents": ["y.png"], "output": "x.png"},
|
||||
{"kind": "fix", "prompt": "y", "ts": 2, "parents": ["x.png"], "output": "y.png"},
|
||||
]
|
||||
lin = worklog.build_lineage(rows, "x.png") # must not loop forever
|
||||
assert lin["node"] == "x.png"
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Unit tests for the Studio Home surface (middleware/studio_home.py)."""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from middleware import studio_home as sh
|
||||
|
||||
|
||||
def test_reseed_randomizes_every_sampler_seed():
|
||||
graph = {
|
||||
"a": {"class_type": "KSampler", "inputs": {"seed": 7, "steps": 30}},
|
||||
"b": {"class_type": "KSamplerAdvanced", "inputs": {"noise_seed": 7}},
|
||||
"c": {"class_type": "SaveImage", "inputs": {"filename_prefix": "x"}},
|
||||
}
|
||||
out = sh._reseed(json.loads(json.dumps(graph)))
|
||||
assert out["a"]["inputs"]["steps"] == 30
|
||||
# 2**31 space: a collision with the old seed twice over is not a thing.
|
||||
assert (out["a"]["inputs"]["seed"], out["b"]["inputs"]["noise_seed"]) != (7, 7)
|
||||
|
||||
|
||||
def test_fix_graph_keeps_recipe_invariants():
|
||||
g = sh._fix_graph("ref.png", "make the straps thicker", "fixes/test")
|
||||
ks = g["13"]["inputs"]
|
||||
assert ks["cfg"] == 4.0 and ks["steps"] == 30 # proven floor, not the weak 3.0
|
||||
assert g["12"]["class_type"] == "CFGNorm"
|
||||
assert g["8"]["inputs"]["reference_latents_method"] == "index_timestep_zero"
|
||||
assert g["6"]["inputs"]["image1"] == ["2", 0] # scaled ref, never raw
|
||||
assert "straps thicker" in g["6"]["inputs"]["prompt"]
|
||||
assert g["15"]["inputs"]["filename_prefix"] == "fixes/test"
|
||||
|
||||
|
||||
def test_safe_asset_blocks_traversal(tmp_path):
|
||||
root = tmp_path / "out"
|
||||
(root / "designs").mkdir(parents=True)
|
||||
(root / "designs" / "a.png").write_bytes(b"x")
|
||||
(tmp_path / "secret.txt").write_text("no")
|
||||
assert sh._safe_asset(root, "designs/a.png") is not None
|
||||
assert sh._safe_asset(root, "../secret.txt") is None
|
||||
assert sh._safe_asset(root, "designs/missing.png") is None
|
||||
|
||||
|
||||
def test_status_vocab_matches_library_lifecycle():
|
||||
assert sh._STATUSES == ("draft", "approved", "queued", "published", "deleted")
|
||||
|
||||
|
||||
def test_resolve_uploads_keeps_only_safe_existing_images(tmp_path, monkeypatch):
|
||||
in_dir = tmp_path / "orgs" / "acme" / "input"
|
||||
in_dir.mkdir(parents=True)
|
||||
(in_dir / "ref.png").write_bytes(b"\x89PNG")
|
||||
(in_dir / "shot.jpg").write_bytes(b"jpg")
|
||||
(tmp_path / "secret.txt").write_text("no")
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda org=None: str(in_dir))
|
||||
out = sh._resolve_uploads("acme", [
|
||||
"ref.png", # kept
|
||||
"sub/dir/shot.jpg", # basename kept (upload names are flat)
|
||||
"ref.png", # de-duped
|
||||
"../secret.txt", # traversal + non-image -> dropped
|
||||
"missing.png", # absent -> dropped
|
||||
"notes.txt", # non-image -> dropped
|
||||
42, # non-string -> dropped
|
||||
])
|
||||
assert out == ["ref.png", "shot.jpg"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compose_accepts_uploaded_references(tmp_path, monkeypatch):
|
||||
"""A base library asset + one uploaded image is a valid 2-reference compose:
|
||||
the upload is named directly (already in the input dir), the library asset is
|
||||
staged, and both reach the graph — no lower bound violation."""
|
||||
org = "acme"
|
||||
root = tmp_path / "orgs" / org / "output"
|
||||
root.mkdir(parents=True)
|
||||
(root / "library.json").write_text(json.dumps({"assets": [{"path": "a.png"}]}))
|
||||
(root / "a.png").write_bytes(b"\x89PNG-base")
|
||||
in_dir = tmp_path / "orgs" / org / "input"
|
||||
in_dir.mkdir(parents=True)
|
||||
(in_dir / "up_0001_.png").write_bytes(b"\x89PNG-up")
|
||||
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda o=None: str(in_dir))
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
|
||||
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
|
||||
|
||||
captured = {}
|
||||
async def fake_queue(request, server, graph):
|
||||
captured["graph"] = graph
|
||||
return "pid-1"
|
||||
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
|
||||
|
||||
out = await sh.dispatch_compose(None, None, org, ["a.png"], "merge them", uploads=["up_0001_.png"])
|
||||
assert out["ok"] and out["refs"] == 2
|
||||
load_names = [n["inputs"]["image"] for n in captured["graph"].values()
|
||||
if n["class_type"] == "LoadImage"]
|
||||
assert "up_0001_.png" in load_names # upload referenced by its own name
|
||||
assert any(n.startswith("compose_src_") for n in load_names) # library asset staged
|
||||
|
||||
|
||||
class _FakeQueue:
|
||||
def __init__(self, running=None, pending=None):
|
||||
self.running = running or []
|
||||
self.pending = pending or []
|
||||
self.deleted = []
|
||||
|
||||
def get_current_queue_volatile(self):
|
||||
return (self.running, self.pending)
|
||||
|
||||
def delete_queue_item(self, fn):
|
||||
before = len(self.pending)
|
||||
self.pending = [it for it in self.pending if not fn(it)]
|
||||
self.deleted.append(before - len(self.pending))
|
||||
|
||||
|
||||
class _FakeServer:
|
||||
port = 1
|
||||
|
||||
def __init__(self, **kw):
|
||||
self.prompt_queue = _FakeQueue(**kw)
|
||||
|
||||
|
||||
def _orgaware(tmp_path, monkeypatch):
|
||||
def outd(o=None):
|
||||
d = tmp_path / "orgs" / (o or "default") / "output"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return str(d)
|
||||
def ind(o=None):
|
||||
d = tmp_path / "orgs" / (o or "default") / "input"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return str(d)
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", outd)
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", ind)
|
||||
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
|
||||
return outd, ind
|
||||
|
||||
|
||||
def test_cancel_marks_cancelled_and_removes_gpu_row_org_scoped(tmp_path, monkeypatch):
|
||||
outd, _ = _orgaware(tmp_path, monkeypatch)
|
||||
(Path(outd("acme")) / "library.json").write_text('{"assets":[]}')
|
||||
sh.worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
|
||||
output_prefix="fixes/x", pid="p1", lane="gpu")
|
||||
(Path(outd("acme")) / "render_jobs.json").write_text(json.dumps([{"id": "p1", "prefix": "x"}]))
|
||||
srv = _FakeServer()
|
||||
assert sh._owns_job(srv, "acme", "p1")
|
||||
assert not sh._owns_job(srv, "globex", "p1") # cross-tenant: not owned
|
||||
assert sh._cancel_job(srv, "acme", "p1") == "gpu"
|
||||
assert sh.worklog.load("acme")[0]["status"] == "cancelled"
|
||||
assert json.loads((Path(outd("acme")) / "render_jobs.json").read_text()) == []
|
||||
|
||||
|
||||
def test_cancel_deletes_local_pending_only_when_org_matches(tmp_path, monkeypatch):
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
mine = (1, "p1", {}, {"org_id": "acme"}, [])
|
||||
theirs = (2, "p2", {}, {"org_id": "globex"}, [])
|
||||
srv = _FakeServer(pending=[mine, theirs])
|
||||
sh.worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
|
||||
output_prefix="fixes/x", pid="p1")
|
||||
assert sh._cancel_job(srv, "acme", "p1") == "local"
|
||||
assert [it[1] for it in srv.prompt_queue.pending] == ["p2"] # only mine deleted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_amend_atomic_swap_keeps_original_refs_and_adds_new(tmp_path, monkeypatch):
|
||||
outd, ind = _orgaware(tmp_path, monkeypatch)
|
||||
(Path(outd("acme")) / "library.json").write_text('{"assets":[]}')
|
||||
(Path(ind("acme")) / "fix_src_1.png").write_bytes(b"\x89PNG") # original ref, still present
|
||||
(Path(ind("acme")) / "drop_9.png").write_bytes(b"\x89PNG") # new upload
|
||||
sh.worklog.record("acme", kind="fix", prompt="brighten", refs=["fix_src_1.png"],
|
||||
uploads=[], parents=["a.png"], output_prefix="fixes/a", pid="p1", lane="local")
|
||||
captured = {}
|
||||
async def fake_queue(request, server, graph):
|
||||
captured["graph"] = graph
|
||||
return "p2"
|
||||
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
|
||||
out = await sh.dispatch_amend(None, _FakeServer(), "acme", "p1", "brighten a lot more",
|
||||
paths=[], uploads=["drop_9.png"])
|
||||
assert out["amended_from"] == "p1" and out["prompt_id"] == "p2" and out["refs"] == 2
|
||||
rows = {r["id"]: r for r in sh.worklog.load("acme")}
|
||||
assert rows["p1"]["status"] == "cancelled" # original swapped out
|
||||
assert rows["p2"]["status"] == "queued" and rows["p2"]["prompt"] == "brighten a lot more"
|
||||
loads = [n["inputs"]["image"] for n in captured["graph"].values() if n.get("class_type") == "LoadImage"]
|
||||
assert "fix_src_1.png" in loads and "drop_9.png" in loads # original kept + new added
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_amend_unknown_job_is_not_owned(tmp_path, monkeypatch):
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
with pytest.raises(sh.NotOwned):
|
||||
await sh.dispatch_amend(None, _FakeServer(), "acme", "ghost", "change it")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_amend_terminal_job_rejected(tmp_path, monkeypatch):
|
||||
outd, _ = _orgaware(tmp_path, monkeypatch)
|
||||
sh.worklog.record("acme", kind="fix", prompt="x", refs=["r.png"], uploads=[], parents=[],
|
||||
output_prefix="fixes/x", pid="p1")
|
||||
sh.worklog.set_status("acme", "p1", "done")
|
||||
with pytest.raises(sh.DispatchError):
|
||||
await sh.dispatch_amend(None, _FakeServer(), "acme", "p1", "too late")
|
||||
|
||||
|
||||
def test_prompt_error_message_extracts_real_reason():
|
||||
# model-less pod: /prompt returns a validation error — the user must SEE it.
|
||||
body = {"error": {"type": "prompt_outputs_failed_validation",
|
||||
"message": "Prompt outputs failed validation",
|
||||
"details": "UNETLoader: unet_name 'qwen-image-edit-2511.safetensors' not in []"}}
|
||||
msg = sh._prompt_error_message(body)
|
||||
assert "Prompt outputs failed validation" in msg and "not in []" in msg
|
||||
assert sh._prompt_error_message({}) and "silent" not in sh._prompt_error_message({}).lower()
|
||||
assert sh._prompt_error_message({"error": "no GPU worker"}) == "no GPU worker"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_only_fix_dispatches_single_image(tmp_path, monkeypatch):
|
||||
"""REGRESSION LOCK: click Fix, type text, no references → the single-image
|
||||
/v1/library/fix path (base library asset staged, proven Qwen edit graph)."""
|
||||
org = "acme"
|
||||
root = tmp_path / "orgs" / org / "output"
|
||||
root.mkdir(parents=True)
|
||||
(root / "library.json").write_text(json.dumps({"assets": [{"path": "a.png"}]}))
|
||||
(root / "a.png").write_bytes(b"\x89PNG")
|
||||
in_dir = tmp_path / "orgs" / org / "input"
|
||||
in_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda o=None: str(in_dir))
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
|
||||
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
|
||||
captured = {}
|
||||
async def fake_queue(request, server, graph):
|
||||
captured["graph"] = graph
|
||||
return "pid-fix"
|
||||
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
|
||||
out = await sh.dispatch_fix(None, None, org, "a.png", "make the straps thicker")
|
||||
assert out["ok"] and out["prompt_id"] == "pid-fix"
|
||||
g = captured["graph"]
|
||||
assert g["1"]["class_type"] == "LoadImage" and g["1"]["inputs"]["image"].startswith("fix_src_")
|
||||
assert g["15"]["class_type"] == "SaveImage" and "straps thicker" in g["6"]["inputs"]["prompt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fix_accepts_uploaded_base_image(tmp_path, monkeypatch):
|
||||
"""A freshly uploaded photo can be the fix base (upload=) without landing it in
|
||||
the library first — the '+ add a photo' single-ref path."""
|
||||
org = "acme"
|
||||
in_dir = tmp_path / "orgs" / org / "input"
|
||||
in_dir.mkdir(parents=True)
|
||||
(in_dir / "shot_1.png").write_bytes(b"\x89PNG-up")
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda o=None: str(in_dir))
|
||||
captured = {}
|
||||
async def fake_queue(request, server, graph):
|
||||
captured["graph"] = graph
|
||||
return "pid-up"
|
||||
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
|
||||
out = await sh.dispatch_fix(None, None, org, "", "brighten it", upload="shot_1.png")
|
||||
assert out["ok"] and captured["graph"]["1"]["inputs"]["image"] == "shot_1.png"
|
||||
with pytest.raises(sh.DispatchError):
|
||||
await sh.dispatch_fix(None, None, org, "", "brighten it", upload="../etc/passwd")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queue_prompt_surfaces_downstream_error(tmp_path, monkeypatch):
|
||||
"""A non-200 from the self-POST /prompt (model-less pod) becomes a DispatchError
|
||||
carrying the REAL reason — never a raw 502 the dialog swallows into 'Failed:'."""
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
class Srv:
|
||||
port = None
|
||||
srv = Srv()
|
||||
routes = web.RouteTableDef()
|
||||
|
||||
@routes.post("/prompt")
|
||||
async def prompt(request):
|
||||
return web.json_response({"error": {"message": "Prompt outputs failed validation",
|
||||
"details": "UNETLoader: unet_name '…' not in []"}}, status=400)
|
||||
|
||||
async def edit(request):
|
||||
graph = (await request.json())["graph"]
|
||||
try:
|
||||
pid = await sh._queue_prompt(request, srv, graph)
|
||||
return web.json_response({"prompt_id": pid})
|
||||
except sh.DispatchError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
routes.post("/edit")(edit)
|
||||
|
||||
app = web.Application()
|
||||
app.add_routes(routes)
|
||||
ts = TestServer(app)
|
||||
await ts.start_server()
|
||||
srv.port = ts.port
|
||||
client = TestClient(ts)
|
||||
await client.start_server()
|
||||
r = await client.post("/edit", json={"graph": {"1": {"class_type": "SaveImage", "inputs": {}}}})
|
||||
body = await r.json()
|
||||
assert r.status == 400 and "error" in body
|
||||
assert "failed validation" in body["error"] and "not in []" in body["error"] # real reason, shown
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compose_rejects_single_reference(tmp_path, monkeypatch):
|
||||
org = "acme"
|
||||
root = tmp_path / "orgs" / org / "output"
|
||||
root.mkdir(parents=True)
|
||||
(root / "library.json").write_text(json.dumps({"assets": [{"path": "a.png"}]}))
|
||||
(root / "a.png").write_bytes(b"\x89PNG")
|
||||
in_dir = tmp_path / "orgs" / org / "input"
|
||||
in_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda o=None: str(in_dir))
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
|
||||
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
|
||||
with pytest.raises(sh.DispatchError):
|
||||
await sh.dispatch_compose(None, None, org, ["a.png"], "just one", uploads=[])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_home_redirect_only_hits_root_html(monkeypatch):
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
|
||||
monkeypatch.setenv("STUDIO_HOME_DEFAULT", "1")
|
||||
|
||||
async def handler(_):
|
||||
return web.Response(text="spa")
|
||||
|
||||
# root + html accept -> redirect to /studio
|
||||
req = make_mocked_request("GET", "/", headers={"Accept": "text/html"})
|
||||
with pytest.raises(web.HTTPFound) as e:
|
||||
await sh.home_redirect(req, handler)
|
||||
assert e.value.location == "/studio"
|
||||
|
||||
# advanced escape hatch and non-root paths pass through untouched
|
||||
for path, accept in [("/?advanced=1", "text/html"), ("/assets/x.js", "*/*"), ("/", "application/json")]:
|
||||
req = make_mocked_request("GET", path, headers={"Accept": accept})
|
||||
resp = await sh.home_redirect(req, handler)
|
||||
assert resp.text == "spa"
|
||||
|
||||
# env off (local dev / worker pods): root is untouched
|
||||
monkeypatch.delenv("STUDIO_HOME_DEFAULT")
|
||||
req = make_mocked_request("GET", "/", headers={"Accept": "text/html"})
|
||||
resp = await sh.home_redirect(req, handler)
|
||||
assert resp.text == "spa"
|
||||
@@ -0,0 +1,744 @@
|
||||
{
|
||||
"id": "hanzo-full-generative-pipeline",
|
||||
"revision": 0,
|
||||
"last_node_id": 11,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "HanzoChat",
|
||||
"pos": [
|
||||
40,
|
||||
40
|
||||
],
|
||||
"size": [
|
||||
420,
|
||||
340
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "model"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "prompt",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "prompt"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "system",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "system"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "temperature",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "temperature"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "max_tokens",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "max_tokens"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "stream",
|
||||
"type": "BOOLEAN",
|
||||
"widget": {
|
||||
"name": "stream"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "HanzoChat"
|
||||
},
|
||||
"widgets_values": [
|
||||
"default",
|
||||
"/no_think Write one vivid comma-separated image prompt for a majestic bronze dragon perched on a cliff at golden hour. Output only the prompt.",
|
||||
"You are a concise text-to-image prompt engineer.",
|
||||
0.4,
|
||||
200,
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "HanzoImageGen",
|
||||
"pos": [
|
||||
500,
|
||||
40
|
||||
],
|
||||
"size": [
|
||||
340,
|
||||
220
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "prompt",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "prompt"
|
||||
},
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "model",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "model"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "n",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "n"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
5,
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "HanzoImageGen"
|
||||
},
|
||||
"widgets_values": [
|
||||
"",
|
||||
1024,
|
||||
1024,
|
||||
"default",
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "HanzoImageTo3D",
|
||||
"pos": [
|
||||
880,
|
||||
40
|
||||
],
|
||||
"size": [
|
||||
320,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 5
|
||||
},
|
||||
{
|
||||
"name": "format",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "format"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "texture",
|
||||
"type": "BOOLEAN",
|
||||
"widget": {
|
||||
"name": "texture"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "steps"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "mesh_path",
|
||||
"type": "STRING",
|
||||
"links": []
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "HanzoImageTo3D"
|
||||
},
|
||||
"widgets_values": [
|
||||
"glb",
|
||||
true,
|
||||
0,
|
||||
30
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "HanzoTextToVideo",
|
||||
"pos": [
|
||||
500,
|
||||
320
|
||||
],
|
||||
"size": [
|
||||
340,
|
||||
260
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "prompt",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "prompt"
|
||||
},
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"name": "num_frames",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "num_frames"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "steps"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "model",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "model"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "frames",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
7
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "HanzoTextToVideo"
|
||||
},
|
||||
"widgets_values": [
|
||||
"",
|
||||
81,
|
||||
832,
|
||||
480,
|
||||
30,
|
||||
"default"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "HanzoTTS",
|
||||
"pos": [
|
||||
500,
|
||||
620
|
||||
],
|
||||
"size": [
|
||||
340,
|
||||
160
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"link": 3
|
||||
},
|
||||
{
|
||||
"name": "model",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "model"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "response_format",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "response_format"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "audio",
|
||||
"type": "AUDIO",
|
||||
"links": [
|
||||
8
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "HanzoTTS"
|
||||
},
|
||||
"widgets_values": [
|
||||
"default",
|
||||
"wav"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "HanzoMusic",
|
||||
"pos": [
|
||||
40,
|
||||
620
|
||||
],
|
||||
"size": [
|
||||
420,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "prompt",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "prompt"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "model",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "model"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "response_format",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "response_format"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "audio",
|
||||
"type": "AUDIO",
|
||||
"links": [
|
||||
9
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "HanzoMusic"
|
||||
},
|
||||
"widgets_values": [
|
||||
"epic cinematic orchestral, driving percussion, brass swells, heroic",
|
||||
"default",
|
||||
"wav"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
1240,
|
||||
40
|
||||
],
|
||||
"size": [
|
||||
380,
|
||||
380
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "SaveImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"hanzo_full_pipeline_still"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
880,
|
||||
320
|
||||
],
|
||||
"size": [
|
||||
340,
|
||||
300
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 7
|
||||
},
|
||||
{
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "SaveImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"hanzo_full_pipeline_video_frames"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveAudio",
|
||||
"pos": [
|
||||
880,
|
||||
620
|
||||
],
|
||||
"size": [
|
||||
320,
|
||||
140
|
||||
],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "audio",
|
||||
"type": "AUDIO",
|
||||
"link": 8
|
||||
},
|
||||
{
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "SaveAudio"
|
||||
},
|
||||
"widgets_values": [
|
||||
"audio/hanzo_full_pipeline_narration"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "SaveAudio",
|
||||
"pos": [
|
||||
40,
|
||||
840
|
||||
],
|
||||
"size": [
|
||||
420,
|
||||
140
|
||||
],
|
||||
"flags": {},
|
||||
"order": 9,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "audio",
|
||||
"type": "AUDIO",
|
||||
"link": 9
|
||||
},
|
||||
{
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "SaveAudio"
|
||||
},
|
||||
"widgets_values": [
|
||||
"audio/hanzo_full_pipeline_music"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "HanzoSaveText",
|
||||
"pos": [
|
||||
1240,
|
||||
460
|
||||
],
|
||||
"size": [
|
||||
340,
|
||||
160
|
||||
],
|
||||
"flags": {},
|
||||
"order": 10,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "HanzoSaveText"
|
||||
},
|
||||
"widgets_values": [
|
||||
"hanzo_full_pipeline_prompt"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
"STRING"
|
||||
],
|
||||
[
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
"STRING"
|
||||
],
|
||||
[
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
"STRING"
|
||||
],
|
||||
[
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
11,
|
||||
0,
|
||||
"STRING"
|
||||
],
|
||||
[
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
6,
|
||||
2,
|
||||
0,
|
||||
7,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
7,
|
||||
4,
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
8,
|
||||
5,
|
||||
0,
|
||||
9,
|
||||
0,
|
||||
"AUDIO"
|
||||
],
|
||||
[
|
||||
9,
|
||||
6,
|
||||
0,
|
||||
10,
|
||||
0,
|
||||
"AUDIO"
|
||||
]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"title": "Prompt",
|
||||
"bounding": [
|
||||
20,
|
||||
0,
|
||||
460,
|
||||
380
|
||||
],
|
||||
"color": "#3f789e"
|
||||
},
|
||||
{
|
||||
"title": "Image + 3D",
|
||||
"bounding": [
|
||||
480,
|
||||
0,
|
||||
1140,
|
||||
300
|
||||
],
|
||||
"color": "#a1309b"
|
||||
},
|
||||
{
|
||||
"title": "Video",
|
||||
"bounding": [
|
||||
480,
|
||||
300,
|
||||
740,
|
||||
320
|
||||
],
|
||||
"color": "#b58b2a"
|
||||
},
|
||||
{
|
||||
"title": "Audio (speech + music)",
|
||||
"bounding": [
|
||||
20,
|
||||
600,
|
||||
1180,
|
||||
400
|
||||
],
|
||||
"color": "#2a7d4f"
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"id": "hanzo-native-pipeline",
|
||||
"revision": 0,
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 3,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "HanzoChat",
|
||||
"pos": [40, 40],
|
||||
"size": [400, 320],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "model", "type": "COMBO", "widget": {"name": "model"}, "link": null},
|
||||
{"name": "prompt", "type": "STRING", "widget": {"name": "prompt"}, "link": null},
|
||||
{"name": "system", "type": "STRING", "widget": {"name": "system"}, "link": null},
|
||||
{"name": "temperature", "type": "FLOAT", "widget": {"name": "temperature"}, "link": null},
|
||||
{"name": "max_tokens", "type": "INT", "widget": {"name": "max_tokens"}, "link": null},
|
||||
{"name": "stream", "type": "BOOLEAN", "widget": {"name": "stream"}, "link": null}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "text", "type": "STRING", "links": [1, 2]}
|
||||
],
|
||||
"properties": {"Node name for S&R": "HanzoChat"},
|
||||
"widgets_values": [
|
||||
"default",
|
||||
"/no_think Write one vivid comma-separated image prompt for a serene alpine lake at dawn. Output only the prompt, no preamble.",
|
||||
"You are a concise text-to-image prompt engineer.",
|
||||
0.4,
|
||||
200,
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "HanzoImageGen",
|
||||
"pos": [480, 40],
|
||||
"size": [340, 220],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "prompt", "type": "STRING", "widget": {"name": "prompt"}, "link": 1},
|
||||
{"name": "width", "type": "INT", "widget": {"name": "width"}, "link": null},
|
||||
{"name": "height", "type": "INT", "widget": {"name": "height"}, "link": null},
|
||||
{"name": "model", "type": "COMBO", "widget": {"name": "model"}, "link": null},
|
||||
{"name": "n", "type": "INT", "widget": {"name": "n"}, "link": null}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "IMAGE", "type": "IMAGE", "links": [3]}
|
||||
],
|
||||
"properties": {"Node name for S&R": "HanzoImageGen"},
|
||||
"widgets_values": ["", 512, 512, "default", 1]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "SaveImage",
|
||||
"pos": [860, 40],
|
||||
"size": [400, 400],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 3},
|
||||
{"name": "filename_prefix", "type": "STRING", "widget": {"name": "filename_prefix"}, "link": null}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {"Node name for S&R": "SaveImage"},
|
||||
"widgets_values": ["hanzo_native_pipeline"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "HanzoSaveText",
|
||||
"pos": [480, 320],
|
||||
"size": [340, 180],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "text", "type": "STRING", "link": 2},
|
||||
{"name": "filename_prefix", "type": "STRING", "widget": {"name": "filename_prefix"}, "link": null}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {"Node name for S&R": "HanzoSaveText"},
|
||||
"widgets_values": ["hanzo_native_prompt"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 2, 0, "STRING"],
|
||||
[2, 1, 0, 4, 0, "STRING"],
|
||||
[3, 2, 0, 3, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
# Studio web — the Hanzo Studio product frontend
|
||||
|
||||
Our unique studio frontend: a specialized **chat app** on the shared Hanzo stack.
|
||||
You say what to create; the cloud `/v1/chat` orchestrator drives studio's render
|
||||
tools and the outputs land in the library. This is the default surface at
|
||||
`studio.hanzo.ai/studio`.
|
||||
|
||||
- **Stack**: Vite + React + TS. `@hanzo/*` packages are npm-published, so a clean
|
||||
`npm install` — no workspace linking.
|
||||
- **Chat**: `POST api.hanzo.ai/v1/chat {capability, messages}` → `{reply, actions, ops}`
|
||||
— the cloud `clients/chat` orchestrator, called **directly** (no studio proxy). The
|
||||
`hanzo_token` cookie is `.hanzo.ai`-scoped, so `credentials:'include'` carries it
|
||||
cross-origin and the handler bills the caller org. (Gateway CORS must allow the
|
||||
`studio.hanzo.ai` origin with credentials.)
|
||||
- **Engine**: the studio pod serves `/v1/session`, `/v1/library`, `/v1/render-queue`,
|
||||
`/v1/gpu-status` same-origin (same cookie) — no token handling in the browser.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build # -> dist/ (served at /studio; base = /studio/)
|
||||
npm run dev # local, set VITE_STUDIO_API=https://studio.hanzo.ai
|
||||
```
|
||||
|
||||
The ComfyUI-derived node editor ("Advanced mode") is a **separate** repo —
|
||||
`hanzoai/studio-ui` (formerly `studio-frontend`) — not this app.
|
||||
|
||||
## Status / next
|
||||
- Increment 1 (done): chat wired to `/v1/chat` create + live library/queue/gpu strip.
|
||||
- Increment 2: swap the transcript/composer for `@hanzo/chat` `<Chat>` on `@hanzo/gui`
|
||||
(GuiProvider + SessionProvider, copying console2's `Provider.tsx` spine).
|
||||
- Wiring: Dockerfile multi-stage to build `dist/` and serve it at `/studio` in
|
||||
place of `middleware/studio_home.html`. (No proxy — chat hits api.hanzo.ai direct.)
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Hanzo Studio</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1888
File diff suppressed because it is too large
Load Diff
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@hanzo/studio-chat",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Studio as a specialized chat app — @hanzo/ai + @hanzo/gui talking to the cloud /v1/chat orchestrator, served at studio.hanzo.ai/studio.",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
Vendored
+113
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
chat, getSession, getLibrary, getRenderQueue, gpuOnline, thumbURL,
|
||||
type ChatMessage, type Session, type Asset, type RenderJob,
|
||||
} from './api'
|
||||
|
||||
// Studio-chat: the specialized chat surface. You say what to create; the cloud
|
||||
// /v1/chat "create" capability drives studio's render tools (fix/compose) and the
|
||||
// outputs land in the library strip. Increment 1 uses a minimal own-UI; the next
|
||||
// increment swaps the transcript/composer for @hanzo/chat's <Chat> on @hanzo/gui.
|
||||
|
||||
export function App() {
|
||||
const [session, setSession] = useState<Session>({})
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [assets, setAssets] = useState<Asset[]>([])
|
||||
const [jobs, setJobs] = useState<RenderJob[]>([])
|
||||
const [gpu, setGpu] = useState(false)
|
||||
const scroller = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => { getSession().then(setSession) }, [])
|
||||
|
||||
// Poll the library + in-flight queue so dispatched renders appear as they land.
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
const tick = async () => {
|
||||
if (!alive) return
|
||||
const [lib, q, on] = await Promise.all([getLibrary(), getRenderQueue(), gpuOnline()])
|
||||
if (!alive) return
|
||||
setAssets(lib.assets.filter(a => a.status !== 'deleted').slice(0, 24))
|
||||
setJobs(q.jobs); setGpu(on)
|
||||
}
|
||||
tick()
|
||||
const t = setInterval(tick, 5000)
|
||||
return () => { alive = false; clearInterval(t) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { scroller.current?.scrollTo(0, scroller.current.scrollHeight) }, [messages, busy])
|
||||
|
||||
async function send() {
|
||||
const text = input.trim()
|
||||
if (!text || busy) return
|
||||
const next = [...messages, { role: 'user' as const, content: text }]
|
||||
setMessages(next); setInput(''); setBusy(true)
|
||||
try {
|
||||
const r = await chat(next)
|
||||
const note = r.actions.length
|
||||
? `\n\n${r.actions.map(a => a.error ? `⚠︎ ${a.name}: ${a.error}` : `▸ ${a.name} dispatched`).join('\n')}`
|
||||
: ''
|
||||
setMessages([...next, { role: 'assistant', content: (r.reply || '…') + note }])
|
||||
} catch (e) {
|
||||
setMessages([...next, { role: 'assistant', content: `Couldn't reach the studio chat (${(e as Error).message}).` }])
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="top">
|
||||
<div className="brand">Hanzo <b>Studio</b></div>
|
||||
<div className="spacer" />
|
||||
<div className={`gpu ${gpu ? 'on' : 'off'}`}>{gpu ? 'GPU online' : 'no GPU'}</div>
|
||||
<div className="who">{session.authenticated ? (session.org || session.user || 'signed in') : 'sign in'}</div>
|
||||
</header>
|
||||
|
||||
<main className="body">
|
||||
<section className="chat">
|
||||
<div className="scroll" ref={scroller}>
|
||||
{messages.length === 0 && (
|
||||
<div className="hero">
|
||||
<h1>What should we create?</h1>
|
||||
<p>Describe an edit or a new shot — “make the Valentina back crotchless”,
|
||||
“put design 13 on model 01”. I’ll run it on your GPU and it lands below.</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} className={`msg ${m.role}`}><div className="bubble">{m.content}</div></div>
|
||||
))}
|
||||
{busy && <div className="msg assistant"><div className="bubble dim">working…</div></div>}
|
||||
</div>
|
||||
<div className="composer">
|
||||
<textarea
|
||||
value={input} placeholder="Describe what to create or change…"
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }}
|
||||
/>
|
||||
<button onClick={send} disabled={busy || !input.trim()}>Send</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="rail">
|
||||
{jobs.length > 0 && (
|
||||
<div className="queue">
|
||||
<div className="rail-h">Rendering ({jobs.length})</div>
|
||||
{jobs.map(jb => <div key={jb.id} className="job">{jb.prefix || jb.id.slice(0, 8)}</div>)}
|
||||
</div>
|
||||
)}
|
||||
<div className="rail-h">Library</div>
|
||||
<div className="grid">
|
||||
{assets.map(a => (
|
||||
<a key={a.path} className="cell" href={thumbURL(a.path)} target="_blank" rel="noreferrer">
|
||||
<img loading="lazy" src={thumbURL(a.path)} alt="" />
|
||||
</a>
|
||||
))}
|
||||
{assets.length === 0 && <div className="empty">Renders appear here.</div>}
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Vendored
+52
@@ -0,0 +1,52 @@
|
||||
// The studio-chat data layer. Two backends, no proxy layer between them:
|
||||
// - the cloud chat orchestrator: POST api.hanzo.ai/v1/chat (clients/chat) —
|
||||
// called DIRECTLY, no studio pass-through. The hanzo_token cookie is scoped to
|
||||
// .hanzo.ai so credentials:'include' carries it cross-origin, and the handler
|
||||
// replays it into the per-org-billed completion. (Gateway CORS must allow the
|
||||
// studio.hanzo.ai origin with credentials.)
|
||||
// - the studio engine API: /v1/session, /v1/library, /v1/render-queue, … —
|
||||
// same-origin on studio.hanzo.ai (same cookie).
|
||||
// No token handling in the browser: the cookie is the credential for both.
|
||||
// VITE_STUDIO_API / VITE_CHAT_API override the bases for local dev.
|
||||
|
||||
const BASE = (import.meta.env.VITE_STUDIO_API ?? '').replace(/\/$/, '')
|
||||
const CHAT_API = (import.meta.env.VITE_CHAT_API ?? 'https://api.hanzo.ai').replace(/\/$/, '')
|
||||
|
||||
async function j<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const r = await fetch(url, { credentials: 'include', ...init })
|
||||
if (!r.ok) throw new Error(`${url} -> ${r.status}`)
|
||||
return r.json() as Promise<T>
|
||||
}
|
||||
|
||||
export type Role = 'user' | 'assistant'
|
||||
export interface ChatMessage { role: Role; content: string }
|
||||
export interface Action { name: string; args?: Record<string, unknown>; result?: unknown; error?: string }
|
||||
export interface Op { name: string; args?: Record<string, unknown> }
|
||||
export interface ChatReply { reply: string; actions: Action[]; ops: Op[] }
|
||||
|
||||
// One turn of the create capability: the model edits/combines library assets;
|
||||
// `actions` are the renders it dispatched (each result carries a prompt_id).
|
||||
export function chat(messages: ChatMessage[], capability = 'create'): Promise<ChatReply> {
|
||||
return j<ChatReply>(`${CHAT_API}/v1/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ capability, messages }),
|
||||
})
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
user?: string; email?: string; org?: string; orgs?: string[]
|
||||
authenticated?: boolean; iam_url?: string
|
||||
}
|
||||
export const getSession = () => j<Session>(`${BASE}/v1/session`).catch(() => ({ authenticated: false } as Session))
|
||||
|
||||
export interface Asset { path: string; status?: string; mtime?: number; view?: unknown }
|
||||
export const getLibrary = () => j<{ org: string; assets: Asset[] }>(`${BASE}/v1/library`).catch(() => ({ org: '', assets: [] }))
|
||||
|
||||
export interface RenderJob { id: string; prefix?: string; refs?: string[]; ts?: number; age?: number; status?: string }
|
||||
export const getRenderQueue = () => j<{ jobs: RenderJob[] }>(`${BASE}/v1/render-queue`).catch(() => ({ jobs: [] }))
|
||||
export const gpuOnline = () => j<{ online: boolean }>(`${BASE}/v1/gpu-status`).then(r => r.online).catch(() => false)
|
||||
|
||||
// Thumbnail URL for a library asset (the studio pod serves a downsized JPEG).
|
||||
export const thumbURL = (path: string) => `${BASE}/v1/library/file?thumb=1&path=${encodeURIComponent(path)}`
|
||||
export const fileURL = (path: string) => `${BASE}/v1/library/file?path=${encodeURIComponent(path)}`
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
import './styles.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
Vendored
+48
@@ -0,0 +1,48 @@
|
||||
:root {
|
||||
--bg: #0d0d0f; --panel: #151517; --line: #26262b; --text: #f4f4f6;
|
||||
--dim: #9a9aa2; --accent: #f4f4f6; --accent-ink: #0d0d0f;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root { --bg: #fafafa; --panel: #fff; --line: #e6e6ea; --text: #16161a; --dim: #6b6b73; --accent: #16161a; --accent-ink: #fff; }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
body { background: var(--bg); color: var(--text); font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||||
|
||||
.app { display: flex; flex-direction: column; height: 100%; }
|
||||
.top { display: flex; align-items: center; gap: 12px; padding: 12px 18px; border-bottom: 1px solid var(--line); }
|
||||
.brand { font-weight: 500; letter-spacing: .2px; } .brand b { font-weight: 700; }
|
||||
.spacer { flex: 1; }
|
||||
.gpu { font-size: 12px; padding: 3px 8px; border-radius: 999px; border: 1px solid var(--line); color: var(--dim); }
|
||||
.gpu.on { color: var(--text); } .gpu.on::before { content: "● "; color: #35c26b; }
|
||||
.who { font-size: 13px; color: var(--dim); }
|
||||
|
||||
.body { flex: 1; display: grid; grid-template-columns: 1fr 320px; min-height: 0; }
|
||||
.chat { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--line); }
|
||||
.scroll { flex: 1; overflow-y: auto; padding: 22px; }
|
||||
.hero { max-width: 560px; margin: 8vh auto 0; text-align: center; }
|
||||
.hero h1 { font-size: 30px; font-weight: 600; margin: 0 0 10px; }
|
||||
.hero p { color: var(--dim); }
|
||||
.msg { display: flex; margin: 10px 0; } .msg.user { justify-content: flex-end; }
|
||||
.bubble { max-width: 74%; padding: 10px 14px; border-radius: 14px; white-space: pre-wrap; border: 1px solid var(--line); background: var(--panel); }
|
||||
.msg.user .bubble { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
|
||||
.bubble.dim { color: var(--dim); }
|
||||
|
||||
.composer { display: flex; gap: 10px; padding: 14px; border-top: 1px solid var(--line); }
|
||||
.composer textarea { flex: 1; resize: none; height: 44px; padding: 11px 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); color: var(--text); font: inherit; }
|
||||
.composer button { padding: 0 18px; border-radius: 12px; border: 0; background: var(--accent); color: var(--accent-ink); font-weight: 600; cursor: pointer; }
|
||||
.composer button:disabled { opacity: .5; cursor: default; }
|
||||
|
||||
.rail { overflow-y: auto; padding: 16px; }
|
||||
.rail-h { font-size: 12px; text-transform: uppercase; letter-spacing: .6px; color: var(--dim); margin: 14px 0 8px; }
|
||||
.queue .job { font-size: 12px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 8px; margin-bottom: 6px; color: var(--dim); }
|
||||
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
|
||||
.cell { aspect-ratio: 3 / 4; overflow: hidden; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); }
|
||||
.cell img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.empty { color: var(--dim); font-size: 13px; }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.body { grid-template-columns: 1fr; }
|
||||
.rail { display: none; }
|
||||
.chat { border-right: 0; }
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// Served same-origin by the studio Python pod at /studio, so the app is based at
|
||||
// /studio/ and every /v1/* fetch rides the same-origin hanzo_token cookie (no CORS).
|
||||
export default defineConfig({
|
||||
base: '/studio/',
|
||||
plugins: [react()],
|
||||
build: { outDir: 'dist', sourcemap: false },
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
# zen-video — headless text-to-video queue (GB10)
|
||||
|
||||
Fronts Hanzo Studio (ComfyUI, `:8188`) on the spark GB10 box with a small,
|
||||
serialized, persisted queue that turns a prompt into a real MP4. Public model
|
||||
name is the **Zen video family** (`zen3-video`); the diffusion backend is the
|
||||
Zen video model (Wan2.2 TI2V-5B). The upstream/private model filenames live
|
||||
only in `zen_wan_workflow.py` and are NEVER returned to callers.
|
||||
|
||||
## Why a queue
|
||||
|
||||
The GB10 is one GPU — one generation at a time. A single async worker drains an
|
||||
ordered queue and submits to Studio, which itself serializes on the GPU. Jobs
|
||||
persist in SQLite (`jobs.db`) so a service restart never loses in-flight or
|
||||
finished state; interrupted jobs are re-queued on boot.
|
||||
|
||||
## Two API surfaces (composable, one backend)
|
||||
|
||||
1. **OpenAI-style** — for direct/frontend use, gated by gateway-injected identity:
|
||||
- `POST /v1/videos/generations` `{model, prompt, size?, seconds?, seed?, negative_prompt?}`
|
||||
→ `202 {id, status:"queued", ...}`. Requires `X-Org-Id` (the gateway injects
|
||||
it from the IAM JWT); anonymous is refused unless `ZEN_ALLOW_ANON=1`.
|
||||
- `GET /v1/videos/generations/{id}` → status; on completion `{status:"completed", url}`.
|
||||
- `GET /v1/videos/files/{name}` → the MP4 (served over the Cloudflare tunnel).
|
||||
- Bills per generated second into the `usage` table (and posts to `ZEN_METER_URL` if set).
|
||||
|
||||
2. **do-ai-compatible async** — driven by the cloud LLM gateway (`hanzoai/ai`
|
||||
`GenerateVideoDOAI`), gated by a Bearer provider key (`ZEN_PROVIDER_KEY`):
|
||||
- `POST /v1/videos` `{model, prompt, size?, seconds?}` → `202 {id:"video_…", object:"video", status:"queued"}`
|
||||
- `GET /v1/videos/{id}` → `{status:"queued|in_progress|completed|failed"}`
|
||||
- `GET /v1/videos/{id}/content` → raw `video/mp4` (only once completed)
|
||||
|
||||
This is the exact Sora-style create→poll→download contract the ai service's
|
||||
video client expects, so the ai registry adopts spark by pointing the
|
||||
`zen3-video` provider at `https://spark-video.hanzo.ai/v1` — no ai-side
|
||||
client change.
|
||||
|
||||
`GET /healthz` reports comfy up/down + queue depth. `GET /v1/videos/models`
|
||||
lists the Zen video SKUs.
|
||||
|
||||
## Models
|
||||
|
||||
| id | default | max s | steps | $/s |
|
||||
|----|---------|-------|-------|-----|
|
||||
| `zen3-video` | 1280×704 | 5 | 20 | 0.20 |
|
||||
| `zen3-video-fast` | 704×704 | 3 | 16 | 0.08 |
|
||||
| `zen3-video-pro` | 1280×704 | 5 | 30 | 0.40 |
|
||||
| `wan2-2-t2v-a14b` | 704×704 | 3 | 20 | 0.20 | (do-ai upstream id, moderate default to fit the gateway's 300s ceiling) |
|
||||
|
||||
## Deploy (spark, systemd)
|
||||
|
||||
Runs under the Studio venv (`aiohttp`, `boto3`). Unit: `zen-video.service`
|
||||
(`Requires=studio.service`). Secrets in `/etc/zen-video.env` (chmod 600):
|
||||
`ZEN_PROVIDER_KEY` (KMS `hanzo/video/SPARK_VIDEO_PROVIDER_KEY`).
|
||||
|
||||
```
|
||||
sudo cp zen-video.service /etc/systemd/system/ && sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now zen-video.service
|
||||
```
|
||||
|
||||
Reachability: spark is LAN-only/NAT'd. The `lux-dchain` cloudflared tunnel routes
|
||||
`spark-video.hanzo.ai → localhost:8189` (TLS terminated by Cloudflare).
|
||||
|
||||
## Delivery
|
||||
|
||||
S3-first (SeaweedFS) when `ZEN_S3_*` is set, else the MP4 is served from this
|
||||
service over the tunnel at `/v1/videos/files/{name}` (a scoped, unguessable URL).
|
||||
NOTE: the cluster SeaweedFS admin identity is currently a placeholder
|
||||
(`PLACEHOLDER`/`PLACEHOLDER`) — provision real creds in KMS to enable S3.
|
||||
|
||||
## Ops
|
||||
|
||||
`studio_clean_restart.sh` breaks a Studio EADDRINUSE/JIT crash-loop: it stops the
|
||||
service, frees `:8188` of orphans, waits for memory to settle, and starts one
|
||||
clean instance (studio's `comfy_kitchen` backend JIT-compiles CUDA kernels once
|
||||
on first clean boot; a crash-loop never lets that finish → runaway `cicc`
|
||||
compilers exhaust unified memory).
|
||||
Binary file not shown.
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hard-clean restart of studio.service: break orphan/EADDRINUSE crash loop.
|
||||
set -u
|
||||
echo "[1] stop service"
|
||||
sudo systemctl stop studio.service 2>&1
|
||||
sleep 2
|
||||
echo "[2] kill all studio python + free 8188 (repeat until clear)"
|
||||
for i in 1 2 3 4 5; do
|
||||
sudo fuser -k 8188/tcp 2>/dev/null
|
||||
pkill -9 -f "studio/.venv/bin/python main.py" 2>/dev/null
|
||||
sleep 2
|
||||
holders=$(sudo ss -tlnp 2>/dev/null | grep -c ':8188 ')
|
||||
procs=$(pgrep -c -f "studio/.venv/bin/python main.py" 2>/dev/null || echo 0)
|
||||
echo " pass=$i holders=$holders procs=$procs"
|
||||
if [ "$holders" = "0" ] && [ "$procs" = "0" ]; then break; fi
|
||||
done
|
||||
echo "[3] confirm 8188 truly dead"
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://127.0.0.1:8188/ 2>/dev/null)
|
||||
echo " curl 8188 -> $code (000 = free)"
|
||||
echo "[4] mem"
|
||||
free -g | awk '/Mem:/{print " free="$4"GB used="$3"GB"}'
|
||||
echo "[5] reset-failed + start"
|
||||
sudo systemctl reset-failed studio.service
|
||||
sudo systemctl start studio.service
|
||||
echo " start rc=$? state=$(systemctl is-active studio.service)"
|
||||
@@ -0,0 +1,29 @@
|
||||
[Unit]
|
||||
Description=Zen Video - headless text-to-video queue (GB10) fronting Hanzo Studio
|
||||
Documentation=https://github.com/hanzoai/studio
|
||||
After=network-online.target studio.service
|
||||
Wants=network-online.target
|
||||
Requires=studio.service
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=z
|
||||
Group=z
|
||||
WorkingDirectory=/home/z/work/hanzo/studio/zen-video
|
||||
EnvironmentFile=/etc/zen-video.env
|
||||
Environment=ZEN_COMFY_URL=http://127.0.0.1:8188
|
||||
Environment=ZEN_VIDEO_PORT=8189
|
||||
Environment=ZEN_OUTPUT_DIR=/home/z/work/hanzo/studio/output
|
||||
Environment=ZEN_DB_PATH=/home/z/work/hanzo/studio/zen-video/jobs.db
|
||||
Environment=ZEN_PUBLIC_BASE=https://spark-video.hanzo.ai
|
||||
Environment=ZEN_ALLOW_ANON=0
|
||||
ExecStart=/home/z/work/hanzo/studio/.venv/bin/python zen_video_service.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
KillMode=mixed
|
||||
TimeoutStopSec=15
|
||||
Nice=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,551 @@
|
||||
#!/usr/bin/env python3
|
||||
"""zen-video: headless text-to-video queue service (Zen video family) on the GB10.
|
||||
|
||||
Public model id: zen3-video. Backend: Hanzo Studio (ComfyUI) on :8188 running the
|
||||
Zen video diffusion model. The upstream/private model filenames live only inside
|
||||
zen_wan_workflow.py and are NEVER returned to callers.
|
||||
|
||||
One GPU => one job at a time. A single asyncio worker drains an ordered queue and
|
||||
submits to Studio, which itself serializes on the GPU. Jobs persist in SQLite so a
|
||||
service restart never loses in-flight/finished job state.
|
||||
|
||||
Delivery: S3 (SeaweedFS) when ZEN_S3_* is configured, else the finished mp4 is
|
||||
served directly from this service over the Cloudflare tunnel at
|
||||
/v1/videos/files/{name} (a scoped, unguessable URL).
|
||||
|
||||
Billing: identity is injected by the gateway as X-User-Id / X-Org-Id (from the
|
||||
IAM JWT). Every completed job writes a usage row and, if ZEN_METER_URL is set,
|
||||
posts a metering event. Requests without an org identity are refused unless
|
||||
ZEN_ALLOW_ANON=1 (dev only) — video is expensive, never free by accident.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
|
||||
from zen_wan_workflow import build_prompt
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config (env, matches studio.service conventions)
|
||||
# ---------------------------------------------------------------------------
|
||||
COMFY_URL = os.environ.get("ZEN_COMFY_URL", "http://127.0.0.1:8188")
|
||||
PORT = int(os.environ.get("ZEN_VIDEO_PORT", "8189"))
|
||||
OUTPUT_DIR = Path(os.environ.get("ZEN_OUTPUT_DIR", "/home/z/work/hanzo/studio/output"))
|
||||
DB_PATH = os.environ.get("ZEN_DB_PATH", "/home/z/zen-video/jobs.db")
|
||||
PUBLIC_BASE = os.environ.get("ZEN_PUBLIC_BASE", "").rstrip("/") # e.g. https://spark-video.hanzo.ai
|
||||
ALLOW_ANON = os.environ.get("ZEN_ALLOW_ANON", "0") == "1"
|
||||
METER_URL = os.environ.get("ZEN_METER_URL", "").rstrip("/")
|
||||
# Bearer key that the cloud LLM gateway (hanzoai/ai) presents on the do-ai-compatible
|
||||
# async video API (/v1/videos*). Provisioned in KMS, set as the "spark-video" provider
|
||||
# apiKey in the ai model registry. Empty in dev => async API is open (dev only).
|
||||
PROVIDER_KEY = os.environ.get("ZEN_PROVIDER_KEY", "")
|
||||
POLL_SECS = float(os.environ.get("ZEN_POLL_SECS", "2"))
|
||||
JOB_TIMEOUT = float(os.environ.get("ZEN_JOB_TIMEOUT", "3600"))
|
||||
|
||||
# S3 (optional durable delivery)
|
||||
S3_ENDPOINT = os.environ.get("ZEN_S3_ENDPOINT", "")
|
||||
S3_EXTERNAL = os.environ.get("ZEN_S3_EXTERNAL", "").rstrip("/")
|
||||
S3_BUCKET = os.environ.get("ZEN_S3_BUCKET", "videos")
|
||||
S3_KEY = os.environ.get("ZEN_S3_ACCESS_KEY", "")
|
||||
S3_SECRET = os.environ.get("ZEN_S3_SECRET_KEY", "")
|
||||
S3_REGION = os.environ.get("ZEN_S3_REGION", "us-east-1")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public model registry — Zen names only. Maps to backend generation params.
|
||||
# NEVER surface the private model filename to a caller.
|
||||
# ---------------------------------------------------------------------------
|
||||
MODELS = {
|
||||
"zen3-video": {
|
||||
"default_size": (1280, 704), # native 720p
|
||||
"max_seconds": 5,
|
||||
"fps": 24,
|
||||
"steps": 20,
|
||||
"cfg": 5.0,
|
||||
"shift": 8.0,
|
||||
# metered price (USD) — flat per generated second; video is expensive.
|
||||
"usd_per_second": 0.20,
|
||||
},
|
||||
# fast/preview SKU: lower res + fewer frames, cheaper
|
||||
"zen3-video-fast": {
|
||||
"default_size": (704, 704),
|
||||
"max_seconds": 3,
|
||||
"fps": 24,
|
||||
"steps": 16,
|
||||
"cfg": 5.0,
|
||||
"shift": 8.0,
|
||||
"usd_per_second": 0.08,
|
||||
},
|
||||
"zen3-video-pro": {
|
||||
"default_size": (1280, 704),
|
||||
"max_seconds": 5,
|
||||
"fps": 24,
|
||||
"steps": 30,
|
||||
"cfg": 5.0,
|
||||
"shift": 8.0,
|
||||
"usd_per_second": 0.40,
|
||||
},
|
||||
# do-ai upstream id: the cloud LLM gateway maps zen3-video -> this upstream
|
||||
# model on the async video provider. Default kept moderate so a generation
|
||||
# completes inside the gateway client's 300s ceiling.
|
||||
"wan2-2-t2v-a14b": {
|
||||
"default_size": (704, 704),
|
||||
"max_seconds": 3,
|
||||
"fps": 24,
|
||||
"steps": 20,
|
||||
"cfg": 5.0,
|
||||
"shift": 8.0,
|
||||
"usd_per_second": 0.20,
|
||||
},
|
||||
}
|
||||
|
||||
# do-ai async status = OpenAI Sora-style. Map internal job status to it.
|
||||
_DOAI_STATUS = {
|
||||
"queued": "queued", "running": "in_progress",
|
||||
"completed": "completed", "failed": "failed",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SQLite job store
|
||||
# ---------------------------------------------------------------------------
|
||||
def db() -> sqlite3.Connection:
|
||||
c = sqlite3.connect(DB_PATH, timeout=30)
|
||||
c.row_factory = sqlite3.Row
|
||||
return c
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
with db() as c:
|
||||
c.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS jobs(
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
model TEXT, prompt TEXT, params TEXT,
|
||||
org_id TEXT, user_id TEXT,
|
||||
prompt_id TEXT,
|
||||
url TEXT, filename TEXT,
|
||||
seconds REAL, width INTEGER, height INTEGER,
|
||||
error TEXT,
|
||||
usd REAL DEFAULT 0,
|
||||
created REAL, started REAL, finished REAL
|
||||
)"""
|
||||
)
|
||||
c.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS usage(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id TEXT, org_id TEXT, user_id TEXT, model TEXT,
|
||||
seconds REAL, usd REAL, ts REAL, metered INTEGER DEFAULT 0
|
||||
)"""
|
||||
)
|
||||
|
||||
|
||||
def job_row(job_id: str):
|
||||
with db() as c:
|
||||
r = c.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
|
||||
return dict(r) if r else None
|
||||
|
||||
|
||||
def job_update(job_id: str, **kw) -> None:
|
||||
if not kw:
|
||||
return
|
||||
cols = ", ".join(f"{k}=?" for k in kw)
|
||||
with db() as c:
|
||||
c.execute(f"UPDATE jobs SET {cols} WHERE id=?", (*kw.values(), job_id))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serialization view for API responses (Zen surface — never leak backend)
|
||||
# ---------------------------------------------------------------------------
|
||||
def public_job(r: dict) -> dict:
|
||||
out = {
|
||||
"id": r["id"],
|
||||
"object": "video.generation",
|
||||
"model": r["model"],
|
||||
"status": r["status"],
|
||||
"created": r.get("created"),
|
||||
}
|
||||
if r.get("seconds"):
|
||||
out["seconds"] = r["seconds"]
|
||||
if r.get("width") and r.get("height"):
|
||||
out["size"] = f"{r['width']}x{r['height']}"
|
||||
if r["status"] == "completed" and r.get("url"):
|
||||
out["url"] = r["url"]
|
||||
out["data"] = [{"url": r["url"]}]
|
||||
if r["status"] == "failed" and r.get("error"):
|
||||
out["error"] = {"message": r["error"]}
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ComfyUI interaction (async)
|
||||
# ---------------------------------------------------------------------------
|
||||
async def comfy_submit(session: aiohttp.ClientSession, graph: dict) -> str:
|
||||
async with session.post(f"{COMFY_URL}/prompt",
|
||||
json={"prompt": graph, "client_id": "zen-video"},
|
||||
timeout=aiohttp.ClientTimeout(total=120)) as r:
|
||||
r.raise_for_status()
|
||||
return (await r.json())["prompt_id"]
|
||||
|
||||
|
||||
async def comfy_wait(session: aiohttp.ClientSession, prompt_id: str) -> dict:
|
||||
deadline = time.time() + JOB_TIMEOUT
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
async with session.get(f"{COMFY_URL}/history/{prompt_id}",
|
||||
timeout=aiohttp.ClientTimeout(total=30)) as r:
|
||||
hist = await r.json()
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError):
|
||||
await asyncio.sleep(POLL_SECS)
|
||||
continue
|
||||
entry = hist.get(prompt_id)
|
||||
if entry:
|
||||
status = entry.get("status", {})
|
||||
if status.get("status_str") == "success" or status.get("completed"):
|
||||
return entry
|
||||
if status.get("status_str") == "error":
|
||||
msgs = []
|
||||
for m in status.get("messages", []):
|
||||
if m and m[0] == "execution_error":
|
||||
msgs.append(json.dumps(m[1])[:500])
|
||||
raise RuntimeError("studio error: " + ("; ".join(msgs) or "unknown"))
|
||||
await asyncio.sleep(POLL_SECS)
|
||||
raise TimeoutError(f"job exceeded {JOB_TIMEOUT}s")
|
||||
|
||||
|
||||
def find_output_mp4(entry: dict) -> tuple[str, Path]:
|
||||
"""Return (filename, absolute path) of the SaveVideo mp4 from a history entry."""
|
||||
for node_out in entry.get("outputs", {}).values():
|
||||
for key in ("videos", "gifs", "images"):
|
||||
for item in node_out.get(key, []) or []:
|
||||
fn = item.get("filename", "")
|
||||
if fn.endswith(".mp4"):
|
||||
sub = item.get("subfolder", "")
|
||||
p = OUTPUT_DIR / sub / fn
|
||||
return fn, p
|
||||
raise RuntimeError("no mp4 in studio output")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Delivery
|
||||
# ---------------------------------------------------------------------------
|
||||
def upload_s3(path: Path, key: str) -> str | None:
|
||||
if not (S3_ENDPOINT and S3_KEY and S3_SECRET):
|
||||
return None
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
s3 = boto3.client(
|
||||
"s3", endpoint_url=S3_ENDPOINT, aws_access_key_id=S3_KEY,
|
||||
aws_secret_access_key=S3_SECRET, region_name=S3_REGION,
|
||||
config=Config(s3={"addressing_style": "path"}),
|
||||
)
|
||||
s3.upload_file(str(path), S3_BUCKET, key,
|
||||
ExtraArgs={"ContentType": "video/mp4", "ACL": "public-read"})
|
||||
base = S3_EXTERNAL or S3_ENDPOINT
|
||||
return f"{base}/{S3_BUCKET}/{key}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Worker
|
||||
# ---------------------------------------------------------------------------
|
||||
async def worker(app: web.Application) -> None:
|
||||
q: asyncio.Queue = app["queue"]
|
||||
async with aiohttp.ClientSession() as session:
|
||||
while True:
|
||||
job_id = await q.get()
|
||||
try:
|
||||
await run_job(session, job_id)
|
||||
except Exception as e: # never let the worker die
|
||||
job_update(job_id, status="failed", error=str(e)[:1000],
|
||||
finished=time.time())
|
||||
finally:
|
||||
q.task_done()
|
||||
|
||||
|
||||
async def run_job(session: aiohttp.ClientSession, job_id: str) -> None:
|
||||
r = job_row(job_id)
|
||||
if not r or r["status"] in ("completed", "failed"):
|
||||
return
|
||||
params = json.loads(r["params"])
|
||||
job_update(job_id, status="running", started=time.time())
|
||||
graph, _seed = build_prompt(**params["wan"])
|
||||
prompt_id = await comfy_submit(session, graph)
|
||||
job_update(job_id, prompt_id=prompt_id)
|
||||
entry = await comfy_wait(session, prompt_id)
|
||||
fn, path = find_output_mp4(entry)
|
||||
# deliver
|
||||
key = f"{time.strftime('%Y/%m/%d')}/{job_id}.mp4"
|
||||
url = None
|
||||
try:
|
||||
url = await asyncio.get_event_loop().run_in_executor(None, upload_s3, path, key)
|
||||
except Exception:
|
||||
url = None # fall back to tunnel-serve
|
||||
if not url:
|
||||
url = f"{PUBLIC_BASE}/v1/videos/files/{fn}" if PUBLIC_BASE else f"/v1/videos/files/{fn}"
|
||||
# billing
|
||||
seconds = params["seconds"]
|
||||
usd = params["usd"]
|
||||
_record_usage(job_id, r["org_id"], r["user_id"], r["model"], seconds, usd)
|
||||
job_update(job_id, status="completed", url=url, filename=fn, usd=usd,
|
||||
finished=time.time())
|
||||
|
||||
|
||||
def _record_usage(job_id, org, user, model, seconds, usd) -> None:
|
||||
with db() as c:
|
||||
c.execute(
|
||||
"INSERT INTO usage(job_id,org_id,user_id,model,seconds,usd,ts,metered) "
|
||||
"VALUES(?,?,?,?,?,?,?,0)",
|
||||
(job_id, org, user, model, seconds, usd, time.time()),
|
||||
)
|
||||
if METER_URL and org:
|
||||
# best-effort synchronous meter post (small); failures leave metered=0 for retry
|
||||
try:
|
||||
import urllib.request
|
||||
body = json.dumps({
|
||||
"org_id": org, "user_id": user, "model": model,
|
||||
"quantity": seconds, "unit": "second", "usd": usd,
|
||||
"resource": "video.generation", "job_id": job_id,
|
||||
}).encode()
|
||||
req = urllib.request.Request(METER_URL, data=body,
|
||||
headers={"Content-Type": "application/json"})
|
||||
urllib.request.urlopen(req, timeout=10).read()
|
||||
with db() as c:
|
||||
c.execute("UPDATE usage SET metered=1 WHERE job_id=?", (job_id,))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
def _identity(request: web.Request) -> tuple[str, str]:
|
||||
return (request.headers.get("X-Org-Id", ""), request.headers.get("X-User-Id", ""))
|
||||
|
||||
|
||||
def _bearer_ok(request: web.Request) -> bool:
|
||||
if not PROVIDER_KEY:
|
||||
return True # dev: open async API
|
||||
auth = request.headers.get("Authorization", "")
|
||||
return auth.startswith("Bearer ") and auth[7:].strip() == PROVIDER_KEY
|
||||
|
||||
|
||||
class BadRequest(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def _enqueue(app, *, model, prompt, size, seconds, seed, negative,
|
||||
org, user, idem_id) -> dict:
|
||||
"""Validate + persist + enqueue a job. Returns the job row. Raises BadRequest."""
|
||||
spec = MODELS.get(model)
|
||||
if not spec:
|
||||
raise BadRequest(f"unknown model '{model}'")
|
||||
prompt = (prompt or "").strip()
|
||||
if not prompt:
|
||||
raise BadRequest("prompt required")
|
||||
if size:
|
||||
try:
|
||||
w, h = (int(x) for x in str(size).lower().split("x"))
|
||||
except Exception:
|
||||
raise BadRequest("size must be WxH")
|
||||
else:
|
||||
w, h = spec["default_size"]
|
||||
fps = spec["fps"]
|
||||
seconds = float(seconds) if seconds else spec["max_seconds"]
|
||||
seconds = max(1.0, min(seconds, spec["max_seconds"]))
|
||||
length = int(round(seconds * fps))
|
||||
length = ((length - 1) // 4) * 4 + 1
|
||||
seconds = round(length / fps, 3)
|
||||
usd = round(seconds * spec["usd_per_second"], 4)
|
||||
|
||||
job_id = idem_id or str(uuid.uuid4())
|
||||
existing = job_row(job_id)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
wan = {"positive": prompt, "width": w, "height": h, "length": length,
|
||||
"steps": spec["steps"], "cfg": spec["cfg"], "shift": spec["shift"],
|
||||
"fps": fps, "filename_prefix": f"video/zen-{job_id[:8]}"}
|
||||
if negative:
|
||||
wan["negative"] = negative
|
||||
if seed is not None:
|
||||
wan["seed"] = int(seed)
|
||||
params = {"wan": wan, "seconds": seconds, "usd": usd}
|
||||
now = time.time()
|
||||
with db() as c:
|
||||
c.execute(
|
||||
"INSERT INTO jobs(id,status,model,prompt,params,org_id,user_id,"
|
||||
"seconds,width,height,usd,created) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(job_id, "queued", model, prompt, json.dumps(params), org, user,
|
||||
seconds, w, h, usd, now))
|
||||
await app["queue"].put(job_id)
|
||||
return job_row(job_id)
|
||||
|
||||
|
||||
async def create_generation(request: web.Request) -> web.Response:
|
||||
org, user = _identity(request)
|
||||
if not org and not ALLOW_ANON:
|
||||
return web.json_response(
|
||||
{"error": {"message": "org identity required (gateway must inject X-Org-Id)"}},
|
||||
status=401)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": {"message": "invalid JSON"}}, status=400)
|
||||
|
||||
idem = body.get("id") or request.headers.get("Idempotency-Key")
|
||||
try:
|
||||
r = await _enqueue(
|
||||
request.app, model=body.get("model", "zen3-video"),
|
||||
prompt=body.get("prompt"), size=body.get("size"),
|
||||
seconds=body.get("seconds"), seed=body.get("seed"),
|
||||
negative=body.get("negative_prompt"), org=org, user=user, idem_id=idem)
|
||||
except BadRequest as e:
|
||||
return web.json_response(
|
||||
{"error": {"message": str(e), "available": list(MODELS)}}, status=400)
|
||||
resp = public_job(r)
|
||||
resp["queue_position"] = request.app["queue"].qsize()
|
||||
return web.json_response(resp, status=202)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# do-ai-compatible async video API (OpenAI Sora-style) — driven by the cloud
|
||||
# LLM gateway's GenerateVideoDOAI client. Auth = Bearer provider key. Identity
|
||||
# and billing are owned upstream by the LLM gateway; this side just generates.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _doai_record(r: dict) -> dict:
|
||||
out = {"id": f"video_{r['id']}", "object": "video", "model": r["model"],
|
||||
"status": _DOAI_STATUS.get(r["status"], "queued")}
|
||||
if r["status"] == "failed":
|
||||
out["error"] = {"message": r.get("error") or "generation failed",
|
||||
"type": "generation_error"}
|
||||
else:
|
||||
out["error"] = None
|
||||
return out
|
||||
|
||||
|
||||
def _strip_vid(vid: str) -> str:
|
||||
return vid[6:] if vid.startswith("video_") else vid
|
||||
|
||||
|
||||
async def doai_create(request: web.Request) -> web.Response:
|
||||
if not _bearer_ok(request):
|
||||
return web.json_response({"error": {"message": "unauthorized"}}, status=401)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": {"message": "invalid JSON"}}, status=400)
|
||||
try:
|
||||
r = await _enqueue(
|
||||
request.app, model=body.get("model", "wan2-2-t2v-a14b"),
|
||||
prompt=body.get("prompt"), size=body.get("size"),
|
||||
seconds=body.get("seconds"), seed=body.get("seed"),
|
||||
negative=body.get("negative_prompt"),
|
||||
org=request.headers.get("X-Org-Id", ""),
|
||||
user=request.headers.get("X-User-Id", ""), idem_id=None)
|
||||
except BadRequest as e:
|
||||
return web.json_response({"error": {"message": str(e)}}, status=400)
|
||||
return web.json_response(_doai_record(r), status=202)
|
||||
|
||||
|
||||
async def doai_status(request: web.Request) -> web.Response:
|
||||
if not _bearer_ok(request):
|
||||
return web.json_response({"error": {"message": "unauthorized"}}, status=401)
|
||||
r = job_row(_strip_vid(request.match_info["id"]))
|
||||
if not r:
|
||||
return web.json_response({"error": {"message": "not found"}}, status=404)
|
||||
return web.json_response(_doai_record(r))
|
||||
|
||||
|
||||
async def doai_content(request: web.Request) -> web.StreamResponse:
|
||||
if not _bearer_ok(request):
|
||||
raise web.HTTPUnauthorized()
|
||||
r = job_row(_strip_vid(request.match_info["id"]))
|
||||
if not r:
|
||||
raise web.HTTPNotFound()
|
||||
if r["status"] != "completed" or not r.get("filename"):
|
||||
# do-ai returns a tiny JSON placeholder while in progress; the client
|
||||
# only downloads after polling to completion.
|
||||
return web.json_response({"status": _DOAI_STATUS.get(r["status"], "queued")},
|
||||
status=409)
|
||||
for p in OUTPUT_DIR.rglob(r["filename"]):
|
||||
if p.is_file():
|
||||
return web.FileResponse(p, headers={"Content-Type": "video/mp4"})
|
||||
raise web.HTTPNotFound()
|
||||
|
||||
|
||||
async def get_generation(request: web.Request) -> web.Response:
|
||||
r = job_row(request.match_info["id"])
|
||||
if not r:
|
||||
return web.json_response({"error": {"message": "not found"}}, status=404)
|
||||
return web.json_response(public_job(r))
|
||||
|
||||
|
||||
async def serve_file(request: web.Request) -> web.StreamResponse:
|
||||
name = request.match_info["name"]
|
||||
if "/" in name or ".." in name:
|
||||
raise web.HTTPBadRequest()
|
||||
# search output tree for the file
|
||||
for p in OUTPUT_DIR.rglob(name):
|
||||
if p.is_file():
|
||||
return web.FileResponse(p, headers={"Content-Type": "video/mp4"})
|
||||
raise web.HTTPNotFound()
|
||||
|
||||
|
||||
async def healthz(request: web.Request) -> web.Response:
|
||||
comfy = "down"
|
||||
try:
|
||||
async with aiohttp.ClientSession() as s:
|
||||
async with s.get(f"{COMFY_URL}/system_stats",
|
||||
timeout=aiohttp.ClientTimeout(total=5)) as r:
|
||||
if r.status == 200:
|
||||
comfy = "up"
|
||||
except Exception:
|
||||
pass
|
||||
return web.json_response({
|
||||
"status": "ok", "comfy": comfy,
|
||||
"queue_depth": request.app["queue"].qsize(),
|
||||
"models": list(MODELS),
|
||||
})
|
||||
|
||||
|
||||
async def list_models(request: web.Request) -> web.Response:
|
||||
data = [{"id": m, "object": "model", "owned_by": "hanzo",
|
||||
"max_seconds": s["max_seconds"], "default_size": f"{s['default_size'][0]}x{s['default_size'][1]}"}
|
||||
for m, s in MODELS.items()]
|
||||
return web.json_response({"object": "list", "data": data})
|
||||
|
||||
|
||||
def make_app() -> web.Application:
|
||||
init_db()
|
||||
app = web.Application(client_max_size=1 * 1024 * 1024)
|
||||
app["queue"] = asyncio.Queue()
|
||||
app.router.add_post("/v1/videos/generations", create_generation)
|
||||
app.router.add_get("/v1/videos/generations/{id}", get_generation)
|
||||
app.router.add_get("/v1/videos/files/{name}", serve_file)
|
||||
app.router.add_get("/v1/videos/models", list_models)
|
||||
# do-ai-compatible async video API (driven by the cloud LLM gateway)
|
||||
app.router.add_post("/v1/videos", doai_create)
|
||||
app.router.add_get("/v1/videos/{id}", doai_status)
|
||||
app.router.add_get("/v1/videos/{id}/content", doai_content)
|
||||
app.router.add_get("/healthz", healthz)
|
||||
|
||||
async def _on_start(app):
|
||||
# requeue jobs interrupted by a restart
|
||||
with db() as c:
|
||||
for row in c.execute("SELECT id FROM jobs WHERE status IN ('queued','running')"):
|
||||
app["queue"].put_nowait(row["id"])
|
||||
app["worker"] = asyncio.create_task(worker(app))
|
||||
|
||||
app.on_startup.append(_on_start)
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
web.run_app(make_app(), host="0.0.0.0", port=PORT)
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Zen video: Wan2.2 TI2V-5B text-to-video ComfyUI API-format workflow builder.
|
||||
|
||||
Public model id: zen3-video. Private backend: wan2.2_ti2v_5B_fp16.
|
||||
Never expose the upstream name in any public surface.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import random
|
||||
|
||||
# Wan2.2 default negative prompt (matches the official ComfyUI TI2V template).
|
||||
DEFAULT_NEGATIVE = (
|
||||
"色调艳丽,过曝,静态,细节模糊不清,"
|
||||
"字幕,风格,作品,画作,画面,静止,"
|
||||
"整体发灰,最差质量,低质量,JPEG压缩残留,"
|
||||
"丑陋的,残缺的,多余的手指,画得不好的手部,"
|
||||
"画得不好的脸部,畸形的,毁容的,形态畸形的肢体,"
|
||||
"手指融合,静止不动的画面,杂乱的背景,三条腿,"
|
||||
"背景人很多,倒着走"
|
||||
)
|
||||
|
||||
# Model filenames as present on spark (models/ dir).
|
||||
UNET = "wan2.2_ti2v_5B_fp16.safetensors"
|
||||
CLIP = "umt5_xxl_fp8_e4m3fn_scaled.safetensors"
|
||||
VAE = "wan2.2_vae.safetensors"
|
||||
|
||||
|
||||
def build_prompt(
|
||||
*,
|
||||
positive: str,
|
||||
negative: str | None = None,
|
||||
width: int = 1280,
|
||||
height: int = 704,
|
||||
length: int = 121,
|
||||
steps: int = 20,
|
||||
cfg: float = 5.0,
|
||||
shift: float = 8.0,
|
||||
sampler: str = "uni_pc",
|
||||
scheduler: str = "simple",
|
||||
fps: int = 24,
|
||||
seed: int | None = None,
|
||||
filename_prefix: str = "video/zen",
|
||||
) -> dict:
|
||||
"""Return a ComfyUI /prompt API-format graph for Wan2.2 TI2V-5B text-to-video.
|
||||
|
||||
length must be 4n+1 (temporal VAE stride 4). width/height multiples of 16.
|
||||
"""
|
||||
if negative is None:
|
||||
negative = DEFAULT_NEGATIVE
|
||||
if seed is None:
|
||||
seed = random.randint(0, 2**53 - 1)
|
||||
# snap length to 4n+1
|
||||
if (length - 1) % 4 != 0:
|
||||
length = ((length - 1) // 4) * 4 + 1
|
||||
width -= width % 16
|
||||
height -= height % 16
|
||||
|
||||
return {
|
||||
"37": {"class_type": "UNETLoader",
|
||||
"inputs": {"unet_name": UNET, "weight_dtype": "default"}},
|
||||
"38": {"class_type": "CLIPLoader",
|
||||
"inputs": {"clip_name": CLIP, "type": "wan", "device": "default"}},
|
||||
"39": {"class_type": "VAELoader",
|
||||
"inputs": {"vae_name": VAE}},
|
||||
"6": {"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": positive, "clip": ["38", 0]}},
|
||||
"7": {"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": negative, "clip": ["38", 0]}},
|
||||
"48": {"class_type": "ModelSamplingSD3",
|
||||
"inputs": {"model": ["37", 0], "shift": shift}},
|
||||
"55": {"class_type": "Wan22ImageToVideoLatent",
|
||||
"inputs": {"vae": ["39", 0], "width": width, "height": height,
|
||||
"length": length, "batch_size": 1}},
|
||||
"3": {"class_type": "KSampler",
|
||||
"inputs": {"model": ["48", 0], "seed": seed, "steps": steps,
|
||||
"cfg": cfg, "sampler_name": sampler, "scheduler": scheduler,
|
||||
"positive": ["6", 0], "negative": ["7", 0],
|
||||
"latent_image": ["55", 0], "denoise": 1.0}},
|
||||
"8": {"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["3", 0], "vae": ["39", 0]}},
|
||||
"57": {"class_type": "CreateVideo",
|
||||
"inputs": {"images": ["8", 0], "fps": fps}},
|
||||
"58": {"class_type": "SaveVideo",
|
||||
"inputs": {"video": ["57", 0], "filename_prefix": filename_prefix,
|
||||
"format": "mp4", "codec": "h264"}},
|
||||
}, seed
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
import sys
|
||||
p, s = build_prompt(positive=sys.argv[1] if len(sys.argv) > 1 else "a cat",
|
||||
width=640, height=640, length=41, steps=20)
|
||||
sys.stdout.write(json.dumps({"prompt": p}, ensure_ascii=False) + "\n")
|
||||
Reference in New Issue
Block a user