Compare commits
87
Commits
fix/upload-cap
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b89cf707a2 | ||
|
|
d8399c312f | ||
|
|
20d2f809af | ||
|
|
c00384cf1a | ||
|
|
cadc589875 | ||
|
|
a8dc089716 | ||
|
|
e631d35fd0 | ||
|
|
267735d0bd | ||
|
|
657e2b5fc0 | ||
|
|
99720f2bf4 | ||
|
|
49b8d1ad0f | ||
|
|
c20b6b6fa5 | ||
|
|
09dc7af5ab | ||
|
|
f9ea2d2eda | ||
|
|
d75983c437 | ||
|
|
131081184a | ||
|
|
caeef70dba | ||
|
|
6fb9cfc956 | ||
|
|
79bbe7cfc6 | ||
|
|
3893495338 | ||
|
|
7275e1c5eb | ||
|
|
3eb2d90971 | ||
|
|
9df0c317b7 | ||
|
|
160b1913c2 | ||
|
|
8383230a98 | ||
|
|
481add2183 | ||
|
|
cd92ea2a6c | ||
|
|
ce491d2848 | ||
|
|
537149fd36 | ||
|
|
2f73744113 | ||
|
|
b6f6186a39 | ||
|
|
3e50f9414e | ||
|
|
c3ecffd4cd | ||
|
|
7d564eaa40 | ||
|
|
436a965fa0 | ||
|
|
d278c03f80 | ||
|
|
141c4b3fc9 | ||
|
|
f5e7483570 | ||
|
|
d45067db4d | ||
|
|
4b128ccfc8 | ||
|
|
20a433678c | ||
|
|
c474161fba | ||
|
|
e16131b0b6 | ||
|
|
2a5bb8a71d | ||
|
|
94e0c4e685 | ||
|
|
100ca8d8a2 | ||
|
|
c088a3833c | ||
|
|
1671b21334 | ||
|
|
3565974382 | ||
|
|
193aee3aac | ||
|
|
9370bf82b2 | ||
|
|
9d18ad1c75 | ||
|
|
6fcd119bc8 | ||
|
|
5ccee8f052 | ||
|
|
7d419c2101 | ||
|
|
5087403496 | ||
|
|
d6b888dbd0 | ||
|
|
225658f60f | ||
|
|
d561e35584 | ||
|
|
d3c5dca63f | ||
|
|
9062c238e8 | ||
|
|
b81974b52f | ||
|
|
1c7d66ce34 | ||
|
|
76af4f30e8 | ||
|
|
701d166b3f | ||
|
|
49c4f28e76 | ||
|
|
21d25458b9 | ||
|
|
0fd473923b | ||
|
|
4ac849c28e | ||
|
|
2ee53f67e4 | ||
|
|
5016d2c5d8 | ||
|
|
baa40cf16c | ||
|
|
7ba4956e8a | ||
|
|
8423eada67 | ||
|
|
4ee5adcef9 | ||
|
|
9e4d098af2 | ||
|
|
c86ded1bad | ||
|
|
4c77c82b90 | ||
|
|
a6c1130b54 | ||
|
|
a363751352 | ||
|
|
99919db60d | ||
|
|
fac9880aaf | ||
|
|
bb3711eca1 | ||
|
|
86e301345f | ||
|
|
3f48ca15e0 | ||
|
|
fdc2da4c57 | ||
|
|
55f6eee60a |
@@ -1,22 +1,14 @@
|
||||
# Canonical CI/CD — imports the shared hanzoai/ci reusable workflow, which
|
||||
# reads hanzo.yml (images + test + deploy + kms). No per-repo build logic,
|
||||
# no GitHub-hosted runners (defaults to the Hanzo cloud arc pool).
|
||||
# Deploy is native now — see .hanzo/workflows/deploy.yml (Hanzo Git → act_runner
|
||||
# → BuildKit → ghcr.io/hanzoai/studio:<sha> → operator reconcile → hanzocd).
|
||||
# GitHub is a mirror; this workflow no longer builds or deploys the app image.
|
||||
name: CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
cicd:
|
||||
uses: hanzoai/ci/.github/workflows/build.yml@v1
|
||||
# Target the Hanzo arc scale set by its installation name. ARC scale sets
|
||||
# are matched by name, not by the [self-hosted,linux,amd64] label triple
|
||||
# that ci@v1 defaults to — without this override the job never gets a
|
||||
# runner and sits queued.
|
||||
with:
|
||||
runner: '["hanzo-build-linux-amd64"]'
|
||||
secrets: inherit
|
||||
sync-notice:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Notice
|
||||
run: echo "native pipeline is .hanzo/workflows/deploy.yml; GitHub is a mirror"
|
||||
|
||||
@@ -20,6 +20,7 @@ venv*/
|
||||
/web/extensions/*
|
||||
!/web/extensions/logging.js.example
|
||||
!/web/extensions/core/
|
||||
/web/shell/node_modules/
|
||||
/tests-ui/data/object_info.json
|
||||
/user/
|
||||
*.log
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
name: deploy
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: hanzo-linux-amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build + push image
|
||||
run: |
|
||||
SHA="${GITHUB_SHA::8}"
|
||||
buildctl-daemonless.sh build --frontend=dockerfile.v0 \
|
||||
--opt context="${{ github.server_url }}/${{ github.repository }}.git#${GITHUB_SHA}" \
|
||||
--opt filename=Dockerfile --opt platform=linux/amd64 \
|
||||
--secret id=GIT_AUTH_TOKEN,env=GIT_AUTH_TOKEN \
|
||||
--output "type=image,name=ghcr.io/hanzoai/studio:${SHA},push=true" --progress=plain
|
||||
env:
|
||||
GIT_AUTH_TOKEN: ${{ secrets.GIT_CLONE_TOKEN }}
|
||||
- name: Deploy — declare tag to operator
|
||||
run: |
|
||||
for app in studio; do
|
||||
kubectl -n hanzo patch app "$app" --type=merge -p "{\"spec\":{\"image\":{\"repository\":\"ghcr.io/hanzoai/studio\",\"tag\":\"${GITHUB_SHA::8}\"}}}"
|
||||
done
|
||||
+7
-12
@@ -1,12 +1,3 @@
|
||||
# ── studio-chat SPA (web/) ── the /v1/agent chat UI served at /studio. Built here
|
||||
# so the image is self-contained; the small Vite bundle lands at /app/web/dist.
|
||||
FROM node:20-slim AS web
|
||||
WORKDIR /web
|
||||
COPY web/package.json web/package-lock.json* ./
|
||||
RUN npm ci 2>/dev/null || npm install
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM python:3.11-slim AS base
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
@@ -14,6 +5,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
# WeasyPrint native stack (office-document PDF renderer, middleware/doc_render.py):
|
||||
# pango/cairo/gdk-pixbuf + a base font. Absent → the built-in PDF fallback is used.
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
libgdk-pixbuf-2.0-0 \
|
||||
shared-mime-info \
|
||||
fonts-liberation \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
@@ -34,9 +32,6 @@ RUN chmod +x /tmp/branding/apply-branding.sh && /tmp/branding/apply-branding.sh
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# The studio-chat SPA built above → served at /studio by middleware/studio_home.py.
|
||||
COPY --from=web /web/dist ./web/dist
|
||||
|
||||
# Vendor the Hanzo Studio custom-node packs (segmentation, controlnet, ipadapter,
|
||||
# upscaling, video, utils) at their pinned commits and install their deps with the
|
||||
# torch/numpy/transformers stack locked. See custom_nodes/hanzo-packs.txt.
|
||||
|
||||
@@ -97,6 +97,24 @@ shared Hanzo IAM (standard OIDC code flow). No embedding.
|
||||
visor_client}.py`): `/v1/workers/register` (heartbeat), `/v1/workers` (list),
|
||||
`/v1/worker/execute` (in-cluster push fast path). Worker↔coordinator trust via
|
||||
`X-Worker-Token` (`STUDIO_WORKER_TOKEN`, KMS-sourced).
|
||||
- **Per-GPU gpu-jobs queue** (`middleware/{gpu_dispatch,studio_home}.py`, `server.py`) —
|
||||
the ONE submit path onto a GPU. A model-less coordinator's `POST /prompt` enqueues to
|
||||
the cloud tasks engine (`POST {STUDIO_CLOUD_API_URL}/v1/tasks/namespaces/gpu-jobs/activities`,
|
||||
user bearer → org-scoped); a BYO worker (`hanzo gpu connect`) claims + runs it.
|
||||
**Targeting**: the Home "Run on <gpu>" chip sets `X-Target-GPU: <identity>`;
|
||||
`dispatch_if_worker` enqueues on that machine's own lane `taskQueue="gpu:<identity>"`
|
||||
(namespace stays `gpu-jobs`), else the shared `gpu-jobs` lane any worker drains —
|
||||
`X-Target-GPU` rides through `_queue_prompt` so fix/compose/video/rerun all honor it.
|
||||
**Authoritative visible queue**: `/v1/render-queue` + `/v1/nodes` read the tasks
|
||||
engine's activity list — REAL claiming `identity`, status
|
||||
SCHEDULED/STARTED/COMPLETED/FAILED → queued/running/done/failed, `lastHeartbeatTime`
|
||||
liveness, per-node depth — not a local shadow; `render_jobs.json` survives only as a
|
||||
~2s pre-claim placeholder. **No hidden runs**: in `--worker-mode` a direct `/prompt`
|
||||
POST is refused 403 unless it carries `X-Worker-Token` — the only accepted execution is
|
||||
a claimed job handed in over `POST /v1/worker/execute`
|
||||
(`worker_client.reject_untrusted_worker_submit`, one policy gate). Legacy push
|
||||
(`prompt_router`) is OFF unless `STUDIO_LEGACY_PUSH_ROUTER=1`. Tests:
|
||||
`middleware_test/{gpu_dispatch_test,worker_seam_test}.py`.
|
||||
- **Durable render queue** (`middleware/tasks_queue.py`, `docs/federation.md`):
|
||||
`SqlitePromptQueue` — crash-durable render queue in a single SQLite file
|
||||
(stdlib `sqlite3`, WAL, **zero external processes**). `STUDIO_QUEUE_DB=<path>`
|
||||
@@ -111,6 +129,25 @@ shared Hanzo IAM (standard OIDC code flow). No embedding.
|
||||
compete safely. Tested: `middleware_test/tasks_queue_test.py` (incl.
|
||||
crash-recovery across instances). Future (federation.md §6): a remote queue
|
||||
backend served by Hanzo Tasks + a Go/unified-binary (HIP-0106) migration.
|
||||
- **Stacks** (`middleware/stacks_store.py`, `middleware/studio_home.py`): Procreate-
|
||||
style gallery folders — a per-org SQLite store (`orgs/<org>/stacks.db`, WAL, every
|
||||
mutation under `BEGIN IMMEDIATE`, the same `tasks_queue.py` pattern) that is ONLY an
|
||||
organizational layer; assets stay the source of truth (library.json + files). Schema
|
||||
= the spec's `stacks` + `stack_assets` join; `UNIQUE(asset_id)` ⇒ one Stack per
|
||||
asset. Tenancy is structural (one DB file per org → cross-org 404s by construction).
|
||||
REST (`/v1` house style): `GET/POST /v1/stacks`, `GET/PATCH/DELETE /v1/stacks/:id`
|
||||
(`?mode=only|images`), `POST|DELETE /v1/stacks/:id/assets`, `PATCH /v1/stacks/:id/order`,
|
||||
`POST /v1/stacks/:id/unstack`, `GET/PATCH /v1/stacks/settings`; registered via
|
||||
injected resolvers (org/owner/workspace + the library soft-deleter) so the module
|
||||
stays decoupled. Remove ≠ delete (returns to the gallery); Delete-Stack defaults to
|
||||
preserving; "…and Images" maps to the library soft-delete (`status=deleted`, files
|
||||
kept). Generation integration: `fix/compose/rerun/template` accept a `stack` id and
|
||||
the ingest point (`_index_upload` → `stacks_store.absorb`) assigns membership when
|
||||
outputs land (ALL outputs of a multi-output job); a per-org `auto_stack_batches`
|
||||
setting auto-creates `"<prompt> — <date>"` Stacks. UI is the vanilla-JS home
|
||||
(`studio_home.html`): layered cards, drag/multi-select/mobile-tap parity (no DnD ever
|
||||
required), a11y labels + live region. Tested: `studio_home_test/test_stacks.py`
|
||||
(routes, tenancy, one-per-asset, order persistence, delete/unstack, ingest→absorb).
|
||||
- **Engine selector** (`middleware/engine_selector.py`): `/v1/engines` lists
|
||||
execution targets for the org (`local` + registered compute_config workers) and
|
||||
`PUT /v1/engines/default` sets a per-org default (stored on
|
||||
@@ -182,10 +219,108 @@ shared Hanzo IAM (standard OIDC code flow). No embedding.
|
||||
behind disabled API-nodes) are intentionally left. patch_frontend.py keeps its
|
||||
orthogonal concern (URLs/domains + `Comfy-Org` → `Hanzo AI`).
|
||||
|
||||
## Office documents (the non-GPU pipeline)
|
||||
Studio's creative side is ComfyUI graphs → GPU. Office documents are the **orthogonal**
|
||||
subsystem: structured content (a template's typed fields, filled by a person OR by the AI)
|
||||
→ a pure-Python renderer → a real `.pdf/.docx/.xlsx/.pptx/.md`. No graph, no model, no GPU.
|
||||
NOT bolted onto graphs; its own module, store, endpoints, and Home tab.
|
||||
|
||||
- **Renderers** (`middleware/doc_render.py`, pure — no HTTP/storage/org): one neutral IR
|
||||
per archetype (`doc` prose blocks · `sheet` sheets · `deck` slides) and EXACTLY ONE
|
||||
renderer per format. Adding a template = one `build(fields)->(kind,ir)`; adding a format =
|
||||
one renderer. Deps imported lazily so the module loads stdlib-only. **PDF** prefers
|
||||
WeasyPrint (full-CSS, monochrome print stylesheet); when its native pango/cairo stack is
|
||||
absent (the light CI unit venv, model-less pods) a **built-in pure-Python PDF writer**
|
||||
(base-14 Helvetica, WinAnsi/cp1252, auto-pagination, `/Info` title) produces a valid
|
||||
`%PDF` — so every format renders with just the wheels. **DOCX/XLSX/PPTX** = python-docx /
|
||||
openpyxl / python-pptx (real, always). Native libs in the `Dockerfile` (libpango/…).
|
||||
- **Catalog + AI-fill + store + REST** (`middleware/documents.py`): ONE declarative `CATALOG`
|
||||
of 22 common templates (Documents 15 → pdf/docx/md · Spreadsheets 4 → xlsx/pdf/docx/md ·
|
||||
Presentations 3 → pptx/pdf/md). Each template's typed `fields` drive the form, the AI-fill
|
||||
JSON, AND the renderer inputs (one schema, three uses). Registered from within
|
||||
`add_studio_home_routes` with the SAME injected tenancy resolvers as `stacks_store`
|
||||
(`org_of`/`owner_of`). Per-org store `orgs/<org>/documents/` (atomic `index.json` +
|
||||
`<id>/doc.json`), doc.json is the source of truth, files rendered on demand at download.
|
||||
Endpoints (`/v1` house style): `GET /v1/documents` (grouped catalog),
|
||||
`POST /v1/documents/generate` `{templateId, description?|fields?}`,
|
||||
`GET /v1/documents/library`, `GET|PATCH|DELETE /v1/documents/{id}`,
|
||||
`GET /v1/documents/{id}/download?format=`.
|
||||
- **AI-fill is a LAYERED ENHANCEMENT** — the structured-form path renders a valid document
|
||||
with NO LLM, so a broken/unauthorized gateway can NEVER block creation (`422 needFields`
|
||||
→ the UI reveals the prefilled form). It reuses the SAME working token the shipped Chat
|
||||
uses: the browser sends `window.HZ.pk` (the publishable `pk-` key `_publishable`/
|
||||
`_hz_config_script` emit) as the bearer to `/v1/documents/generate`; the backend forwards
|
||||
it, fail-closed, in order: (1) a **server-side gateway key** (`STUDIO_DOC_TOKEN`→
|
||||
`STUDIO_COPILOT_TOKEN`→`STUDIO_COMMERCE_TOKEN`); (2) else the caller's own token, forwarded
|
||||
ONLY when gateway-verifiable — a publishable `pk-` key or an asymmetric/JWKS JWT
|
||||
(RS/ES/PS/EdDSA). A symmetric **HS256 hanzo.id session token is NEVER forwarded** (the
|
||||
gateway rejects it: "unsupported signing method: HS256"), nor any secret `sk-`/`hk-` key.
|
||||
Model = `STUDIO_DOC_MODEL`→`STUDIO_CHAT_MODEL`→`enso` (matches window.HZ.model).
|
||||
- **UI** (`middleware/studio_home.html`, vanilla JS, monochrome/Geist): a **📄 Documents**
|
||||
Home tab — searchable server-driven catalog + "Your documents" grid with Download
|
||||
PDF/Word/Excel/PowerPoint. The `docdlg` dialog: describe in one line → Generate with AI
|
||||
(sends window.HZ.pk), or fill the short typed form → Generate; edit fields before/after;
|
||||
re-open a saved doc to edit (PATCH). The dead `doc/sheet/paper` composer KIND placeholders
|
||||
were replaced with one "Office documents" card that opens the tab.
|
||||
|
||||
## Create composer + gateway auth (v0.18.1 — the intuitive first Create)
|
||||
The "What should we create?" composer must PRODUCE something on the first ↑, and Chat must
|
||||
reach the gateway. Both were fixed here.
|
||||
|
||||
- **Gateway auth is ONE fail-closed seam** (`middleware/gateway_auth.py`): the canonical
|
||||
allowlist for handing a browser-edge bearer to api.hanzo.ai. `ai_bearer(request,
|
||||
server_env=…)` = a server key first (never in page source), else the caller's own token
|
||||
ONLY when the gateway can verify it — a publishable `pk-` key or an asymmetric/JWKS JWT
|
||||
(RS/ES/PS/EdDSA). A symmetric **HS256 hanzo.id session token is NEVER forwarded** (the
|
||||
gateway rejects it → "unsupported signing method: HS256" → the old 502), nor any secret
|
||||
`sk-`/`hk-`/`rk-` key. `copilot.py._resolve_target` now uses it
|
||||
(`_SERVER_TOKEN_ENV=(STUDIO_COPILOT_TOKEN, STUDIO_COMMERCE_TOKEN)`), so the editor-ops
|
||||
chat no longer 502s on the session token. The shipped **Chat sidebar** (`shell.js`) still
|
||||
streams enso directly from the gateway with `window.HZ.pk` when present; its copilot
|
||||
fallback now renders ONE friendly line on failure — never the raw backend error/JSON.
|
||||
(documents.py's AI-fill has an equivalent private copy; it should adopt `gateway_auth`
|
||||
once its concurrent review lands — the shared module is the going-forward one way.)
|
||||
- **Text-to-image is the default create lane** (`studio_home.py`: `_image_graph` +
|
||||
`dispatch_image` + `POST /v1/library/image`): the proven **Z-Image-Turbo** graph
|
||||
(`z_image_turbo_bf16` / `qwen_3_4b` lumina2 / `ae` vae; EmptySD3LatentImage →
|
||||
ModelSamplingAuraFlow shift 3 → KSampler res_multistep/simple 4 steps cfg 1.0 →
|
||||
SaveImage), lifted from the shipped blueprint, dispatched through the ONE funnel like
|
||||
video/fix. Words in → an image out; a model-less pod surfaces the real DispatchError, not
|
||||
a silent nothing.
|
||||
- **Composer routes ↑ by the selected model's modality** (`studio_home.html`): default
|
||||
`MODEL='zen-image'` from `hz_create_model` — DECOUPLED from the chat model (`hz_model`),
|
||||
so an image pick never breaks the assistant (and `shell.js chatModel()` ignores a
|
||||
non-chat `hz_model`). `composerPlan()`: assistant model → hand to the ONE chat via
|
||||
`window.HanzoChat.send` (exposed by shell.js); no photo → text-to-image; 1 photo → fix;
|
||||
2+ → compose; video model/lane → video. `create()` ALWAYS pulses a "Preparing…"
|
||||
live-bar state and, on failure, shows a friendly retry (prompt kept) — never a silent
|
||||
no-op. The code model (`zen5-coder`) is hidden from the creative composer; each model has
|
||||
a one-line description; the `</>` request-preview is gated behind Developers mode
|
||||
(`body.hzdev`, set by the shell's Developers pill).
|
||||
- **P2 friction fixes** (`studio_home.html` + `shell.css`): a first-class **Download** on
|
||||
every tile AND in the viewer (PNG original · JPG/WebP client-side canvas convert, the
|
||||
popover flips up near the viewport bottom); the fixed header + sticky live-bar are now
|
||||
OPAQUE (were translucent → content scrolled under them); the **ARCHIVED** badge shows
|
||||
ONLY for truly deleted/flagged items (active work is unbadged); on mobile the ~30 filter
|
||||
pills collapse to one horizontally-scrollable row and the tab bar gets a scroll fade
|
||||
affordance. The Chat empty state (shell.js) shows an assistant identity + one line + 3
|
||||
tappable starters.
|
||||
|
||||
## Tests
|
||||
`tests-unit/{folder_paths_test,middleware_test,app_test,prompt_server_test}` —
|
||||
`tests-unit/{folder_paths_test,middleware_test,app_test,prompt_server_test,studio_home_test,documents_test}` —
|
||||
run `uv run pytest tests-unit/folder_paths_test tests-unit/middleware_test
|
||||
tests-unit/app_test tests-unit/prompt_server_test -q`. The auth/tenancy work is
|
||||
tests-unit/app_test tests-unit/prompt_server_test tests-unit/studio_home_test
|
||||
tests-unit/documents_test -q`. The auth/tenancy work is
|
||||
covered by `middleware_test/iam_auth_test.py`,
|
||||
`middleware_test/session_test.py` and
|
||||
`folder_paths_test/org_scoping_test.py`.
|
||||
`folder_paths_test/org_scoping_test.py`. Office documents:
|
||||
`documents_test/test_documents.py` (catalog invariants, the full template×format render
|
||||
matrix asserted by magic numbers, AI-fill parse + fail-closed bearer allowlist, and the
|
||||
REST surface incl. per-org isolation on a real aiohttp app). Create composer + gateway
|
||||
auth: `middleware_test/gateway_auth_test.py` (the fail-closed allowlist — HS256/`sk-` refused,
|
||||
`pk-`/asymmetric forwarded, server-key precedence, session-cookie path),
|
||||
`middleware_test/copilot_test.py::test_resolve_target_never_forwards_hs256_session_token`,
|
||||
and `studio_home_test/create_image_test.py` (the Z-Image-Turbo graph shape, `dispatch_image`
|
||||
validation, and served-HTML invariants: default-model-is-image, text→image route,
|
||||
Create-always-shows-a-state, first-class Download, no ARCHIVED badge on active items, opaque
|
||||
bars, gated `</>`, collapsed mobile filters).
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
Hanzo Studio
|
||||
============
|
||||
|
||||
Forked from https://github.com/comfyanonymous/ComfyUI (GPL-3.0).
|
||||
|
||||
Hanzo Studio is a fork of ComfyUI (https://github.com/comfyanonymous/ComfyUI),
|
||||
Copyright (c) ComfyUI contributors, licensed under the GNU General Public
|
||||
License v3.0 (GPL-3.0).
|
||||
|
||||
Modifications Copyright (c) 2026 Hanzo Industries Inc, also released under the
|
||||
Modifications Copyright (c) 2026 Hanzo AI, Inc., also released under the
|
||||
GNU General Public License v3.0 (GPL-3.0). See the LICENSE file for the full
|
||||
license text.
|
||||
|
||||
As required by the GPL-3.0, this fork remains under the same license as the
|
||||
upstream project. The original ComfyUI copyright notices are retained.
|
||||
|
||||
Modifications made in this fork
|
||||
-------------------------------
|
||||
Modifications made in this fork (DEVIATIONS)
|
||||
--------------------------------------------
|
||||
|
||||
- Branding: user-visible name changed from "ComfyUI" to "Hanzo Studio",
|
||||
including the served page <title>, favicon, in-app logos, and display
|
||||
|
||||
+24
-23
@@ -1,6 +1,7 @@
|
||||
/* Hanzo Studio Copilot — black + Hanzo-purple sidebar chat.
|
||||
* Reuses the theme's CSS vars (hanzo-theme.css) so it tracks the shell palette;
|
||||
* hard-coded fallbacks keep it on-brand if a var is ever absent.
|
||||
/* Hanzo Studio Copilot — monochrome sidebar chat.
|
||||
* Tracks the shell's neutral theme vars (hanzo-theme.css) with canonical Hanzo
|
||||
* design-system fallbacks: surface #0a0a0a · raised #1a1a1a · border #1f1f1f ·
|
||||
* text #ededed · dim #888 · brand/CTA #fff. No color accent — monochrome.
|
||||
*/
|
||||
.hanzo-copilot {
|
||||
display: flex;
|
||||
@@ -9,7 +10,7 @@
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--comfy-menu-bg, #0a0a0a);
|
||||
color: var(--fg-color, #fff);
|
||||
color: var(--fg-color, #ededed);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -26,8 +27,7 @@
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--p-primary-color, #8b5cf6);
|
||||
box-shadow: 0 0 8px var(--p-primary-color, #8b5cf6);
|
||||
background: #ededed;
|
||||
}
|
||||
|
||||
.hanzo-copilot .hz-messages {
|
||||
@@ -50,12 +50,13 @@
|
||||
white-space: normal;
|
||||
}
|
||||
.hanzo-copilot .hz-user .hz-bubble {
|
||||
background: var(--p-button-primary-background, #7c3aed);
|
||||
color: #fff;
|
||||
background: #ffffff;
|
||||
color: #111;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
.hanzo-copilot .hz-assistant .hz-bubble {
|
||||
background: #1a1a1f;
|
||||
background: #1a1a1a;
|
||||
color: var(--fg-color, #ededed);
|
||||
border: 1px solid var(--border-color, #1f1f1f);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
@@ -69,17 +70,18 @@
|
||||
}
|
||||
.hanzo-copilot .hz-chip {
|
||||
background: transparent;
|
||||
color: #c4b5fd;
|
||||
border: 1px solid #2a2140;
|
||||
color: #888;
|
||||
border: 1px solid var(--border-color, #1f1f1f);
|
||||
border-radius: 999px;
|
||||
padding: 5px 10px;
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.hanzo-copilot .hz-chip:hover {
|
||||
background: rgba(139, 92, 246, 0.16);
|
||||
border-color: var(--p-primary-color, #8b5cf6);
|
||||
background: #1a1a1a;
|
||||
border-color: #333;
|
||||
color: #ededed;
|
||||
}
|
||||
|
||||
.hanzo-copilot .hz-input {
|
||||
@@ -93,7 +95,7 @@
|
||||
flex: 1 1 auto;
|
||||
resize: none;
|
||||
background: var(--comfy-input-bg, #0a0a0a);
|
||||
color: var(--fg-color, #fff);
|
||||
color: var(--fg-color, #ededed);
|
||||
border: 1px solid var(--border-color, #1f1f1f);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
@@ -101,11 +103,11 @@
|
||||
outline: none;
|
||||
}
|
||||
.hanzo-copilot .hz-input textarea:focus {
|
||||
border-color: var(--p-primary-color, #8b5cf6);
|
||||
border-color: #888;
|
||||
}
|
||||
.hanzo-copilot .hz-send {
|
||||
background: var(--p-button-primary-background, #7c3aed);
|
||||
color: #fff;
|
||||
background: #ffffff;
|
||||
color: #111;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 9px 14px;
|
||||
@@ -113,7 +115,7 @@
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.hanzo-copilot .hz-send:hover { background: #6d28d9; }
|
||||
.hanzo-copilot .hz-send:hover { background: #e4e4e7; }
|
||||
.hanzo-copilot .hz-send:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* Fallback toast (used only when the app toast service is unavailable) */
|
||||
@@ -122,10 +124,9 @@
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 12px);
|
||||
background: #17171c;
|
||||
color: #fff;
|
||||
border: 1px solid #2a2140;
|
||||
border-left: 3px solid var(--p-primary-color, #8b5cf6);
|
||||
background: #1a1a1a;
|
||||
color: #ededed;
|
||||
border: 1px solid var(--border-color, #1f1f1f);
|
||||
border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
|
||||
+55
-54
@@ -1,11 +1,12 @@
|
||||
/* Hanzo Studio — black surface + Hanzo-purple accent theme.
|
||||
/* Hanzo Studio — black surface + monochrome accent theme.
|
||||
* Minimal override: only background/surface/accent tokens are touched so the
|
||||
* rest of the frontend's dark palette (text, node colors, borders) is intact.
|
||||
* Loaded LAST in index.html so it beats PrimeVue's runtime-injected tokens;
|
||||
* every declaration is !important because those tokens are injected after this
|
||||
* sheet at boot. Colors chosen for WCAG AA contrast on black:
|
||||
* accent #8B5CF6 on #000 -> 6.2:1 (AA normal text)
|
||||
* button #ffffff on #7C3AED -> 5.25:1 (AA normal text)
|
||||
* sheet at boot. Monochrome — no color accent — matching the canonical Hanzo
|
||||
* design system (page #000 · surface #0a0a0a · raised #1a1a1a · border #1f1f1f ·
|
||||
* text #ededed · accent/brand #ededed/#fff). Contrast on black:
|
||||
* accent #ededed on #000 -> ~18:1 · button #111 on #fff -> ~18:1
|
||||
*/
|
||||
|
||||
:root {
|
||||
@@ -36,23 +37,23 @@
|
||||
--p-overlay-popover-background: #0a0a0a !important;
|
||||
--p-mask-background: rgba(0, 0, 0, 0.72) !important;
|
||||
|
||||
/* --- Hanzo purple accent --- */
|
||||
--p-primary-color: #8b5cf6 !important; /* icons/links/active — AA on black */
|
||||
--p-primary-contrast-color: #ffffff !important;
|
||||
--p-primary-400: #a78bfa !important;
|
||||
--p-primary-500: #8b5cf6 !important;
|
||||
--p-primary-600: #7c3aed !important;
|
||||
--p-focus-ring-color: #8b5cf6 !important;
|
||||
--p-highlight-background: rgba(124, 58, 237, 0.24) !important;
|
||||
--p-highlight-focus-background: rgba(124, 58, 237, 0.32) !important;
|
||||
--p-highlight-color: #ede9fe !important;
|
||||
/* --- Monochrome accent (near-white on black — icons/links/active) --- */
|
||||
--p-primary-color: #ededed !important;
|
||||
--p-primary-contrast-color: #111111 !important;
|
||||
--p-primary-400: #f5f5f5 !important;
|
||||
--p-primary-500: #ededed !important;
|
||||
--p-primary-600: #d4d4d4 !important;
|
||||
--p-focus-ring-color: #8a8a8a !important;
|
||||
--p-highlight-background: rgba(255, 255, 255, 0.14) !important;
|
||||
--p-highlight-focus-background: rgba(255, 255, 255, 0.20) !important;
|
||||
--p-highlight-color: #ffffff !important;
|
||||
|
||||
/* --- Primary buttons: white on deeper violet keeps AA (5.25:1) --- */
|
||||
--p-button-primary-background: #7c3aed !important;
|
||||
--p-button-primary-hover-background: #6d28d9 !important;
|
||||
--p-button-primary-active-background: #5b21b6 !important;
|
||||
--p-button-primary-border-color: #7c3aed !important;
|
||||
--p-button-primary-color: #ffffff !important;
|
||||
/* --- Primary buttons: brand/CTA white on black, dark text (matches Home) --- */
|
||||
--p-button-primary-background: #ffffff !important;
|
||||
--p-button-primary-hover-background: #e4e4e7 !important;
|
||||
--p-button-primary-active-background: #d4d4d4 !important;
|
||||
--p-button-primary-border-color: #ffffff !important;
|
||||
--p-button-primary-color: #111111 !important;
|
||||
}
|
||||
|
||||
/* Pure-black canvas gutter behind the litegraph <canvas>. */
|
||||
@@ -65,31 +66,31 @@ body.litegraph,
|
||||
|
||||
/* ── hover/active states: dark surfaces, never light (fix white-on-white) ── */
|
||||
:root, :root.dark-theme {
|
||||
--p-content-hover-background: #1f1f23 !important;
|
||||
--p-content-hover-background: #1a1a1a !important;
|
||||
--p-content-hover-color: #ffffff !important;
|
||||
--p-navigation-item-hover-background: #1f1f23 !important;
|
||||
--p-list-option-focus-background: #26262b !important;
|
||||
--p-select-option-focus-background: #26262b !important;
|
||||
--p-button-text-secondary-hover-background: #1f1f23 !important;
|
||||
--p-button-text-primary-hover-background: rgba(139,92,246,.16) !important;
|
||||
--p-button-secondary-hover-background: #26262b !important;
|
||||
--p-togglebutton-hover-background: #26262b !important;
|
||||
--p-tab-hover-background: #1f1f23 !important;
|
||||
--p-menubar-item-focus-background: #26262b !important;
|
||||
--p-menu-item-focus-background: #26262b !important;
|
||||
--p-tree-node-hover-background: #1f1f23 !important;
|
||||
--p-surface-100: #26262b !important;
|
||||
--p-surface-200: #1f1f23 !important;
|
||||
--p-highlight-background: rgba(139,92,246,.20) !important;
|
||||
--p-navigation-item-hover-background: #1a1a1a !important;
|
||||
--p-list-option-focus-background: #242424 !important;
|
||||
--p-select-option-focus-background: #242424 !important;
|
||||
--p-button-text-secondary-hover-background: #1a1a1a !important;
|
||||
--p-button-text-primary-hover-background: rgba(255,255,255,.10) !important;
|
||||
--p-button-secondary-hover-background: #242424 !important;
|
||||
--p-togglebutton-hover-background: #242424 !important;
|
||||
--p-tab-hover-background: #1a1a1a !important;
|
||||
--p-menubar-item-focus-background: #242424 !important;
|
||||
--p-menu-item-focus-background: #242424 !important;
|
||||
--p-tree-node-hover-background: #1a1a1a !important;
|
||||
--p-surface-100: #242424 !important;
|
||||
--p-surface-200: #1a1a1a !important;
|
||||
--p-highlight-background: rgba(255,255,255,.14) !important;
|
||||
--p-highlight-color: #ffffff !important;
|
||||
}
|
||||
.side-tool-bar-container .p-button:hover,
|
||||
.side-bar-button:hover,
|
||||
.comfyui-button:hover {
|
||||
background: #1f1f23 !important;
|
||||
background: #1a1a1a !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.p-button-text:not(.p-button-danger):hover { background: #1f1f23 !important; color: #fff !important; }
|
||||
.p-button-text:not(.p-button-danger):hover { background: #1a1a1a !important; color: #fff !important; }
|
||||
|
||||
/* ── the H logo menu button: keep the mark visible on hover/open ── */
|
||||
button:has(> .comfyui-logo):hover,
|
||||
@@ -97,7 +98,7 @@ button:has(.comfyui-logo):hover,
|
||||
.p-button:has(.comfyui-logo):hover,
|
||||
button:has(.comfyui-logo)[aria-expanded="true"],
|
||||
.comfyui-logo-menu-trigger:hover {
|
||||
background: #1f1f23 !important;
|
||||
background: #1a1a1a !important;
|
||||
}
|
||||
button:has(.comfyui-logo):hover .comfyui-logo,
|
||||
button:has(.comfyui-logo):hover svg,
|
||||
@@ -106,37 +107,37 @@ button:has(.comfyui-logo):hover path {
|
||||
fill: #ffffff !important;
|
||||
}
|
||||
|
||||
/* ── active/selected/checked states: never white (white-box-under-H fix) ── */
|
||||
/* ── active/selected/checked states: neutral gray, never white ── */
|
||||
:root, :root.dark-theme {
|
||||
--p-button-secondary-active-background: #26262b !important;
|
||||
--p-togglebutton-checked-background: #2a2140 !important;
|
||||
--p-togglebutton-checked-color: #c4b5fd !important;
|
||||
--p-navigation-item-active-background: #26262b !important;
|
||||
--p-button-secondary-active-background: #242424 !important;
|
||||
--p-togglebutton-checked-background: #242424 !important;
|
||||
--p-togglebutton-checked-color: #ededed !important;
|
||||
--p-navigation-item-active-background: #242424 !important;
|
||||
--p-tab-active-background: #0a0a0a !important;
|
||||
--p-button-text-primary-active-background: rgba(139,92,246,.24) !important;
|
||||
--p-button-text-primary-active-background: rgba(255,255,255,.16) !important;
|
||||
}
|
||||
.side-tool-bar-container .p-button.p-highlight,
|
||||
.side-tool-bar-container .p-togglebutton.p-togglebutton-checked,
|
||||
.side-tool-bar-container [aria-pressed="true"],
|
||||
.side-tool-bar-container .p-button:active,
|
||||
.p-selectbutton .p-button.p-highlight {
|
||||
background: #2a2140 !important;
|
||||
color: #c4b5fd !important;
|
||||
background: #242424 !important;
|
||||
color: #ededed !important;
|
||||
}
|
||||
.side-tool-bar-container .p-button.p-highlight svg,
|
||||
.side-tool-bar-container [aria-pressed="true"] svg { color: #c4b5fd !important; fill: currentColor !important; }
|
||||
.side-tool-bar-container [aria-pressed="true"] svg { color: #ededed !important; fill: currentColor !important; }
|
||||
|
||||
/* ── selected sidebar icon: purple-tinted dark, never white ── */
|
||||
/* ── selected sidebar icon: neutral dark, never white ── */
|
||||
:root, :root.dark-theme {
|
||||
--interface-panel-selected-surface: #2a2140 !important;
|
||||
--interface-menu-component-surface-selected: #2a2140 !important;
|
||||
--interface-panel-selected-surface: #242424 !important;
|
||||
--interface-menu-component-surface-selected: #242424 !important;
|
||||
}
|
||||
.side-bar-button-selected {
|
||||
background-color: #2a2140 !important;
|
||||
color: #c4b5fd !important;
|
||||
background-color: #242424 !important;
|
||||
color: #ededed !important;
|
||||
}
|
||||
.side-bar-button-selected svg, .side-bar-button-selected i { color: #c4b5fd !important; }
|
||||
.side-bar-button-selected svg, .side-bar-button-selected i { color: #ededed !important; }
|
||||
.selected:not(.p-galleria-thumbnail-item) {
|
||||
background-color: #2a2140 !important;
|
||||
background-color: #242424 !important;
|
||||
color: #e5e5e5 !important;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,9 @@ test:
|
||||
tests-unit/folder_paths_test \
|
||||
tests-unit/middleware_test \
|
||||
tests-unit/app_test \
|
||||
tests-unit/prompt_server_test
|
||||
tests-unit/prompt_server_test \
|
||||
tests-unit/studio_home_test \
|
||||
tests-unit/documents_test
|
||||
|
||||
# Deploy: roll the freshly-built image onto the studio Service CR.
|
||||
deploy:
|
||||
|
||||
@@ -451,17 +451,32 @@ def start_studio(asyncio_loop=None):
|
||||
cuda_malloc_warning()
|
||||
setup_database()
|
||||
|
||||
prompt_server.add_routes()
|
||||
hijack_progress(prompt_server)
|
||||
|
||||
# In worker mode, add worker execution routes and register with coordinator
|
||||
# In worker mode, register the worker execution routes BEFORE add_routes() mounts
|
||||
# the route table — otherwise POST /v1/worker/execute is appended after the SPA
|
||||
# catch-all is already mounted and never registers, so the catch-all answers it
|
||||
# (GET/HEAD only) and the coordinator seam 405s (no hidden-run hole, but no renders).
|
||||
if args.worker_mode:
|
||||
from middleware.worker_client import add_worker_routes
|
||||
from middleware.worker_client import add_worker_routes, WORKER_TOKEN
|
||||
# FAIL CLOSED: a worker box executes jobs the coordinator hands it over a
|
||||
# secret-authenticated seam. Without STUDIO_WORKER_TOKEN that seam (and the
|
||||
# worker-mode /prompt gate) would accept un-tokened callers — silently
|
||||
# reopening the hidden-run hole. Refuse to start rather than run wide open.
|
||||
if not WORKER_TOKEN:
|
||||
sys.exit("worker-mode requires STUDIO_WORKER_TOKEN (coordinator shared secret) — refusing to start")
|
||||
add_worker_routes(prompt_server.routes, prompt_server)
|
||||
logging.info("Worker mode enabled — worker_id=%s coordinator=%s",
|
||||
args.worker_id, args.coordinator_url)
|
||||
|
||||
threading.Thread(target=prompt_worker, daemon=True, args=(prompt_server.prompt_queue, prompt_server,)).start()
|
||||
prompt_server.add_routes()
|
||||
hijack_progress(prompt_server)
|
||||
|
||||
_worker_thread = threading.Thread(target=prompt_worker, daemon=True, args=(prompt_server.prompt_queue, prompt_server,))
|
||||
_worker_thread.start()
|
||||
# Exposed so /ready can gate on the render worker actually being alive: if this
|
||||
# daemon thread dies (an unhandled exception in the executor), the queue still
|
||||
# accepts /prompt but nothing runs — readiness must fail so the pod leaves rotation
|
||||
# and a roll never promotes a wedged pod.
|
||||
prompt_server.prompt_worker_thread = _worker_thread
|
||||
|
||||
if args.quick_test_for_ci:
|
||||
exit(0)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Colour fidelity for Fix outputs.
|
||||
|
||||
A Qwen-Image-Edit fix runs at denoise 1.0 — it re-paints EVERY pixel, so the
|
||||
render's global white-balance / exposure / saturation drift off the source, and
|
||||
that drift COMPOUNDS across fix-of-a-fix. This restores the render's global colour
|
||||
statistics to its source (Reinhard mean/std transfer, per RGB channel): the local
|
||||
edit stays, the global cast goes. Matching an already-correct image to its source
|
||||
is the identity, so a render that did not drift is left untouched.
|
||||
|
||||
Applied POD-SIDE when a BYO-GPU worker's finished render is ingested
|
||||
(server.upload_output). It needs NO node on the GPU worker and touches no render
|
||||
graph, so it can never make a render fail. Fail-safe throughout: any error, missing
|
||||
source, or size mismatch leaves the raw bytes exactly as uploaded.
|
||||
"""
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
# Fraction of the correction to apply. <1.0 leaves headroom for an INTENTIONAL
|
||||
# global colour change in the instruction: the drift being corrected is a global
|
||||
# cast, while a normal edit is local and barely moves the whole-frame statistics,
|
||||
# so a moderate-high default corrects drift while an intended recolour mostly
|
||||
# survives. Tunable without a rebuild via STUDIO_COLORMATCH_STRENGTH.
|
||||
STRENGTH = float(os.environ.get("STUDIO_COLORMATCH_STRENGTH", "0.85"))
|
||||
|
||||
|
||||
def color_match(target: Image.Image, reference: Image.Image, strength: float = STRENGTH) -> Image.Image:
|
||||
"""Pull ``target``'s global colour balance onto ``reference``'s — a per-channel
|
||||
Reinhard mean/std transfer in RGB. target-to-itself is the identity."""
|
||||
t = np.asarray(target.convert("RGB"), np.float32)
|
||||
ref = reference.convert("RGB")
|
||||
if ref.size != target.size:
|
||||
ref = ref.resize(target.size, Image.LANCZOS)
|
||||
r = np.asarray(ref, np.float32)
|
||||
out = t.copy()
|
||||
for c in range(3):
|
||||
tm, ts = t[..., c].mean(), t[..., c].std() + 1e-5
|
||||
rm, rs = r[..., c].mean(), r[..., c].std() + 1e-5
|
||||
out[..., c] = (t[..., c] - tm) * (rs / ts) + rm
|
||||
out = t + (out - t) * float(strength)
|
||||
return Image.fromarray(np.clip(out, 0, 255).astype(np.uint8))
|
||||
|
||||
|
||||
def match_in_place(out_path: str, src_path: str, strength: float = STRENGTH) -> bool:
|
||||
"""Colour-match the fix render at ``out_path`` to its source, atomically in
|
||||
place (tmp + replace). Returns True if rewritten, False (no-op) on any error or
|
||||
missing input — never raises."""
|
||||
try:
|
||||
if strength <= 0 or not (os.path.isfile(out_path) and os.path.isfile(src_path)):
|
||||
return False
|
||||
with Image.open(out_path) as o, Image.open(src_path) as s:
|
||||
matched = color_match(o, s, strength)
|
||||
# Tmp keeps a real image extension so PIL can infer the format; pass it
|
||||
# explicitly too (the ".cm.tmp" suffix alone is unknown to PIL).
|
||||
ext = os.path.splitext(out_path)[1].lower()
|
||||
fmt = {".png": "PNG", ".jpg": "JPEG", ".jpeg": "JPEG", ".webp": "WEBP"}.get(ext, "PNG")
|
||||
tmp = f"{out_path}.cm.tmp{ext or '.png'}"
|
||||
matched.save(tmp, format=fmt)
|
||||
os.replace(tmp, out_path)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
+18
-11
@@ -17,7 +17,7 @@ Env (all optional):
|
||||
STUDIO_COPILOT_URL chat-completions URL. Default: the local engine at
|
||||
http://127.0.0.1:1234/v1/chat/completions if that port
|
||||
is open, else https://api.hanzo.ai/v1/chat/completions.
|
||||
STUDIO_COPILOT_MODEL model id (default "zen").
|
||||
STUDIO_COPILOT_MODEL model id (default "enso" — the Hanzo flagship assistant).
|
||||
STUDIO_COPILOT_TOKEN bearer for the gateway. Falls back to
|
||||
STUDIO_COMMERCE_TOKEN, then any bearer on the request.
|
||||
|
||||
@@ -62,6 +62,11 @@ _OP_REQUIRED = {
|
||||
_GATEWAY_URL = "https://api.hanzo.ai/v1/chat/completions"
|
||||
_LOCAL_URL = "http://127.0.0.1:1234/v1/chat/completions"
|
||||
|
||||
# Server-side gateway keys, in precedence order (never in page source). When none is set
|
||||
# the caller's own token is forwarded ONLY when the gateway can verify it (pk-/asymmetric
|
||||
# JWT) — see gateway_auth. This is why a browser HS256 session token no longer 502s.
|
||||
_SERVER_TOKEN_ENV = ("STUDIO_COPILOT_TOKEN", "STUDIO_COMMERCE_TOKEN")
|
||||
|
||||
# Single tool: the model calls it to both reply and edit the graph.
|
||||
_EDIT_TOOL = {
|
||||
"type": "function",
|
||||
@@ -142,17 +147,19 @@ class CopilotError(Exception):
|
||||
|
||||
|
||||
def _resolve_target(request) -> tuple[str, str, str]:
|
||||
"""(url, model, token) for this request."""
|
||||
"""(url, model, bearer_token) for this request.
|
||||
|
||||
The bearer is chosen by the ONE fail-closed gateway allowlist (``gateway_auth``): a
|
||||
server-side key first, else the caller's own token ONLY when the gateway can verify it
|
||||
(a publishable ``pk-`` key or an asymmetric/JWKS JWT). A symmetric HS256 hanzo.id
|
||||
session token is NEVER forwarded — the gateway rejects it ("unsupported signing method:
|
||||
HS256"), which was the 502 that made Chat show a raw error. ``token`` is the bare value
|
||||
(no ``Bearer `` prefix) since ``_post_chat`` adds it; "" when none is usable."""
|
||||
from middleware import gateway_auth
|
||||
url = os.environ.get("STUDIO_COPILOT_URL", "").strip() or _DEFAULT_URL
|
||||
model = os.environ.get("STUDIO_COPILOT_MODEL", "").strip() or "zen"
|
||||
token = (
|
||||
os.environ.get("STUDIO_COPILOT_TOKEN", "").strip()
|
||||
or os.environ.get("STUDIO_COMMERCE_TOKEN", "").strip()
|
||||
)
|
||||
if not token:
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
token = auth[7:]
|
||||
model = os.environ.get("STUDIO_COPILOT_MODEL", "").strip() or "enso"
|
||||
bearer = gateway_auth.ai_bearer(request, server_env=_SERVER_TOKEN_ENV)
|
||||
token = bearer[7:] if bearer[:7].lower() == "bearer " else bearer
|
||||
return url, model, token
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,887 @@
|
||||
"""Office-document renderers — the NON-GPU pipeline.
|
||||
|
||||
Studio's creative side is ComfyUI graphs → GPU. Office documents are the opposite:
|
||||
structured content (a template's typed fields, filled by a person or by the AI) →
|
||||
a pure-Python renderer → a real .pdf/.docx/.xlsx/.pptx/.md file. No graph, no model,
|
||||
no GPU.
|
||||
|
||||
One and only one way, kept DRY by a neutral intermediate representation (IR):
|
||||
|
||||
* a template maps its fields to ONE of three archetype IRs —
|
||||
- ``doc`` : a list of prose blocks (title/heading/paragraph/table/…) → PDF · DOCX · MD
|
||||
- ``sheet`` : a list of sheets (columns + rows + totals) → XLSX · PDF · DOCX · MD
|
||||
- ``deck`` : a list of slides (title + bullets + notes) → PPTX · PDF · MD
|
||||
* each output format has EXACTLY ONE renderer that consumes the IR.
|
||||
|
||||
So adding a template = write one ``build(fields) -> (kind, ir)``; adding a format =
|
||||
write one renderer. The renderers never know about templates, HTTP, storage, or org
|
||||
scoping — they are pure ``(ir) -> bytes``.
|
||||
|
||||
Dependencies are imported LAZILY inside each renderer so this module imports with only
|
||||
the standard library present (unit tests, light pods). PDF prefers WeasyPrint (full CSS
|
||||
fidelity); when its native stack (pango/cairo) is absent it falls back to a self-contained
|
||||
pure-Python PDF writer that still emits a valid, readable, monochrome document — the
|
||||
``weasyprint``-absent fallback. DOCX/XLSX/PPTX are pure-Python (python-docx / openpyxl /
|
||||
python-pptx) and always render for real.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html as _html
|
||||
import io
|
||||
import zlib
|
||||
from typing import Any
|
||||
|
||||
# ── Public surface ────────────────────────────────────────────────────────────────
|
||||
|
||||
FORMATS = ("pdf", "docx", "xlsx", "pptx", "md")
|
||||
|
||||
CONTENT_TYPE = {
|
||||
"pdf": "application/pdf",
|
||||
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"md": "text/markdown; charset=utf-8",
|
||||
}
|
||||
|
||||
# Which formats each archetype can produce. The catalog's per-template ``formats`` is
|
||||
# always a subset of its archetype's capability — the render dispatch enforces both.
|
||||
KIND_FORMATS = {
|
||||
"doc": ("pdf", "docx", "md"),
|
||||
"sheet": ("xlsx", "pdf", "docx", "md"),
|
||||
"deck": ("pptx", "pdf", "md"),
|
||||
}
|
||||
|
||||
|
||||
def content_type(fmt: str) -> str:
|
||||
return CONTENT_TYPE[fmt]
|
||||
|
||||
|
||||
def render(kind: str, ir: dict, fmt: str) -> bytes:
|
||||
"""Render an archetype IR to ``fmt``, returning the file bytes.
|
||||
|
||||
Raises ``ValueError`` for an unsupported (kind, fmt) pair — the one place the
|
||||
dispatch table lives, so a caller can never silently get the wrong renderer.
|
||||
"""
|
||||
if fmt not in KIND_FORMATS.get(kind, ()): # pragma: no cover - guarded by callers
|
||||
raise ValueError(f"{kind!r} cannot render {fmt!r}")
|
||||
if kind == "doc":
|
||||
return _render_doc(ir, fmt)
|
||||
if kind == "sheet":
|
||||
return _render_sheet(ir, fmt)
|
||||
if kind == "deck":
|
||||
return _render_deck(ir, fmt)
|
||||
raise ValueError(f"unknown archetype {kind!r}") # pragma: no cover
|
||||
|
||||
|
||||
def weasyprint_available() -> bool:
|
||||
"""True when the high-fidelity PDF path (WeasyPrint + native pango/cairo) is usable.
|
||||
Diagnostic only — the renderer falls back automatically."""
|
||||
return _weasy_pdf("<html><body>probe</body></html>") is not None
|
||||
|
||||
|
||||
# ── IR block constructors — the shared vocabulary of the ``doc`` archetype ─────────
|
||||
# Blocks are plain dicts (JSON-friendly, no classes to import). A template's build()
|
||||
# assembles a list of them; four renderers consume the same list.
|
||||
|
||||
def title(text: str, sub: str | None = None) -> dict:
|
||||
return {"t": "title", "text": text or "", "sub": sub or ""}
|
||||
|
||||
|
||||
def heading(text: str, level: int = 2) -> dict:
|
||||
return {"t": "h", "text": text or "", "level": max(1, min(3, int(level)))}
|
||||
|
||||
|
||||
def para(text: str) -> dict:
|
||||
return {"t": "p", "text": text or ""}
|
||||
|
||||
|
||||
def keyvals(pairs: list, cols: int = 1) -> dict:
|
||||
return {"t": "kv", "pairs": [[str(k), str(v)] for k, v in pairs if v not in (None, "")], "cols": cols}
|
||||
|
||||
|
||||
def party(left: dict, right: dict | None = None) -> dict:
|
||||
"""Two facing address/identity blocks (invoice from/to, letter sender/recipient)."""
|
||||
def _side(s):
|
||||
s = s or {}
|
||||
return {"title": str(s.get("title", "")), "lines": [str(x) for x in (s.get("lines") or []) if str(x).strip()]}
|
||||
return {"t": "party", "left": _side(left), "right": _side(right)}
|
||||
|
||||
|
||||
def table(headers: list, rows: list, align: list | None = None, foot: list | None = None) -> dict:
|
||||
return {
|
||||
"t": "table",
|
||||
"headers": [str(h) for h in headers],
|
||||
"rows": [[("" if c is None else str(c)) for c in r] for r in rows],
|
||||
"align": align or [],
|
||||
"foot": [[("" if c is None else str(c)) for c in r] for r in (foot or [])],
|
||||
}
|
||||
|
||||
|
||||
def bullets(items: list) -> dict:
|
||||
return {"t": "ul", "items": [str(i) for i in items if str(i).strip()]}
|
||||
|
||||
|
||||
def rule() -> dict:
|
||||
return {"t": "hr"}
|
||||
|
||||
|
||||
def spacer(h: int = 12) -> dict:
|
||||
return {"t": "sp", "h": int(h)}
|
||||
|
||||
|
||||
def signature(label: str) -> dict:
|
||||
return {"t": "sign", "label": label or ""}
|
||||
|
||||
|
||||
def doc_ir(title_text: str, blocks: list, *, meta: dict | None = None) -> dict:
|
||||
return {"title": title_text or "Document", "blocks": [b for b in blocks if b], "meta": meta or {}}
|
||||
|
||||
|
||||
# ── doc archetype: PDF · DOCX · MD ────────────────────────────────────────────────
|
||||
|
||||
def _render_doc(ir: dict, fmt: str) -> bytes:
|
||||
if fmt == "pdf":
|
||||
html = _doc_html(ir)
|
||||
pdf = _weasy_pdf(html)
|
||||
return pdf if pdf is not None else _builtin_pdf(ir)
|
||||
if fmt == "docx":
|
||||
return _doc_docx(ir)
|
||||
if fmt == "md":
|
||||
return _doc_md(ir).encode("utf-8")
|
||||
raise ValueError(fmt) # pragma: no cover
|
||||
|
||||
|
||||
# Monochrome print stylesheet — Hanzo's design-system look on paper: near-black ink on
|
||||
# white, a clean sans stack, thin hairline rules, generous margins. Deliberately light
|
||||
# (this is the paper, previewed as paper even under a dark UI).
|
||||
_PRINT_CSS = """
|
||||
@page { size: Letter; margin: 22mm 20mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: -apple-system, "Helvetica Neue", Helvetica, Arial, "Segoe UI", sans-serif;
|
||||
color: #16171a; font-size: 10.5pt; line-height: 1.5; margin: 0; }
|
||||
h1.doctitle { font-size: 22pt; letter-spacing: -.01em; margin: 0 0 2pt; font-weight: 700; }
|
||||
.docsub { color: #6b6f76; font-size: 10.5pt; margin: 0 0 14pt; }
|
||||
h2 { font-size: 13pt; font-weight: 700; margin: 20pt 0 6pt; letter-spacing:-.005em; }
|
||||
h3 { font-size: 11pt; font-weight: 700; margin: 14pt 0 4pt; }
|
||||
h4 { font-size: 10.5pt; font-weight: 700; margin: 12pt 0 3pt; text-transform: uppercase; letter-spacing: .06em; color:#4a4d53; }
|
||||
p { margin: 0 0 8pt; }
|
||||
.hr { border: 0; border-top: 1px solid #d9dbdf; margin: 14pt 0; }
|
||||
.kv { width: 100%; border-collapse: collapse; margin: 0 0 8pt; }
|
||||
.kv td { padding: 2pt 0; vertical-align: top; }
|
||||
.kv td.k { color: #6b6f76; width: 34%; padding-right: 10pt; }
|
||||
.kv.two { }
|
||||
.parties { display: flex; gap: 26pt; margin: 6pt 0 16pt; }
|
||||
.parties .side { flex: 1; }
|
||||
.parties .side .ttl { text-transform: uppercase; letter-spacing: .06em; font-size: 8.5pt; color: #6b6f76; margin: 0 0 3pt; }
|
||||
.parties .side .ln { margin: 0; }
|
||||
table.grid { width: 100%; border-collapse: collapse; margin: 8pt 0 12pt; font-size: 10pt; }
|
||||
table.grid th { text-align: left; font-weight: 700; border-bottom: 1.4px solid #16171a; padding: 6pt 8pt 6pt 0; }
|
||||
table.grid td { border-bottom: 1px solid #e6e8eb; padding: 6pt 8pt 6pt 0; vertical-align: top; }
|
||||
table.grid th.num, table.grid td.num { text-align: right; padding-right: 0; }
|
||||
table.grid tfoot td { border-bottom: 0; border-top: 1.4px solid #16171a; font-weight: 700; padding-top: 7pt; }
|
||||
ul { margin: 0 0 8pt; padding-left: 16pt; }
|
||||
li { margin: 0 0 3pt; }
|
||||
.sign { margin-top: 34pt; }
|
||||
.sign .line { border-top: 1px solid #16171a; width: 58%; padding-top: 4pt; color: #6b6f76; font-size: 9pt; }
|
||||
"""
|
||||
|
||||
|
||||
def _doc_html(ir: dict) -> str:
|
||||
"""The IR as one self-contained HTML document for WeasyPrint. Built with stdlib
|
||||
``html.escape`` at every value boundary — no template engine, so there is no
|
||||
template-injection surface and no extra dependency; the structural HTML is ours,
|
||||
every field value is escaped."""
|
||||
body = _doc_html_body(ir) # already-escaped, structural HTML
|
||||
doc_title = _html.escape(ir.get("title") or "Document")
|
||||
return (
|
||||
"<!doctype html><html><head><meta charset='utf-8'><title>" + doc_title
|
||||
+ "</title><style>" + _PRINT_CSS + "</style></head><body>" + body + "</body></html>"
|
||||
)
|
||||
|
||||
|
||||
def _e(s: Any) -> str:
|
||||
return _html.escape("" if s is None else str(s))
|
||||
|
||||
|
||||
def _doc_html_body(ir: dict) -> str:
|
||||
out: list[str] = []
|
||||
for b in ir.get("blocks", []):
|
||||
t = b.get("t")
|
||||
if t == "title":
|
||||
out.append(f"<h1 class='doctitle'>{_e(b['text'])}</h1>")
|
||||
if b.get("sub"):
|
||||
out.append(f"<div class='docsub'>{_e(b['sub'])}</div>")
|
||||
elif t == "h":
|
||||
tag = {1: "h2", 2: "h3", 3: "h4"}[b.get("level", 2)]
|
||||
out.append(f"<{tag}>{_e(b['text'])}</{tag}>")
|
||||
elif t == "p":
|
||||
out.append(f"<p>{_e(b['text']).replace(chr(10), '<br>')}</p>")
|
||||
elif t == "kv":
|
||||
cls = "kv two" if b.get("cols") == 2 else "kv"
|
||||
rows = "".join(f"<tr><td class='k'>{_e(k)}</td><td>{_e(v)}</td></tr>" for k, v in b["pairs"])
|
||||
out.append(f"<table class='{cls}'>{rows}</table>")
|
||||
elif t == "party":
|
||||
def side(s):
|
||||
lns = "".join(f"<p class='ln'>{_e(x)}</p>" for x in s["lines"])
|
||||
ttl = f"<p class='ttl'>{_e(s['title'])}</p>" if s["title"] else ""
|
||||
return f"<div class='side'>{ttl}{lns}</div>"
|
||||
out.append(f"<div class='parties'>{side(b['left'])}{side(b['right'])}</div>")
|
||||
elif t == "table":
|
||||
out.append(_html_table(b))
|
||||
elif t == "ul":
|
||||
out.append("<ul>" + "".join(f"<li>{_e(i)}</li>" for i in b["items"]) + "</ul>")
|
||||
elif t == "hr":
|
||||
out.append("<hr class='hr'>")
|
||||
elif t == "sp":
|
||||
out.append(f"<div style='height:{int(b.get('h', 12))}pt'></div>")
|
||||
elif t == "sign":
|
||||
out.append(f"<div class='sign'><div class='line'>{_e(b['label'])}</div></div>")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _num_cols(b: dict) -> set:
|
||||
return {i for i, a in enumerate(b.get("align", [])) if a == "num"}
|
||||
|
||||
|
||||
def _html_table(b: dict) -> str:
|
||||
nums = _num_cols(b)
|
||||
th = "".join(f"<th class='{'num' if i in nums else ''}'>{_e(h)}</th>" for i, h in enumerate(b["headers"]))
|
||||
body = "".join(
|
||||
"<tr>" + "".join(f"<td class='{'num' if i in nums else ''}'>{_e(c)}</td>" for i, c in enumerate(r)) + "</tr>"
|
||||
for r in b["rows"]
|
||||
)
|
||||
foot = ""
|
||||
if b.get("foot"):
|
||||
foot = "<tfoot>" + "".join(
|
||||
"<tr>" + "".join(f"<td class='{'num' if i in nums else ''}'>{_e(c)}</td>" for i, c in enumerate(r)) + "</tr>"
|
||||
for r in b["foot"]
|
||||
) + "</tfoot>"
|
||||
return f"<table class='grid'><thead><tr>{th}</tr></thead><tbody>{body}</tbody>{foot}</table>"
|
||||
|
||||
|
||||
_WEASY: Any = ... # unset sentinel; set to the module or False on first probe
|
||||
|
||||
|
||||
def _weasyprint():
|
||||
"""The weasyprint module if importable with its native stack, else False. Probed
|
||||
once and cached — the import is expensive and, when pango/cairo are absent, prints
|
||||
its own diagnostics, so we do it exactly once and swallow that chatter."""
|
||||
global _WEASY
|
||||
if _WEASY is ...:
|
||||
import contextlib
|
||||
import os as _os
|
||||
try:
|
||||
with open(_os.devnull, "w") as dn, contextlib.redirect_stderr(dn), contextlib.redirect_stdout(dn):
|
||||
import weasyprint # type: ignore
|
||||
_WEASY = weasyprint
|
||||
except Exception:
|
||||
_WEASY = False
|
||||
return _WEASY
|
||||
|
||||
|
||||
def _weasy_pdf(html: str) -> bytes | None:
|
||||
"""WeasyPrint render, or None when the library or its native stack is unavailable —
|
||||
the caller then uses the built-in fallback. Fully guarded so a pod without
|
||||
pango/cairo simply falls back."""
|
||||
wp = _weasyprint()
|
||||
if not wp:
|
||||
return None
|
||||
try:
|
||||
return wp.HTML(string=html).write_pdf()
|
||||
except Exception: # pragma: no cover - native failure at render time
|
||||
return None
|
||||
|
||||
|
||||
# ── DOCX (python-docx) ────────────────────────────────────────────────────────────
|
||||
|
||||
def _doc_docx(ir: dict) -> bytes:
|
||||
from docx import Document
|
||||
from docx.shared import Pt, RGBColor
|
||||
|
||||
INK = RGBColor(0x16, 0x17, 0x1A)
|
||||
DIM = RGBColor(0x6B, 0x6F, 0x76)
|
||||
d = Document()
|
||||
for s in d.styles:
|
||||
try:
|
||||
if s.font is not None and s.font.name is None:
|
||||
s.font.name = "Helvetica"
|
||||
except Exception:
|
||||
pass
|
||||
normal = d.styles["Normal"]
|
||||
normal.font.name = "Helvetica"
|
||||
normal.font.size = Pt(10.5)
|
||||
normal.font.color.rgb = INK
|
||||
|
||||
def _runcolor(p, color=INK):
|
||||
for r in p.runs:
|
||||
r.font.color.rgb = color
|
||||
|
||||
for b in ir.get("blocks", []):
|
||||
t = b.get("t")
|
||||
if t == "title":
|
||||
p = d.add_paragraph()
|
||||
run = p.add_run(b["text"])
|
||||
run.bold = True
|
||||
run.font.size = Pt(22)
|
||||
run.font.color.rgb = INK
|
||||
if b.get("sub"):
|
||||
sp = d.add_paragraph()
|
||||
sr = sp.add_run(b["sub"])
|
||||
sr.font.size = Pt(10.5)
|
||||
sr.font.color.rgb = DIM
|
||||
elif t == "h":
|
||||
p = d.add_paragraph()
|
||||
run = p.add_run(b["text"])
|
||||
run.bold = True
|
||||
run.font.size = Pt({1: 13, 2: 11, 3: 10.5}[b.get("level", 2)])
|
||||
run.font.color.rgb = INK if b.get("level", 2) < 3 else DIM
|
||||
elif t == "p":
|
||||
p = d.add_paragraph(b["text"])
|
||||
_runcolor(p)
|
||||
elif t == "kv":
|
||||
tbl = d.add_table(rows=0, cols=2)
|
||||
for k, v in b["pairs"]:
|
||||
cells = tbl.add_row().cells
|
||||
kr = cells[0].paragraphs[0].add_run(k)
|
||||
kr.font.color.rgb = DIM
|
||||
kr.font.size = Pt(10)
|
||||
vr = cells[1].paragraphs[0].add_run(v)
|
||||
vr.font.size = Pt(10)
|
||||
vr.font.color.rgb = INK
|
||||
elif t == "party":
|
||||
tbl = d.add_table(rows=1, cols=2)
|
||||
for cell, side in ((tbl.rows[0].cells[0], b["left"]), (tbl.rows[0].cells[1], b["right"])):
|
||||
if side["title"]:
|
||||
tr = cell.paragraphs[0].add_run(side["title"].upper())
|
||||
tr.bold = True
|
||||
tr.font.size = Pt(8.5)
|
||||
tr.font.color.rgb = DIM
|
||||
for ln in side["lines"]:
|
||||
lp = cell.add_paragraph()
|
||||
lr = lp.add_run(ln)
|
||||
lr.font.size = Pt(10)
|
||||
lr.font.color.rgb = INK
|
||||
elif t == "table":
|
||||
_docx_table(d, b, INK, DIM, Pt)
|
||||
elif t == "ul":
|
||||
for it in b["items"]:
|
||||
p = d.add_paragraph(it, style="List Bullet")
|
||||
_runcolor(p)
|
||||
elif t == "hr":
|
||||
d.add_paragraph("_" * 48).runs[0].font.color.rgb = DIM
|
||||
elif t == "sp":
|
||||
d.add_paragraph()
|
||||
elif t == "sign":
|
||||
d.add_paragraph()
|
||||
p = d.add_paragraph("__________________________")
|
||||
p.runs[0].font.color.rgb = INK
|
||||
lp = d.add_paragraph()
|
||||
lr = lp.add_run(b["label"])
|
||||
lr.font.size = Pt(9)
|
||||
lr.font.color.rgb = DIM
|
||||
buf = io.BytesIO()
|
||||
d.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _docx_table(d, b, INK, DIM, Pt) -> None:
|
||||
headers, rows, foot = b["headers"], b["rows"], b.get("foot") or []
|
||||
nums = _num_cols(b)
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
tbl = d.add_table(rows=1, cols=len(headers))
|
||||
tbl.style = "Light Grid Accent 1" if "Light Grid Accent 1" in [s.name for s in d.styles] else "Table Grid"
|
||||
for i, h in enumerate(headers):
|
||||
cell = tbl.rows[0].cells[i]
|
||||
run = cell.paragraphs[0].add_run(h)
|
||||
run.bold = True
|
||||
run.font.size = Pt(9.5)
|
||||
run.font.color.rgb = INK
|
||||
if i in nums:
|
||||
cell.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
||||
for r in rows:
|
||||
cells = tbl.add_row().cells
|
||||
for i, c in enumerate(r):
|
||||
run = cells[i].paragraphs[0].add_run(c)
|
||||
run.font.size = Pt(9.5)
|
||||
run.font.color.rgb = INK
|
||||
if i in nums:
|
||||
cells[i].paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
||||
for r in foot:
|
||||
cells = tbl.add_row().cells
|
||||
for i, c in enumerate(r):
|
||||
run = cells[i].paragraphs[0].add_run(c)
|
||||
run.bold = True
|
||||
run.font.size = Pt(9.5)
|
||||
run.font.color.rgb = INK
|
||||
if i in nums:
|
||||
cells[i].paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
||||
|
||||
|
||||
# ── Markdown ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _doc_md(ir: dict) -> str:
|
||||
out: list[str] = []
|
||||
for b in ir.get("blocks", []):
|
||||
t = b.get("t")
|
||||
if t == "title":
|
||||
out.append(f"# {b['text']}")
|
||||
if b.get("sub"):
|
||||
out.append(f"*{b['sub']}*")
|
||||
elif t == "h":
|
||||
out.append(("#" * (b.get("level", 2) + 1)) + " " + b["text"])
|
||||
elif t == "p":
|
||||
out.append(b["text"])
|
||||
elif t == "kv":
|
||||
out.extend(f"- **{k}:** {v}" for k, v in b["pairs"])
|
||||
elif t == "party":
|
||||
for side in (b["left"], b["right"]):
|
||||
if side["title"]:
|
||||
out.append(f"**{side['title']}**")
|
||||
out.extend(side["lines"])
|
||||
out.append("")
|
||||
elif t == "table":
|
||||
out.append(_md_table(b["headers"], b["rows"] + (b.get("foot") or [])))
|
||||
elif t == "ul":
|
||||
out.extend(f"- {i}" for i in b["items"])
|
||||
elif t == "hr":
|
||||
out.append("---")
|
||||
elif t == "sign":
|
||||
out.append("")
|
||||
out.append("\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_")
|
||||
out.append(b["label"])
|
||||
out.append("")
|
||||
return "\n".join(out).strip() + "\n"
|
||||
|
||||
|
||||
def _md_table(headers: list, rows: list) -> str:
|
||||
def row(cells):
|
||||
return "| " + " | ".join(str(c).replace("|", "\\|") for c in cells) + " |"
|
||||
lines = [row(headers), "| " + " | ".join("---" for _ in headers) + " |"]
|
||||
lines += [row(r + [""] * (len(headers) - len(r))) for r in rows]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Built-in PDF fallback — a valid, monochrome, paginated PDF with zero native deps ─
|
||||
# Base-14 Helvetica (no embedding needed), WinAnsi text, absolute-positioned lines,
|
||||
# automatic pagination and word wrap, simple heading/table/bullet layout. Not as rich
|
||||
# as WeasyPrint, but a genuine, readable document that always passes %PDF validation.
|
||||
|
||||
_PAGE_W, _PAGE_H = 612.0, 792.0
|
||||
_ML, _MR, _MT, _MB = 54.0, 54.0, 60.0, 56.0
|
||||
_CONTENT_W = _PAGE_W - _ML - _MR
|
||||
|
||||
# Coarse Helvetica advance widths (per 1pt), enough to wrap without overflow.
|
||||
_AVG = 0.50
|
||||
|
||||
|
||||
def _text_w(s: str, size: float, bold: bool = False) -> float:
|
||||
return len(s) * size * (_AVG + (0.02 if bold else 0.0))
|
||||
|
||||
|
||||
def _wrap(text: str, size: float, width: float, bold: bool = False) -> list[str]:
|
||||
lines: list[str] = []
|
||||
for raw in str(text).split("\n"):
|
||||
words, cur = raw.split(), ""
|
||||
for w in words:
|
||||
trial = w if not cur else cur + " " + w
|
||||
if _text_w(trial, size, bold) <= width or not cur:
|
||||
cur = trial
|
||||
else:
|
||||
lines.append(cur)
|
||||
cur = w
|
||||
lines.append(cur)
|
||||
return lines or [""]
|
||||
|
||||
|
||||
def _pdf_escape(s: str) -> str:
|
||||
return s.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
|
||||
|
||||
|
||||
def _pdf_enc(s: str) -> str:
|
||||
# WinAnsi/cp1252 so currency/quotes/dashes survive; unmappable → '?'.
|
||||
return _pdf_escape(s.encode("cp1252", "replace").decode("cp1252"))
|
||||
|
||||
|
||||
class _PDFPager:
|
||||
"""Accumulates absolute-positioned text ops across auto-paginated pages."""
|
||||
|
||||
def __init__(self):
|
||||
self.pages: list[list[str]] = [[]]
|
||||
self.y = _PAGE_H - _MT
|
||||
|
||||
def _page(self) -> list[str]:
|
||||
return self.pages[-1]
|
||||
|
||||
def _newpage(self):
|
||||
self.pages.append([])
|
||||
self.y = _PAGE_H - _MT
|
||||
|
||||
def space(self, h: float):
|
||||
self.y -= h
|
||||
if self.y < _MB:
|
||||
self._newpage()
|
||||
|
||||
def line(self, text: str, size: float, *, bold=False, gray=False, indent=0.0, leading=1.42, gap=0.0):
|
||||
if gap:
|
||||
self.y -= gap
|
||||
wrapped = _wrap(text, size, _CONTENT_W - indent, bold)
|
||||
for ln in wrapped:
|
||||
if self.y - size < _MB:
|
||||
self._newpage()
|
||||
font = "F2" if bold else "F1"
|
||||
col = "0.42 0.44 0.47 rg\n" if gray else "0.086 0.09 0.102 rg\n"
|
||||
self._page().append(
|
||||
f"BT {col}/{font} {size:.2f} Tf 1 0 0 1 {_ML + indent:.2f} {self.y - size:.2f} Tm ({_pdf_enc(ln)}) Tj ET\n"
|
||||
)
|
||||
self.y -= size * leading
|
||||
|
||||
def cols(self, cells: list[tuple[str, float, bool]], xs: list[float], size: float, *, bold=False, gray=False, gap=0.0):
|
||||
"""One row of already-fitted cell strings at column x-offsets."""
|
||||
if gap:
|
||||
self.y -= gap
|
||||
if self.y - size < _MB:
|
||||
self._newpage()
|
||||
font = "F2" if bold else "F1"
|
||||
col = "0.42 0.44 0.47 rg\n" if gray else "0.086 0.09 0.102 rg\n"
|
||||
y = self.y - size
|
||||
for (text, colw, right), x in zip(cells, xs):
|
||||
s = _fit(text, size, colw, bold)
|
||||
tx = x + (colw - _text_w(s, size, bold)) if right else x
|
||||
self._page().append(f"BT {col}/{font} {size:.2f} Tf 1 0 0 1 {_ML + tx:.2f} {y:.2f} Tm ({_pdf_enc(s)}) Tj ET\n")
|
||||
self.y -= size * 1.55
|
||||
|
||||
def hline(self, weight: float = 0.6, gray=True):
|
||||
self.y -= 4
|
||||
if self.y < _MB:
|
||||
self._newpage()
|
||||
col = "0.85 0.86 0.88 RG\n" if gray else "0.086 0.09 0.102 RG\n"
|
||||
self._page().append(f"{col}{weight:.2f} w {_ML:.2f} {self.y:.2f} m {_PAGE_W - _MR:.2f} {self.y:.2f} l S\n")
|
||||
self.y -= 6
|
||||
|
||||
|
||||
def _fit(text: str, size: float, width: float, bold: bool) -> str:
|
||||
s = str(text)
|
||||
if _text_w(s, size, bold) <= width:
|
||||
return s
|
||||
while s and _text_w(s + "…", size, bold) > width:
|
||||
s = s[:-1]
|
||||
return (s + "…") if s else ""
|
||||
|
||||
|
||||
def _builtin_pdf(ir: dict) -> bytes:
|
||||
p = _PDFPager()
|
||||
for b in ir.get("blocks", []):
|
||||
t = b.get("t")
|
||||
if t == "title":
|
||||
p.line(b["text"], 21, bold=True, gap=2)
|
||||
if b.get("sub"):
|
||||
p.line(b["sub"], 10.5, gray=True, gap=1)
|
||||
p.space(8)
|
||||
elif t == "h":
|
||||
size = {1: 13.5, 2: 11.5, 3: 10.5}[b.get("level", 2)]
|
||||
p.line(b["text"], size, bold=True, gray=b.get("level", 2) == 3, gap=10)
|
||||
p.space(2)
|
||||
elif t == "p":
|
||||
p.line(b["text"], 10.5, gap=2)
|
||||
p.space(4)
|
||||
elif t == "kv":
|
||||
for k, v in b["pairs"]:
|
||||
kw = _CONTENT_W * 0.32
|
||||
p.cols([(k, kw, False), (v, _CONTENT_W - kw, False)], [0, kw], 10, gray=False)
|
||||
elif t == "party":
|
||||
half = _CONTENT_W / 2 - 8
|
||||
left, right = b["left"], b["right"]
|
||||
p.space(4)
|
||||
if left["title"] or right["title"]:
|
||||
p.cols([(left["title"].upper(), half, False), (right["title"].upper(), half, False)],
|
||||
[0, _CONTENT_W / 2 + 8], 8.5, bold=True, gray=True)
|
||||
for i in range(max(len(left["lines"]), len(right["lines"]))):
|
||||
lt = left["lines"][i] if i < len(left["lines"]) else ""
|
||||
rt = right["lines"][i] if i < len(right["lines"]) else ""
|
||||
p.cols([(lt, half, False), (rt, half, False)], [0, _CONTENT_W / 2 + 8], 10)
|
||||
p.space(8)
|
||||
elif t == "table":
|
||||
_builtin_table(p, b)
|
||||
elif t == "ul":
|
||||
for it in b["items"]:
|
||||
for j, ln in enumerate(_wrap(it, 10.5, _CONTENT_W - 16)):
|
||||
p.line(("• " if j == 0 else " ") + ln, 10.5, indent=8, leading=1.35)
|
||||
p.space(4)
|
||||
elif t == "hr":
|
||||
p.hline()
|
||||
elif t == "sp":
|
||||
p.space(int(b.get("h", 12)))
|
||||
elif t == "sign":
|
||||
p.space(28)
|
||||
p.hline(weight=0.9, gray=False)
|
||||
p.line(b["label"], 9, gray=True)
|
||||
return _assemble_pdf(p.pages, ir.get("title") or "Document")
|
||||
|
||||
|
||||
def _builtin_table(p: _PDFPager, b: dict) -> None:
|
||||
headers, rows, foot = b["headers"], b["rows"], b.get("foot") or []
|
||||
nums = _num_cols(b)
|
||||
n = len(headers)
|
||||
# First column takes remaining width; numeric/short columns get a fixed slice.
|
||||
fixed = min(_CONTENT_W * 0.22, 120)
|
||||
other = fixed if n > 1 else _CONTENT_W
|
||||
first = _CONTENT_W - other * (n - 1)
|
||||
widths = [first] + [other] * (n - 1)
|
||||
xs, acc = [], 0.0
|
||||
for w in widths:
|
||||
xs.append(acc)
|
||||
acc += w
|
||||
cells = [(h, widths[i] - 8, i in nums) for i, h in enumerate(headers)]
|
||||
p.space(6)
|
||||
p.cols(cells, xs, 9.5, bold=True, gap=2)
|
||||
p.hline(weight=1.2, gray=False)
|
||||
for r in rows:
|
||||
cells = [((r[i] if i < len(r) else ""), widths[i] - 8, i in nums) for i in range(n)]
|
||||
p.cols(cells, xs, 9.5)
|
||||
if foot:
|
||||
p.hline(weight=1.2, gray=False)
|
||||
for fr in foot:
|
||||
cells = [((fr[i] if i < len(fr) else ""), widths[i] - 8, i in nums) for i in range(n)]
|
||||
p.cols(cells, xs, 9.5, bold=True)
|
||||
p.space(6)
|
||||
|
||||
|
||||
def _assemble_pdf(pages: list[list[str]], title_text: str) -> bytes:
|
||||
"""Serialize positioned-text pages into a valid PDF 1.7 with an xref table.
|
||||
|
||||
Object numbers are planned up front so every cross-reference is known before a
|
||||
single byte is written — no insert/renumber games:
|
||||
1 = Catalog · 2 = Pages · 3 = F1 · 4 = F2 ·
|
||||
then per page i: content = 5+2i, page = 6+2i.
|
||||
"""
|
||||
n_pages = max(1, len(pages))
|
||||
page_nos = [6 + 2 * i for i in range(n_pages)]
|
||||
content_nos = [5 + 2 * i for i in range(n_pages)]
|
||||
objs: dict[int, bytes] = {}
|
||||
objs[1] = b"<< /Type /Catalog /Pages 2 0 R >>"
|
||||
kids = " ".join(f"{no} 0 R" for no in page_nos).encode()
|
||||
objs[2] = b"<< /Type /Pages /Kids [" + kids + b"] /Count %d >>" % n_pages
|
||||
objs[3] = b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"
|
||||
objs[4] = b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>"
|
||||
for i in range(n_pages):
|
||||
ops = pages[i] if i < len(pages) else []
|
||||
# cp1252 so WinAnsi glyphs (€, curly quotes, en/em dash) survive as their byte
|
||||
# values; the fonts declare /WinAnsiEncoding so those bytes map to the right glyph.
|
||||
stream = ("".join(ops)).encode("cp1252", "replace")
|
||||
comp = zlib.compress(stream)
|
||||
objs[content_nos[i]] = (
|
||||
b"<< /Length %d /Filter /FlateDecode >>\nstream\n" % len(comp) + comp + b"\nendstream")
|
||||
objs[page_nos[i]] = (
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %d %d] "
|
||||
b"/Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents %d 0 R >>"
|
||||
% (int(_PAGE_W), int(_PAGE_H), content_nos[i]))
|
||||
|
||||
info_no = max(objs) + 1
|
||||
objs[info_no] = (b"<< /Title (" + _pdf_enc(title_text).encode("cp1252", "replace")
|
||||
+ b") /Producer (Hanzo Studio) >>")
|
||||
|
||||
count = max(objs)
|
||||
out = bytearray(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n")
|
||||
offsets = [0] * (count + 1)
|
||||
for i in range(1, count + 1):
|
||||
offsets[i] = len(out)
|
||||
out += b"%d 0 obj\n" % i + objs[i] + b"\nendobj\n"
|
||||
xref_pos = len(out)
|
||||
out += b"xref\n0 %d\n" % (count + 1)
|
||||
out += b"0000000000 65535 f \n"
|
||||
for i in range(1, count + 1):
|
||||
out += b"%010d 00000 n \n" % offsets[i]
|
||||
out += b"trailer\n<< /Size %d /Root 1 0 R /Info %d 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (
|
||||
count + 1, info_no, xref_pos)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
# ── sheet archetype: XLSX · PDF · DOCX · MD ───────────────────────────────────────
|
||||
# SheetIR = {"title", "sheets": [{"name", "columns": [{"key","label","width","fmt"}],
|
||||
# "rows": [dict], "totals": [dict]}]}. fmt ∈ {"money","int","pct","date",None}.
|
||||
|
||||
def sheet_ir(title_text: str, sheets: list) -> dict:
|
||||
return {"title": title_text or "Spreadsheet", "sheets": sheets}
|
||||
|
||||
|
||||
def _render_sheet(ir: dict, fmt: str) -> bytes:
|
||||
if fmt == "xlsx":
|
||||
return _sheet_xlsx(ir)
|
||||
# Secondary formats reuse the doc archetype by turning each sheet into a table block.
|
||||
d = _sheet_as_doc(ir)
|
||||
return _render_doc(d, fmt)
|
||||
|
||||
|
||||
_XLSX_FMT = {"money": "#,##0.00", "int": "#,##0", "pct": "0.0%", "date": "yyyy-mm-dd"}
|
||||
|
||||
|
||||
def _xlsx_safe(v):
|
||||
"""OWASP formula-injection guard (CWE-1236). openpyxl turns any string cell
|
||||
starting with '=' into a LIVE formula; '+','-','@','\\t','\\r' are the other
|
||||
DDE/HYPERLINK triggers. These XLSX outputs (budgets, expense reports) are made
|
||||
to be shared, so a text cell that begins with a trigger char is coerced to text
|
||||
with a leading apostrophe — inert in Excel/Sheets, invisible to the reader.
|
||||
Numeric cells (int/float) are untouched: openpyxl only weaponizes strings."""
|
||||
if isinstance(v, str) and v[:1] in ("=", "+", "-", "@", "\t", "\r"):
|
||||
return "'" + v
|
||||
return v
|
||||
|
||||
|
||||
def _sheet_xlsx(ir: dict) -> bytes:
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
|
||||
wb = Workbook()
|
||||
wb.remove(wb.active)
|
||||
ink = "16171A"
|
||||
head_fill = PatternFill("solid", fgColor="F0F1F3")
|
||||
head_font = Font(name="Helvetica", bold=True, color=ink, size=10)
|
||||
base_font = Font(name="Helvetica", color=ink, size=10)
|
||||
bot = Border(bottom=Side(style="thin", color="16171A"))
|
||||
thin = Border(bottom=Side(style="hair", color="D9DBDF"))
|
||||
for sh in ir.get("sheets", []):
|
||||
ws = wb.create_sheet(title=(sh.get("name") or "Sheet")[:31])
|
||||
cols = sh.get("columns", [])
|
||||
for ci, col in enumerate(cols, start=1):
|
||||
c = ws.cell(row=1, column=ci, value=col.get("label", col.get("key", "")))
|
||||
c.font = head_font
|
||||
c.fill = head_fill
|
||||
c.border = bot
|
||||
c.alignment = Alignment(horizontal="right" if col.get("fmt") in ("money", "int", "pct") else "left")
|
||||
ws.column_dimensions[c.column_letter].width = col.get("width", 18)
|
||||
r = 2
|
||||
for row in sh.get("rows", []):
|
||||
for ci, col in enumerate(cols, start=1):
|
||||
v = row.get(col["key"])
|
||||
c = ws.cell(row=r, column=ci, value=_xlsx_safe(v))
|
||||
c.font = base_font
|
||||
c.border = thin
|
||||
nf = _XLSX_FMT.get(col.get("fmt"))
|
||||
if nf:
|
||||
c.number_format = nf
|
||||
r += 1
|
||||
for total in sh.get("totals", []):
|
||||
for ci, col in enumerate(cols, start=1):
|
||||
v = total.get(col["key"])
|
||||
c = ws.cell(row=r, column=ci, value=_xlsx_safe(v))
|
||||
c.font = Font(name="Helvetica", bold=True, color=ink, size=10)
|
||||
c.border = bot
|
||||
nf = _XLSX_FMT.get(col.get("fmt"))
|
||||
if nf and isinstance(v, (int, float)):
|
||||
c.number_format = nf
|
||||
r += 1
|
||||
ws.freeze_panes = "A2"
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _fmt_cell(v: Any, fmt: str | None) -> str:
|
||||
if v is None or v == "":
|
||||
return ""
|
||||
if fmt == "money" and isinstance(v, (int, float)):
|
||||
return f"{v:,.2f}"
|
||||
if fmt == "int" and isinstance(v, (int, float)):
|
||||
return f"{int(v):,}"
|
||||
if fmt == "pct" and isinstance(v, (int, float)):
|
||||
return f"{v * 100:.1f}%"
|
||||
return str(v)
|
||||
|
||||
|
||||
def _sheet_as_doc(ir: dict) -> dict:
|
||||
blocks = [title(ir.get("title") or "Spreadsheet")]
|
||||
for sh in ir.get("sheets", []):
|
||||
cols = sh.get("columns", [])
|
||||
if len(ir.get("sheets", [])) > 1:
|
||||
blocks.append(heading(sh.get("name") or "Sheet", 2))
|
||||
headers = [c.get("label", c.get("key", "")) for c in cols]
|
||||
align = ["num" if c.get("fmt") in ("money", "int", "pct") else "" for c in cols]
|
||||
rows = [[_fmt_cell(row.get(c["key"]), c.get("fmt")) for c in cols] for row in sh.get("rows", [])]
|
||||
foot = [[_fmt_cell(t.get(c["key"]), c.get("fmt")) for c in cols] for t in sh.get("totals", [])]
|
||||
blocks.append(table(headers, rows, align=align, foot=foot))
|
||||
return doc_ir(ir.get("title") or "Spreadsheet", blocks)
|
||||
|
||||
|
||||
# ── deck archetype: PPTX · PDF · MD ───────────────────────────────────────────────
|
||||
# DeckIR = {"title", "slides": [{"layout": "title"|"bullets"|"section",
|
||||
# "title", "subtitle", "bullets": [str], "notes"}]}.
|
||||
|
||||
def deck_ir(title_text: str, slides: list) -> dict:
|
||||
return {"title": title_text or "Presentation", "slides": slides}
|
||||
|
||||
|
||||
def _render_deck(ir: dict, fmt: str) -> bytes:
|
||||
if fmt == "pptx":
|
||||
return _deck_pptx(ir)
|
||||
return _render_doc(_deck_as_doc(ir), fmt)
|
||||
|
||||
|
||||
def _deck_pptx(ir: dict) -> bytes:
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches, Pt
|
||||
from pptx.dml.color import RGBColor
|
||||
|
||||
INK = RGBColor(0x16, 0x17, 0x1A)
|
||||
DIM = RGBColor(0x6B, 0x6F, 0x76)
|
||||
prs = Presentation()
|
||||
prs.slide_width = Inches(13.333)
|
||||
prs.slide_height = Inches(7.5)
|
||||
blank = prs.slide_layouts[6]
|
||||
|
||||
def _tb(slide, left, top, width, height):
|
||||
box = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
|
||||
box.text_frame.word_wrap = True
|
||||
return box.text_frame
|
||||
|
||||
for sd in ir.get("slides", []):
|
||||
slide = prs.slides.add_slide(blank)
|
||||
layout = sd.get("layout", "bullets")
|
||||
if layout in ("title", "section"):
|
||||
tf = _tb(slide, 0.9, 2.6 if layout == "title" else 2.9, 11.5, 2.0)
|
||||
para0 = tf.paragraphs[0]
|
||||
run = para0.add_run()
|
||||
run.text = sd.get("title", "")
|
||||
run.font.size = Pt(40 if layout == "title" else 30)
|
||||
run.font.bold = True
|
||||
run.font.color.rgb = INK
|
||||
if sd.get("subtitle"):
|
||||
sp = tf.add_paragraph()
|
||||
sr = sp.add_run()
|
||||
sr.text = sd["subtitle"]
|
||||
sr.font.size = Pt(18)
|
||||
sr.font.color.rgb = DIM
|
||||
else:
|
||||
tf = _tb(slide, 0.9, 0.7, 11.5, 1.1)
|
||||
run = tf.paragraphs[0].add_run()
|
||||
run.text = sd.get("title", "")
|
||||
run.font.size = Pt(28)
|
||||
run.font.bold = True
|
||||
run.font.color.rgb = INK
|
||||
body = _tb(slide, 0.9, 2.0, 11.5, 4.8)
|
||||
first = True
|
||||
for b in sd.get("bullets", []):
|
||||
p = body.paragraphs[0] if first else body.add_paragraph()
|
||||
first = False
|
||||
r = p.add_run()
|
||||
r.text = str(b)
|
||||
r.font.size = Pt(18)
|
||||
r.font.color.rgb = INK
|
||||
p.space_after = Pt(8)
|
||||
if sd.get("notes"):
|
||||
slide.notes_slide.notes_text_frame.text = sd["notes"]
|
||||
buf = io.BytesIO()
|
||||
prs.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _deck_as_doc(ir: dict) -> dict:
|
||||
blocks = [title(ir.get("title") or "Presentation")]
|
||||
for i, sd in enumerate(ir.get("slides", []), start=1):
|
||||
blocks.append(heading(f"{i}. {sd.get('title', '')}".strip(". "), 2))
|
||||
if sd.get("subtitle"):
|
||||
blocks.append(para(sd["subtitle"]))
|
||||
if sd.get("bullets"):
|
||||
blocks.append(bullets(sd["bullets"]))
|
||||
if sd.get("notes"):
|
||||
blocks.append(para(sd["notes"]))
|
||||
return doc_ir(ir.get("title") or "Presentation", blocks)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
"""Gateway auth — the ONE fail-closed allowlist for handing a browser-edge bearer to
|
||||
the Hanzo AI gateway (api.hanzo.ai/v1/chat/completions).
|
||||
|
||||
The problem it closes: the browser session is a *symmetric* HS256 hanzo.id token. The
|
||||
gateway verifies bearers against JWKS and refuses HS256 ("unsupported signing method:
|
||||
HS256") — so forwarding the raw session token produces a 502, not a chat. Secret keys
|
||||
(``sk-``/``hk-``/``rk-``) must never leave the server, and a publishable ``pk-`` key is
|
||||
the one client-safe family the gateway accepts from the edge.
|
||||
|
||||
So the rule, fail-closed, in ONE place every gateway caller reuses:
|
||||
1. a server-side gateway key (configured, never in page source) wins;
|
||||
2. else the caller's own token — forwarded ONLY when the gateway can verify it: a
|
||||
publishable ``pk-`` key, or an asymmetric/JWKS JWT (RS/ES/PS/EdDSA);
|
||||
3. an HS256 session token or any secret key is refused → "" (the caller then degrades
|
||||
honestly instead of emitting a 502).
|
||||
|
||||
This is the same shape the office-documents AI-fill proved (documents.py); it lives here
|
||||
so copilot and any future gateway caller share exactly one implementation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
|
||||
# Secret key families — must never reach the gateway from the browser edge, nor appear
|
||||
# in page source. Matched case-insensitively on the bare token.
|
||||
_SECRET_PREFIXES = ("sk-", "sk_", "hk-", "hk_", "rk-", "rk_")
|
||||
|
||||
|
||||
def jwt_alg(tok: str) -> str:
|
||||
"""The ``alg`` from a JWT header, or "" if not a decodable JWT. Distinguishes a
|
||||
JWKS-verifiable asymmetric token (gateway-acceptable) from an HS256 session token."""
|
||||
try:
|
||||
h = tok.split(".", 1)[0]
|
||||
h += "=" * (-len(h) % 4)
|
||||
alg = json.loads(base64.urlsafe_b64decode(h.encode())).get("alg", "")
|
||||
# A crafted header can make alg a non-string (null/list/number/dict); the
|
||||
# contract is "the alg string or ''", so a non-string is not a usable alg
|
||||
# (else `alg[:2]`/`alg.startswith` below would raise → a self-inflicted 500).
|
||||
return alg if isinstance(alg, str) else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def forwardable_bearer(request) -> str:
|
||||
"""The caller's own token to forward to the gateway, or "" — fail-closed.
|
||||
|
||||
Reuses the app's one token path (``gpu_dispatch._bearer``: Authorization header or the
|
||||
browser session cookie), then applies the strict allowlist:
|
||||
* a publishable ``pk-`` key — allowed;
|
||||
* a JWT — allowed ONLY when asymmetric/JWKS-verifiable (RS/ES/PS/EdDSA); a symmetric
|
||||
HS256 hanzo.id session token is refused (the gateway rejects it, so forwarding it
|
||||
only produces log noise and a 502);
|
||||
* a secret ``sk-``/``hk-``/``rk-`` key — refused outright.
|
||||
So a secret can never reach the gateway from the browser edge and none is in page
|
||||
source. Returns a full ``Bearer <tok>`` value, or "".
|
||||
"""
|
||||
from middleware.gpu_dispatch import _bearer # lazy: keeps this module import light
|
||||
raw = _bearer(request)
|
||||
if not raw:
|
||||
return ""
|
||||
tok = raw[7:].strip() if raw[:7].lower() == "bearer " else raw.strip()
|
||||
if not tok or tok.lower().startswith(_SECRET_PREFIXES):
|
||||
return ""
|
||||
if tok.startswith(("pk-", "pk_")):
|
||||
return "Bearer " + tok
|
||||
if tok.count(".") == 2:
|
||||
# Exact JWS asymmetric set — a prefix match ("RS"/"Ed") also admitted non-JWS
|
||||
# names like RSA-OAEP / "Edwina"; `alg in <tuple>` is crash-safe for any type.
|
||||
if jwt_alg(tok) in ("RS256", "RS384", "RS512", "ES256", "ES384", "ES512",
|
||||
"PS256", "PS384", "PS512", "EdDSA"):
|
||||
return "Bearer " + tok
|
||||
return ""
|
||||
|
||||
|
||||
def server_token(env_names) -> str:
|
||||
"""The first configured server-side gateway key from ``env_names``, as a full
|
||||
``Bearer <tok>`` value, or "". Server-side only — never emitted to page source."""
|
||||
for env in env_names:
|
||||
v = os.environ.get(env, "").strip()
|
||||
if v:
|
||||
return v if v[:7].lower() == "bearer " else "Bearer " + v
|
||||
return ""
|
||||
|
||||
|
||||
def ai_bearer(request, *, server_env) -> str:
|
||||
"""The bearer to send to the gateway for this request, or "" when none is usable.
|
||||
|
||||
A server-side gateway key wins (the configured, working path); otherwise the caller's
|
||||
own gateway-verifiable token via the fail-closed allowlist. Returns ``Bearer <tok>``
|
||||
or "". Callers that need the bare token can strip the 7-char ``Bearer `` prefix."""
|
||||
return server_token(server_env) or forwardable_bearer(request)
|
||||
+210
-17
@@ -8,13 +8,23 @@ shared keys. Falls back to the in-pod queue when there is no live worker.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import folder_paths
|
||||
|
||||
log = logging.getLogger("studio.gpu_dispatch")
|
||||
|
||||
CLOUD = os.environ.get("STUDIO_CLOUD_API_URL", "https://api.hanzo.ai").rstrip("/")
|
||||
JOBS_NS = os.environ.get("STUDIO_GPU_JOBS_NS", "gpu-jobs")
|
||||
|
||||
# The ComfyUI output-node class types — the ONE Python list, defined in this base
|
||||
# module (no studio_home dependency) so studio_home can import it without a cycle.
|
||||
# The client mirror lives in studio_home.html (a separate JS runtime).
|
||||
SAVE_NODES = ("SaveImage", "SaveVideo", "SaveWEBM")
|
||||
# Public base of THIS studio deployment — where the BYO-GPU worker returns finished
|
||||
# outputs (POST /upload/output → orgs/{org}/output → gallery). The worker uploads
|
||||
# here with the user's IAM token; no S3/rclone credentials ever touch the box.
|
||||
@@ -92,11 +102,22 @@ def _node_label(m: dict) -> str:
|
||||
|
||||
def _fleet_machines(tok: str) -> list:
|
||||
"""The caller-org's machines from the cloud fleet. The token scopes the fleet to
|
||||
the caller's org, so this is inherently tenant-isolated."""
|
||||
req = urllib.request.Request(f"{CLOUD}/v1/machines", headers={"Authorization": tok})
|
||||
d = json.loads(urllib.request.urlopen(req, timeout=10).read())
|
||||
ms = d.get("machines", [])
|
||||
return ms if isinstance(ms, list) else []
|
||||
the caller's org, so this is inherently tenant-isolated. Retried a few times so a
|
||||
transient edge fault (502, TLS reset, timeout) does NOT read as "no GPU" and
|
||||
bounce the render onto the model-less pod — the cause of intermittent
|
||||
"Prompt outputs failed validation"."""
|
||||
last = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
req = urllib.request.Request(f"{CLOUD}/v1/machines", headers={"Authorization": tok})
|
||||
d = json.loads(urllib.request.urlopen(req, timeout=10).read())
|
||||
ms = d.get("machines", [])
|
||||
return ms if isinstance(ms, list) else []
|
||||
except Exception as e: # any transient fault (HTTP/TLS/timeout/non-JSON) is retryable
|
||||
last = e
|
||||
if attempt < 2:
|
||||
time.sleep(0.4 * (attempt + 1))
|
||||
raise last
|
||||
|
||||
|
||||
def _online_gpu_nodes(tok: str) -> list:
|
||||
@@ -108,23 +129,173 @@ def _has_online_gpu(tok: str) -> bool:
|
||||
return bool(_online_gpu_nodes(tok))
|
||||
|
||||
|
||||
# Server credentials that read the WHOLE fleet, not just the caller-org's slice like
|
||||
# a user token. KMS-sourced (the SAME envs worker/commerce already use); consulted
|
||||
# ONLY by the advisory capacity gate below — never by a render decision.
|
||||
_SERVICE_TOKEN_ENVS = ("STUDIO_WORKER_TOKEN", "STUDIO_COMMERCE_TOKEN")
|
||||
|
||||
|
||||
def _service_token() -> str:
|
||||
"""A service bearer for a fleet read, or "" if none is configured."""
|
||||
for env in _SERVICE_TOKEN_ENVS:
|
||||
v = os.environ.get(env, "").strip()
|
||||
if v:
|
||||
return v if v.lower().startswith("bearer ") else f"Bearer {v}"
|
||||
return ""
|
||||
|
||||
|
||||
def has_online_gpu_advisory(user_tok: str) -> bool:
|
||||
"""Fleet availability for the UI capacity gate ONLY (the ⚠ warning + the
|
||||
/v1/gpu-status badge) — NEVER a render decision. Reads with a SERVICE identity
|
||||
first so every org sees the TRUE worker availability: a BYO box is often
|
||||
registered under a sibling org and is invisible to an org-scoped user-token read,
|
||||
which made a karma session believe no GPU existed. Falls back to the user token
|
||||
when no service credential is set (local dev). Job tenancy is untouched — dispatch
|
||||
still enqueues under the user token."""
|
||||
for tok in (_service_token(), user_tok):
|
||||
if not tok:
|
||||
continue
|
||||
try:
|
||||
if _has_online_gpu(tok):
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
# Model-loader class → (the widget naming the file, the folder_paths list it lives in).
|
||||
# Covers the loaders the studio graphs use: Qwen fix/compose (UNET+CLIP+VAE) and the
|
||||
# SD-family templates (checkpoint). Enough to answer "can THIS box load THIS graph".
|
||||
_LOADERS = {
|
||||
"UNETLoader": ("unet_name", "diffusion_models"),
|
||||
"CheckpointLoaderSimple": ("ckpt_name", "checkpoints"),
|
||||
"CheckpointLoader": ("ckpt_name", "checkpoints"),
|
||||
"ImageOnlyCheckpointLoader": ("ckpt_name", "checkpoints"),
|
||||
"CLIPLoader": ("clip_name", "text_encoders"),
|
||||
"VAELoader": ("vae_name", "vae"),
|
||||
}
|
||||
|
||||
|
||||
def has_models_for(prompt: dict) -> bool:
|
||||
"""True only if THIS instance has EVERY model file the graph actually references
|
||||
(its UNET / checkpoint / CLIP / VAE loader inputs). The decision is per-GRAPH, not
|
||||
"any model present": a cloud coordinator may carry a stray checkpoint (e.g. an SD1.5
|
||||
stopgap) yet lack the Qwen edit models a fix/compose graph needs — validating THAT
|
||||
locally is the cryptic "not in []" / "Prompt outputs failed validation". So a box
|
||||
renders locally only a graph it can actually load; every other graph goes to the
|
||||
fleet (or a retryable 503). Errs toward the fleet (returns False) on any doubt. A
|
||||
worker node (worker_mode) never reaches this path."""
|
||||
try:
|
||||
for node in (prompt or {}).values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
spec = _LOADERS.get(node.get("class_type", ""))
|
||||
if not spec:
|
||||
continue
|
||||
name = (node.get("inputs") or {}).get(spec[0])
|
||||
if not isinstance(name, str) or not name:
|
||||
continue
|
||||
try:
|
||||
have = folder_paths.get_filename_list(spec[1])
|
||||
except Exception:
|
||||
have = []
|
||||
if name not in have:
|
||||
return False # a model this graph needs is absent → cannot render here
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# Retry ladder across the cloud tasks backend's deploy gap. `/v1/tasks/*` is served
|
||||
# by the single-pod `cloud` binary (strategy Recreate on an RWO SQLite volume), so
|
||||
# every cloud deploy leaves a ~30s window where the ingress (Traefik) has zero Ready
|
||||
# endpoints and returns 503 "no available server" for the enqueue. With CI shipping
|
||||
# cloud continuously, an enqueue can land in that gap and instant-fail the render with
|
||||
# "GPU render worker is momentarily unavailable". The gap is transient and the request
|
||||
# never reached the tasks engine (empty backend pool → Traefik rejects it), so we
|
||||
# retry across it. ~32s total covers a same-node Recreate.
|
||||
_ENQUEUE_RETRY_DELAYS = (2, 4, 6, 8, 12)
|
||||
|
||||
|
||||
def _enqueue_with_retry(req) -> None:
|
||||
"""PUT the enqueue, retrying ONLY on 503 / connection-refused — both mean the
|
||||
request never reached the backend, so re-sending the same idempotent activityId
|
||||
(== prompt_id) cannot double-enqueue. Any other outcome (4xx auth/validation, a
|
||||
timeout that may have been processed) raises to the caller's normal fallback, so
|
||||
we never risk a double render on an ambiguous failure."""
|
||||
last: Exception | None = None
|
||||
for i in range(len(_ENQUEUE_RETRY_DELAYS) + 1):
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=90).read()
|
||||
return
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code != 503:
|
||||
raise # 4xx/other: retrying won't help — fall back
|
||||
last = e # 503 empty-pool: deploy gap, retriable
|
||||
except urllib.error.URLError as e:
|
||||
# Connection refused/reset to the backend during the gap is retriable;
|
||||
# a genuine timeout (request may have been served) is NOT — don't double-send.
|
||||
if isinstance(e.reason, TimeoutError):
|
||||
raise
|
||||
last = e
|
||||
if i < len(_ENQUEUE_RETRY_DELAYS):
|
||||
time.sleep(_ENQUEUE_RETRY_DELAYS[i])
|
||||
if last:
|
||||
raise last
|
||||
|
||||
|
||||
def dispatch_if_worker(request, org_id: str, prompt_id: str, prompt: dict) -> bool:
|
||||
"""If the caller's org has an online GPU node in the fleet, enqueue a
|
||||
studio.render job to gpu-jobs and return True. Return False to run in-pod.
|
||||
Never raises — any failure falls back to local."""
|
||||
"""Enqueue this render to the shared GPU lane (gpu-jobs) and return True; return
|
||||
False only to run it in-pod.
|
||||
|
||||
A model-less cloud coordinator NEVER renders locally — it ALWAYS enqueues and
|
||||
lets the job WAIT for a worker, even when the org-scoped fleet read shows none.
|
||||
BYO workers can be registered under a sibling org while draining the SHARED
|
||||
gpu-jobs queue, so an org-blind read (e.g. an org whose GPUs live under a peer
|
||||
org) must not bounce the render onto the GPU-less pod — that self-POST is what
|
||||
died with an instant "Prompt outputs failed validation". A queued job that waits
|
||||
for a worker beats an instant fake failure. A box WITH its own models still
|
||||
renders locally when it sees no online worker (local dev / a worker node).
|
||||
|
||||
Never raises — a genuine enqueue failure falls back, and server.py then returns a
|
||||
retryable 503 on a model-less pod rather than a doomed local validation."""
|
||||
try:
|
||||
tok = _bearer(request)
|
||||
online = _online_gpu_nodes(tok) if tok else []
|
||||
if not online:
|
||||
if not tok:
|
||||
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"
|
||||
# Per-GPU targeting: the UI's "Run on <gpu>" affordance sets X-Target-GPU to a
|
||||
# machine identity. A targeted render is enqueued on THAT machine's own lane
|
||||
# ("gpu:<identity>") which only that worker claims; an untargeted render rides
|
||||
# the shared "gpu-jobs" lane any worker drains. The NAMESPACE is "gpu-jobs"
|
||||
# either way — targeting is a taskQueue, not a second queue. One enqueue path.
|
||||
target = (request.headers.get("X-Target-GPU") or "").strip()
|
||||
online = _online_gpu_nodes(tok)
|
||||
# Honor a target ONLY if it is one of the caller-org's OWN online GPUs. This
|
||||
# blocks a forged lane AND sanitizes the value before it becomes a taskQueue
|
||||
# (and later a queue-row label): an unknown/offline target falls back to the
|
||||
# shared lane rather than enqueuing onto a "gpu:<bogus>" lane no worker will
|
||||
# ever claim (a 30-min schedule-to-start hang) — and a "<img onerror>" target
|
||||
# never reaches the queue. Cross-org is already impossible (per-org shard); this
|
||||
# is defense in depth.
|
||||
if target and target not in {_node_label(m) for m in online}:
|
||||
target = ""
|
||||
task_queue = f"gpu:{target}" if target else JOBS_NS
|
||||
# No visible worker AND this box can load THIS graph → render locally. No
|
||||
# visible worker on a box that lacks the graph's models → still enqueue and
|
||||
# wait (never a doomed local validation), even with a stray unrelated model.
|
||||
# A validated target ALWAYS enqueues (the user asked for that online GPU).
|
||||
if not target and not online and has_models_for(prompt):
|
||||
return False
|
||||
# Which node runs it: the explicit target; else a single visible GPU is
|
||||
# definitive; with several — or an org-blind read that saw none — the claimer
|
||||
# is decided at pick-up: "gpu".
|
||||
node = target or (_node_label(online[0]) if len(online) == 1 else "gpu")
|
||||
staged = _collect_input_images(prompt, org_id)
|
||||
body = json.dumps({
|
||||
"activityId": prompt_id,
|
||||
"runId": prompt_id,
|
||||
"activityType": {"name": "studio.render"},
|
||||
"taskQueue": JOBS_NS,
|
||||
"taskQueue": task_queue,
|
||||
# 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.
|
||||
@@ -133,6 +304,17 @@ def dispatch_if_worker(request, org_id: str, prompt_id: str, prompt: dict) -> bo
|
||||
# render. Unset, the tasks default (~1h) reaped real renders mid-run and
|
||||
# the queue re-ran them for hours.
|
||||
"startToCloseTimeout": "14400s",
|
||||
# Stale jobs EXPIRE instead of waiting forever for a worker. If no GPU
|
||||
# picks this up within 30 min it is dropped — so a worker outage can't
|
||||
# leave a backlog that silently replays (re-billing old renders) the moment
|
||||
# a worker reconnects. A live user re-runs; nothing lingers unbounded.
|
||||
"scheduleToStartTimeout": "1800s",
|
||||
# ONE attempt, no auto-retry. A GPU render is expensive and often
|
||||
# deterministic — re-running a failed/crashed render (e.g. spark OOM mid-
|
||||
# sample) just re-burns money on the SAME output ("the same reject over and
|
||||
# over"). A failure surfaces to the user, who re-runs deliberately. This is
|
||||
# the cap that the startToCloseTimeout alone did NOT provide.
|
||||
"retryPolicy": {"maximumAttempts": 1},
|
||||
"input": {
|
||||
"prompt": prompt,
|
||||
"org": org_id,
|
||||
@@ -140,7 +322,7 @@ def dispatch_if_worker(request, org_id: str, prompt_id: str, prompt: dict) -> bo
|
||||
# Uploaded inputs live in orgs/{org}/input on THIS pod; the worker
|
||||
# renders on its own disk and cannot read them — ship them along so
|
||||
# LoadImage resolves there.
|
||||
"inputs": _collect_input_images(prompt, org_id),
|
||||
"inputs": staged,
|
||||
},
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
@@ -148,8 +330,19 @@ def dispatch_if_worker(request, org_id: str, prompt_id: str, prompt: dict) -> bo
|
||||
data=body,
|
||||
headers={"Authorization": tok, "Content-Type": "application/json"},
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=15).read()
|
||||
# 90s, not 15s: a fix/compose with a large (e.g. 4k) reference inlines tens of
|
||||
# MB, and 15s wasn't enough to PUT the enqueue body — it timed out, dispatch
|
||||
# fell back, and the model-less pod returned "GPU worker unavailable" on a
|
||||
# perfectly good render. The enqueue itself is quick; this only covers upload.
|
||||
# Retry across the cloud tasks backend's deploy gap (see _enqueue_with_retry).
|
||||
_enqueue_with_retry(req)
|
||||
_track_dispatched(org_id, prompt_id, prompt, node=node)
|
||||
# Correlated by pid (the job id) with studio.home's build/queue lines; names are
|
||||
# content-addressed checksums, so this pins the exact source bytes shipped — no
|
||||
# signed URLs, tokens or image data are logged.
|
||||
log.info("studio.trace gpu.publish org=%s pid=%s node=%s inputs=%s refs=%s",
|
||||
org_id, prompt_id, node, len(staged),
|
||||
",".join(sorted(s["name"] for s in staged)) or None)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -170,7 +363,7 @@ def _track_dispatched(org_id: str, prompt_id: str, prompt: dict, node: str = "gp
|
||||
if not isinstance(n, dict):
|
||||
continue
|
||||
ct = n.get("class_type", "")
|
||||
if ct == "SaveImage":
|
||||
if ct in SAVE_NODES:
|
||||
full_prefix = n.get("inputs", {}).get("filename_prefix") or "render"
|
||||
elif "LoadImage" in ct:
|
||||
im = n.get("inputs", {}).get("image")
|
||||
|
||||
@@ -9,10 +9,13 @@ signing keys are cached. Signature (RS256 by default), ``exp``, ``iss`` and
|
||||
tenancy / rate-limit / billing paths.
|
||||
|
||||
Browser sessions use the standard OIDC Authorization Code flow: an
|
||||
unauthenticated HTML request is redirected to the IAM ``authorize`` endpoint;
|
||||
IAM bounces back to ``/callback`` which exchanges the code for a token and sets
|
||||
an httpOnly session cookie. No frontend changes are required — the prebuilt SPA
|
||||
just rides the cookie.
|
||||
unauthenticated browser navigation is redirected (302) to ``/login`` — the one
|
||||
server-owned entry point — which starts the flow at the IAM ``authorize``
|
||||
endpoint; IAM bounces back to ``/callback`` which exchanges the code for a token
|
||||
and sets an httpOnly session cookie. A programmatic API/XHR/JSON request instead
|
||||
gets a 401 (it handles its own re-auth), so the SPA's ``fetch`` calls never
|
||||
follow a redirect and parse HTML as JSON. No frontend changes are required — the
|
||||
prebuilt SPA just rides the cookie.
|
||||
|
||||
Localhost connections bypass auth (single-user dev). Static assets, health
|
||||
checks and the worker coordinator endpoints are exempt.
|
||||
@@ -94,6 +97,93 @@ def _is_public_path(path: str) -> bool:
|
||||
))
|
||||
|
||||
|
||||
def _is_api_request(request: web.Request) -> bool:
|
||||
"""A programmatic API/XHR/JSON caller (answered with 401) as opposed to a
|
||||
top-level browser navigation (answered with a 302 into the login flow).
|
||||
|
||||
A human who types a URL or follows a link must never be shown a raw 401, so
|
||||
an *ambiguous* non-browser client (plain ``curl``, ``Accept: */*``, no
|
||||
fetch-metadata) counts as a navigation and is redirected. Only a request that
|
||||
self-identifies as fetch/XHR/JSON is refused — otherwise the SPA's own
|
||||
``fetch`` calls would follow the redirect to the IdP and parse HTML as JSON.
|
||||
"""
|
||||
# Fetch-metadata (every evergreen browser stamps it): a user navigation is
|
||||
# ``Sec-Fetch-Mode: navigate``; ``fetch()``/XHR carry cors|same-origin|no-cors.
|
||||
mode = request.headers.get("Sec-Fetch-Mode", "").lower()
|
||||
if mode:
|
||||
return mode != "navigate"
|
||||
# Legacy AJAX marker (jQuery / axios / htmx).
|
||||
if request.headers.get("X-Requested-With", "").lower() == "xmlhttprequest":
|
||||
return True
|
||||
# Content negotiation: an explicit JSON preference with no HTML acceptance.
|
||||
accept = request.headers.get("Accept", "")
|
||||
return "application/json" in accept and "text/html" not in accept
|
||||
|
||||
|
||||
def _safe_next(raw: str | None) -> str:
|
||||
"""A same-origin return path, or ``/``. Rejects absolute URLs, protocol- and
|
||||
backslash-relative forms (open-redirect vectors) and the login route itself
|
||||
(which would loop)."""
|
||||
if not raw or not raw.startswith("/") or raw[:2] in ("//", "/\\"):
|
||||
return "/"
|
||||
if raw == "/login" or raw.startswith(("/login?", "/login/")):
|
||||
return "/"
|
||||
return raw
|
||||
|
||||
|
||||
def _login_redirect(request: web.Request) -> web.Response:
|
||||
"""Send an unauthenticated browser to the ONE server-owned login entry point
|
||||
(``/login``), preserving where it was headed via ``?next`` so the OIDC round
|
||||
trip returns it there. The authorize-URL construction lives in ``/login``
|
||||
(``handle_login``) alone — this gate only decides *who* is sent there.
|
||||
|
||||
FRAMED navigations (the console embeds Studio in an iframe) must NOT be
|
||||
302'd into the OIDC dance: the IdP's login/error pages send
|
||||
``frame-ancestors 'none'`` and the frame dies on a browser error page.
|
||||
Instead the frame gets a tiny same-origin CONNECT card — sign in once
|
||||
top-level (silent when the IdP session exists), the card polls the session
|
||||
and reloads the frame into the real app."""
|
||||
if request.headers.get("Sec-Fetch-Dest", "").lower() in ("iframe", "frame"):
|
||||
return _embed_connect_card(request)
|
||||
return web.HTTPFound("/login?" + urlencode({"next": str(request.rel_url)}))
|
||||
|
||||
|
||||
def _embed_connect_card(request: web.Request) -> web.Response:
|
||||
"""The same-origin sign-in card an unauthenticated FRAME renders (never the
|
||||
IdP — its pages refuse framing). Self-contained inline HTML; the button is
|
||||
the user gesture that opens ``/login`` top-level, and the card polls
|
||||
``/v1/session`` until the session lands, then reloads the frame."""
|
||||
login = "/login?" + urlencode({"next": _safe_next(str(request.rel_url))})
|
||||
html = (
|
||||
"<!doctype html><html><head><meta charset='utf-8'><title>Hanzo Studio</title>"
|
||||
"<style>body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;"
|
||||
"background:#0e0e12;color:#ececf1;font:15px/1.5 Inter,system-ui,sans-serif}"
|
||||
".c{max-width:380px;text-align:center;padding:32px}"
|
||||
"h1{font-size:18px;margin:0 0 8px}p{color:#8a8a95;font-size:13px;margin:0 0 20px}"
|
||||
"button{background:#f4f4f6;color:#111;border:none;border-radius:10px;padding:10px 18px;"
|
||||
"font:600 14px Inter,system-ui,sans-serif;cursor:pointer}</style></head><body>"
|
||||
"<div class='c'><h1>Connect Hanzo Studio</h1>"
|
||||
"<p>Sign in once to use Studio here — with a live Hanzo ID session it completes instantly, "
|
||||
"then this panel loads by itself.</p>"
|
||||
"<button id='go'>Sign in to Studio</button></div>"
|
||||
"<script>document.getElementById('go').onclick=function(){"
|
||||
f"window.open({login!r},'_blank','noopener');"
|
||||
"};setInterval(function(){fetch('/v1/session',{credentials:'same-origin'})"
|
||||
".then(function(r){return r.ok?r.json():null})"
|
||||
".then(function(s){if(s&&s.authenticated){location.reload()}}).catch(function(){})},2000);"
|
||||
"</script></body></html>"
|
||||
)
|
||||
return web.Response(
|
||||
text=html,
|
||||
content_type="text/html",
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
# Only our own consoles may frame this card (same site).
|
||||
"Content-Security-Policy": "frame-ancestors 'self' https://*.hanzo.ai",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _cache_key(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()[:32]
|
||||
|
||||
@@ -339,7 +429,9 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
|
||||
Authorization Code flow (PKCE + signed state + ``/callback``) is built
|
||||
server-side — there is exactly one way to start a login, and the client
|
||||
never hand-rolls an authorize URL. Already-authenticated callers skip
|
||||
straight to the app; everyone else is bounced to IAM and returns to ``/``.
|
||||
straight to the app; everyone else is bounced to IAM and returns to the
|
||||
sanitized ``?next`` target (default ``/``) — this is where the auth gate
|
||||
sends an unauthenticated browser navigation, carrying its original path.
|
||||
"""
|
||||
token = None
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
@@ -353,7 +445,7 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
|
||||
return web.HTTPFound("/")
|
||||
except Exception:
|
||||
pass # IAM/JWKS hiccup — fall through and re-authenticate cleanly.
|
||||
return _authorize_redirect(request, target="/")
|
||||
return _authorize_redirect(request, target=_safe_next(request.query.get("next")))
|
||||
|
||||
async def handle_callback(request: web.Request) -> web.Response:
|
||||
"""Exchange the authorization code for a token and set the session cookie."""
|
||||
@@ -428,12 +520,16 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
|
||||
if not token and path == "/ws":
|
||||
token = request.rel_url.query.get("token")
|
||||
|
||||
wants_html = "text/html" in request.headers.get("Accept", "")
|
||||
# A browser navigation is redirected into the login flow (302 → /login,
|
||||
# carrying ?next); a programmatic API/XHR/JSON caller gets a 401 it can
|
||||
# handle itself. One predicate decides, one way, for both no-token and
|
||||
# bad-token cases below.
|
||||
api = _is_api_request(request)
|
||||
|
||||
if not token:
|
||||
if wants_html:
|
||||
return _authorize_redirect(request)
|
||||
return web.json_response({"error": "Authentication required", "iam_url": iam_url}, status=401)
|
||||
if api:
|
||||
return web.json_response({"error": "Authentication required", "iam_url": iam_url}, status=401)
|
||||
return _login_redirect(request)
|
||||
|
||||
try:
|
||||
user = await _validate(token)
|
||||
@@ -441,9 +537,9 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
|
||||
return web.json_response({"error": "Authentication service unavailable"}, status=503)
|
||||
|
||||
if user is None:
|
||||
if wants_html:
|
||||
return _authorize_redirect(request)
|
||||
return web.json_response({"error": "Invalid or expired token"}, status=401)
|
||||
if api:
|
||||
return web.json_response({"error": "Invalid or expired token"}, status=401)
|
||||
return _login_redirect(request)
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Render quality gate — the ONE guard that keeps corrupted renders out of the library.
|
||||
|
||||
Every render lands through ``POST /v1/library/upload`` → ``studio_home._index_upload``.
|
||||
The moment a NEW asset row is created there, this fires an out-of-band judgment
|
||||
(never blocks the upload response): downscale the just-stored image, ask a cloud
|
||||
vision model to answer PASS/FAIL, and on a clear FAIL move the asset's library
|
||||
status ``draft → flagged`` so it drops out of every default grid — the same
|
||||
visibility semantics as ``deleted``, but recoverable from the UI's Flagged view.
|
||||
|
||||
It FAILS OPEN by construction. A PASS, an inconclusive reply, a 402/429, a timeout,
|
||||
an unreachable judge, an unreadable image — anything that is not an unambiguous
|
||||
FAIL — leaves the render a normal ``draft``. The gate can only ever hide clearly
|
||||
broken work; it can never hide good work by accident, and it never blocks a render
|
||||
from landing.
|
||||
|
||||
Env (all optional):
|
||||
STUDIO_GATE "off"/"0"/"false"/"no" disables the gate entirely. Default on.
|
||||
STUDIO_GATE_MODEL vision model id. Default "zen-vl" (the cloud's VL model).
|
||||
STUDIO_GATE_URL full chat-completions URL override, e.g. a local engine at
|
||||
http://127.0.0.1:1234/v1/chat/completions. Default = the
|
||||
cloud gateway (gpu_dispatch.CLOUD) + /v1/chat/completions.
|
||||
|
||||
One auth path: the judgment carries the SAME bearer the upload carried, so the
|
||||
cloud derives org + billing from it — no shared keys, tenant-isolated like dispatch.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
|
||||
from middleware import worklog
|
||||
from middleware.gpu_dispatch import CLOUD, _bearer
|
||||
|
||||
log = logging.getLogger("studio.gate")
|
||||
|
||||
# The strict binary judge. The model is told to answer EXACTLY PASS or FAIL; the
|
||||
# failure classes are the observed corruption modes of the render pipeline.
|
||||
PROMPT = (
|
||||
"You are a render QA gate for fashion imagery. Look at the image. Answer exactly "
|
||||
"PASS or FAIL. FAIL if: skin texture is corrupted (mottled, blotchy, speckled, "
|
||||
"noise-stippled), the image has ghosting/double-exposure, a frame-within-frame/"
|
||||
"poster border, or glyph/pixel-garbage regions. Otherwise PASS."
|
||||
)
|
||||
|
||||
# The motion judge — the SAME strict binary contract as PROMPT, but the failure classes
|
||||
# are the observed corruption modes of the video pipeline (it is shown start/mid/end frames).
|
||||
MOTION = (
|
||||
"You are a render QA gate for generated video, shown frames sampled across its "
|
||||
"duration. Answer exactly PASS or FAIL. FAIL if: frame-to-frame flicker, "
|
||||
"identity/clothing morphing between frames, frozen/static motion where movement is "
|
||||
"expected, temporal ghosting or trails, or glyph/pixel-garbage regions. Otherwise PASS."
|
||||
)
|
||||
|
||||
_OFF = ("off", "0", "false", "no")
|
||||
_FAIL_RE = re.compile(r"\bfail\b", re.I)
|
||||
_PASS_RE = re.compile(r"\bpass\b", re.I)
|
||||
|
||||
# Keep a reference to in-flight tasks so a fire-and-forget judgment is not
|
||||
# garbage-collected before it runs (per asyncio.ensure_future docs).
|
||||
_TASKS: set = set()
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return os.environ.get("STUDIO_GATE", "").strip().lower() not in _OFF
|
||||
|
||||
|
||||
def _model() -> str:
|
||||
return os.environ.get("STUDIO_GATE_MODEL", "").strip() or "zen-vl"
|
||||
|
||||
|
||||
def _url() -> str:
|
||||
return os.environ.get("STUDIO_GATE_URL", "").strip() or (CLOUD + "/v1/chat/completions")
|
||||
|
||||
|
||||
def parse_verdict(reply: str) -> str:
|
||||
""""pass" | "fail" | "inconclusive" from the judge's reply. Case-insensitive
|
||||
PASS/FAIL as a whole token (so "FAIL — mottled skin" reads as fail but "failure"
|
||||
does not spuriously flag). A reply carrying BOTH tokens, or NEITHER, is
|
||||
inconclusive — and inconclusive fails open."""
|
||||
fails = [m.start() for m in _FAIL_RE.finditer(reply or "")]
|
||||
passes = [m.start() for m in _PASS_RE.finditer(reply or "")]
|
||||
# The LAST token wins: a reasoning judge weighs both words before concluding
|
||||
# ("is this PASS or FAIL... verdict: FAIL") — co-occurrence is deliberation,
|
||||
# position is the verdict.
|
||||
if fails and (not passes or fails[-1] > passes[-1]):
|
||||
return "fail"
|
||||
if passes and (not fails or passes[-1] > fails[-1]):
|
||||
return "pass"
|
||||
return "inconclusive"
|
||||
|
||||
|
||||
def _encode(im) -> str | None:
|
||||
"""A PIL image as a ~512px JPEG, base64 — the ONE frame-encode contract, small
|
||||
enough for a fast vision call. Returns None on any failure."""
|
||||
try:
|
||||
im = im.convert("RGB")
|
||||
im.thumbnail((512, 512))
|
||||
buf = io.BytesIO()
|
||||
im.save(buf, "JPEG", quality=80)
|
||||
return base64.b64encode(buf.getvalue()).decode()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _downscale(path: Path) -> str | None:
|
||||
"""The stored image as a ~512px JPEG, base64. None if it can't be read (→ fail open)."""
|
||||
try:
|
||||
from PIL import Image
|
||||
return _encode(Image.open(path))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _video_frame(path: str, t: float) -> str | None:
|
||||
"""One ~512px JPEG (base64) decoded at ``t`` seconds — seek then take the next frame.
|
||||
A fresh container per frame keeps the decode robust (no abandoned-generator state)."""
|
||||
try:
|
||||
import av
|
||||
with av.open(path) as c:
|
||||
st = c.streams.video[0]
|
||||
if t > 0 and st.time_base:
|
||||
c.seek(int(t / st.time_base), stream=st)
|
||||
for frame in c.decode(st):
|
||||
return _encode(frame.to_image())
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _frames(path: Path) -> list[str] | None:
|
||||
"""Up to three ~512px JPEG frames (base64) to judge, or None when unreadable. A video
|
||||
(.mp4/.webm) is sampled at start / middle / end so the motion judge sees temporal
|
||||
coherence; any other file is the single downscaled image. None → the caller decides
|
||||
(an image fails open; a video is marked ungated, never a silent pass)."""
|
||||
if path.suffix.lower() in (".mp4", ".webm"):
|
||||
try:
|
||||
import av
|
||||
with av.open(str(path)) as probe:
|
||||
st = probe.streams.video[0]
|
||||
if st.duration and st.time_base:
|
||||
dur = float(st.duration * st.time_base)
|
||||
elif probe.duration:
|
||||
dur = float(probe.duration) / 1_000_000
|
||||
else:
|
||||
dur = 0.0
|
||||
targets = [0.0, dur / 2, dur] if dur > 0 else [0.0]
|
||||
out = [b for b in (_video_frame(str(path), t) for t in targets) if b]
|
||||
return out or None
|
||||
except Exception:
|
||||
return None
|
||||
b = _downscale(path)
|
||||
return [b] if b else None
|
||||
|
||||
|
||||
def _reply_text(data: dict) -> str:
|
||||
"""The assistant text from an OpenAI-style completion, tolerating a content
|
||||
string, the content-parts array some gateways return, and REASONING models
|
||||
whose verdict lands in reasoning_content while content comes back null (a
|
||||
tiny max_tokens made zen-vl spend the whole budget thinking — every verdict
|
||||
parsed empty and the gate failed open, silently passing corrupted renders)."""
|
||||
try:
|
||||
msg = data["choices"][0]["message"]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return ""
|
||||
content = msg.get("content") or ""
|
||||
if isinstance(content, list):
|
||||
content = "".join(p.get("text", "") for p in content if isinstance(p, dict))
|
||||
if not str(content).strip():
|
||||
content = msg.get("reasoning_content") or ""
|
||||
return str(content)
|
||||
|
||||
|
||||
async def judge(images: list[str], bearer: str, prompt: str) -> str | None:
|
||||
"""Ask the vision judge to score N frames against ``prompt`` (PROMPT for a still image,
|
||||
MOTION for a video's sampled frames) — one text part + N image parts. Returns its reply
|
||||
text, or None on ANY error (non-200, bad JSON, timeout, unreachable) — caller fails open."""
|
||||
content = [{"type": "text", "text": prompt}]
|
||||
for b64 in images:
|
||||
content.append({"type": "image_url", "image_url": {"url": "data:image/jpeg;base64," + b64}})
|
||||
payload = {
|
||||
"model": _model(),
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
"temperature": 0,
|
||||
"max_tokens": 768, # reasoning models think before the verdict — 8 starved them into fail-open
|
||||
}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if bearer:
|
||||
headers["Authorization"] = bearer
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=45)) as s:
|
||||
async with s.post(_url(), json=payload, headers=headers) as r:
|
||||
if r.status != 200:
|
||||
log.warning("gate: judge HTTP %s", r.status)
|
||||
return None
|
||||
return _reply_text(await r.json())
|
||||
except Exception as e:
|
||||
log.warning("gate: judge unreachable: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _flag(org: str, root: Path, rel: str, reply: str) -> bool:
|
||||
"""Move a still-``draft`` asset to ``flagged`` and record it on its work-log row.
|
||||
Only ``draft`` transitions — a late verdict must never yank a render a human has
|
||||
since approved/published (or one already flagged/deleted). One writer of
|
||||
library.json (studio_home's helpers). Returns True if the asset was flagged."""
|
||||
from middleware import studio_home as sh # lazy: avoid an import cycle
|
||||
lib = sh._load_library(root)
|
||||
for a in lib.get("assets", []):
|
||||
if a.get("path") == rel and a.get("status") == "draft":
|
||||
a["status"] = "flagged"
|
||||
a["updatedAt"] = int(time.time())
|
||||
sh._save_library(root, lib)
|
||||
worklog.add_event(org, rel, "flagged",
|
||||
"quality gate: " + (reply or "").strip()[:80])
|
||||
log.info("gate: FLAGGED %s/%s", org, rel)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _skip(org: str, root: Path, rel: str) -> None:
|
||||
"""A video whose frames could not be sampled is marked visibly UNGATED — NEVER a
|
||||
silent pass: set the asset row's ``gate`` field to ``skipped`` (the card renders an
|
||||
'ungated' badge from it) and log an ``ungated`` work-log event. Same single-writer
|
||||
library pattern as ``_flag``; leaves status untouched."""
|
||||
from middleware import studio_home as sh # lazy: avoid an import cycle
|
||||
lib = sh._load_library(root)
|
||||
hit = False
|
||||
for a in lib.get("assets", []):
|
||||
if a.get("path") == rel:
|
||||
a["gate"] = "skipped"
|
||||
a["updatedAt"] = int(time.time())
|
||||
hit = True
|
||||
if hit:
|
||||
sh._save_library(root, lib)
|
||||
worklog.add_event(org, rel, "ungated", "gate could not sample frames")
|
||||
log.info("gate: UNGATED %s/%s", org, rel)
|
||||
|
||||
|
||||
async def run(org: str, root: Path, rel: str, bearer: str) -> None:
|
||||
"""Judge one just-stored render — a still image against PROMPT, a video against MOTION
|
||||
over its sampled frames — and flag it on a clear FAIL. A video whose frames can't be
|
||||
sampled is marked UNGATED (never a silent pass); an unreadable image fails open. Never
|
||||
raises — a background task that fails open on everything else."""
|
||||
try:
|
||||
p = Path(root) / rel
|
||||
if not p.is_file():
|
||||
return
|
||||
video = p.suffix.lower() in (".mp4", ".webm")
|
||||
frames = _frames(p)
|
||||
if frames is None:
|
||||
if video:
|
||||
_skip(org, root, rel) # honest: an ungated video is badged, never passed silently
|
||||
else:
|
||||
log.info("gate: unreadable %s/%s — pass (fail-open)", org, rel)
|
||||
return
|
||||
reply = await judge(frames, bearer, MOTION if video else PROMPT)
|
||||
verdict = parse_verdict(reply or "")
|
||||
if verdict == "fail":
|
||||
_flag(org, root, rel, reply or "")
|
||||
else:
|
||||
log.info("gate: %s %s/%s", verdict, org, rel)
|
||||
except Exception as e: # a background task must never surface an exception
|
||||
log.info("gate: error %s/%s: %s — pass (fail-open)", org, rel, e)
|
||||
|
||||
|
||||
def schedule(request, org: str, root: Path, rel: str) -> None:
|
||||
"""Fire the judgment out-of-band. Captures the caller's bearer NOW (the request
|
||||
is gone by the time the task runs) and schedules ``run`` on the event loop.
|
||||
Never blocks the upload response; never raises."""
|
||||
if not enabled():
|
||||
return
|
||||
try:
|
||||
bearer = _bearer(request)
|
||||
task = asyncio.ensure_future(run(org, root, rel, bearer))
|
||||
_TASKS.add(task)
|
||||
task.add_done_callback(_TASKS.discard)
|
||||
except Exception as e:
|
||||
log.info("gate: schedule failed %s/%s: %s", org, rel, e)
|
||||
+58
-21
@@ -6,46 +6,58 @@
|
||||
flex-wrap:wrap;justify-content:flex-end;max-width:calc(100vw - 36px);
|
||||
font:400 14px/1.4 Inter,system-ui,-apple-system,sans-serif}
|
||||
#hzbar button,#hzbar a{font:inherit}
|
||||
.hz-pill{display:inline-flex;align-items:center;gap:7px;background:#131317;color:#ececf1;border:1px solid #232329;
|
||||
.hz-pill{display:inline-flex;align-items:center;gap:7px;background:#1a1a1a;color:#ededed;border:1px solid #1f1f1f;
|
||||
border-radius:999px;padding:7px 13px;cursor:pointer;text-decoration:none;white-space:nowrap}
|
||||
.hz-pill:hover{border-color:#3a3a48}
|
||||
.hz-pill:hover{border-color:#333333}
|
||||
.hz-pill .hz-dot{width:8px;height:8px;border-radius:50%;background:#555;flex:0 0 auto}
|
||||
.hz-pill.on .hz-dot{background:#3ddc84}
|
||||
.hz-pill.hz-chat{background:#f4f4f6;color:#111;border-color:#f4f4f6;font-weight:600}
|
||||
.hz-topup{color:#6aa5ff;text-decoration:none;font-size:.82em;border-left:1px solid #232329;padding-left:7px}
|
||||
.hz-pill.hz-chat{background:#ffffff;color:#111;border-color:#ffffff;font-weight:600}
|
||||
.hz-topup{color:#888888;text-decoration:none;font-size:.82em;border-left:1px solid #1f1f1f;padding-left:7px}
|
||||
.hz-topup:hover{text-decoration:underline}
|
||||
/* right-docked chat sidebar (both views) */
|
||||
#hzchat{position:fixed;top:0;right:0;height:100vh;width:380px;max-width:94vw;background:#0e0e12;color:#ececf1;
|
||||
border-left:1px solid #232329;z-index:2147483001;display:flex;flex-direction:column;
|
||||
#hzchat{position:fixed;top:0;right:0;height:100vh;width:380px;max-width:94vw;background:#0a0a0a;color:#ededed;
|
||||
border-left:1px solid #1f1f1f;z-index:2147483001;display:flex;flex-direction:column;
|
||||
transform:translateX(100%);transition:transform .22s ease;box-shadow:-14px 0 44px #0007}
|
||||
#hzchat.on{transform:translateX(0)}
|
||||
#hzchat .hz-h{display:flex;align-items:center;gap:9px;padding:13px 15px;border-bottom:1px solid #232329;flex-wrap:wrap}
|
||||
#hzchat .hz-h .hz-av{width:26px;height:26px;border-radius:50%;background:#26262e;display:flex;align-items:center;
|
||||
#hzchat .hz-h{display:flex;align-items:center;gap:9px;padding:13px 15px;border-bottom:1px solid #1f1f1f;flex-wrap:wrap}
|
||||
#hzchat .hz-h .hz-av{width:26px;height:26px;border-radius:50%;background:#242424;display:flex;align-items:center;
|
||||
justify-content:center;font-size:.72rem;font-weight:600;flex:0 0 auto}
|
||||
#hzchat .hz-h .hz-who{font-size:.82rem;min-width:0}
|
||||
#hzchat .hz-h .hz-who b{display:block;font-size:.86rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
#hzchat .hz-h .hz-who span{color:#8a8a95;font-size:.72rem}
|
||||
#hzchat .hz-h .hz-x{margin-left:auto;cursor:pointer;color:#8a8a95;font-size:1.3rem;line-height:1;background:none;border:none}
|
||||
#hzchat .hz-h .hz-who span{color:#888888;font-size:.72rem}
|
||||
#hzchat .hz-h .hz-x{margin-left:auto;cursor:pointer;color:#888888;font-size:1.3rem;line-height:1;background:none;border:none}
|
||||
#hzmsgs{flex:1;overflow:auto;padding:14px;display:flex;flex-direction:column;gap:10px}
|
||||
.hz-m{max-width:88%;padding:9px 12px;border-radius:12px;font-size:.86rem;line-height:1.45;white-space:pre-wrap;word-break:break-word}
|
||||
.hz-m.u{align-self:flex-end;background:#f4f4f6;color:#111;border-bottom-right-radius:3px}
|
||||
.hz-m.a{align-self:flex-start;background:#1a1a20;border:1px solid #232329;border-bottom-left-radius:3px}
|
||||
.hz-m.sys{align-self:center;color:#8a8a95;font-size:.78rem;background:none}
|
||||
#hzform{display:flex;gap:8px;padding:12px;border-top:1px solid #232329}
|
||||
#hzform input{flex:1;background:#0a0a0d;border:1px solid #232329;border-radius:9px;color:#ececf1;padding:9px 11px;font:inherit;font-size:.85rem;outline:none}
|
||||
#hzform button{background:#f4f4f6;color:#111;border:none;border-radius:9px;padding:0 14px;font-weight:600;cursor:pointer}
|
||||
.hz-m.u{align-self:flex-end;background:#ffffff;color:#111;border-bottom-right-radius:3px}
|
||||
.hz-m.a{align-self:flex-start;background:#1a1a1a;border:1px solid #1f1f1f;border-bottom-left-radius:3px}
|
||||
.hz-m.sys{align-self:center;color:#888888;font-size:.78rem;background:none}
|
||||
/* chat empty state — assistant identity + one line + 3 tappable starters */
|
||||
.hz-empty{display:flex;flex-direction:column;align-items:center;text-align:center;gap:6px;margin:auto 0;padding:8px 6px}
|
||||
.hz-empty .hz-eav{width:40px;height:40px;border-radius:50%;background:#1a1a1a;border:1px solid #1f1f1f;display:flex;align-items:center;justify-content:center;font-size:1.1rem;color:#ededed}
|
||||
.hz-empty .hz-etitle{font-size:.95rem;font-weight:600;color:#ededed}
|
||||
.hz-empty .hz-esub{font-size:.8rem;color:#888888;line-height:1.45;max-width:280px}
|
||||
.hz-empty .hz-starters{display:flex;flex-direction:column;gap:7px;width:100%;margin-top:8px}
|
||||
.hz-empty .hz-starter{background:#1a1a1a;color:#ededed;border:1px solid #1f1f1f;border-radius:10px;padding:9px 12px;font:inherit;font-size:.82rem;text-align:left;cursor:pointer}
|
||||
.hz-empty .hz-starter:hover{border-color:#333333;background:#171717}
|
||||
#hzform{display:flex;gap:8px;padding:12px;border-top:1px solid #1f1f1f}
|
||||
#hzform input{flex:1;background:#0a0a0a;border:1px solid #1f1f1f;border-radius:9px;color:#ededed;padding:9px 11px;font:inherit;font-size:.85rem;outline:none}
|
||||
#hzform button{background:#ffffff;color:#111;border:none;border-radius:9px;padding:0 14px;font-weight:600;cursor:pointer}
|
||||
#hzchat .hz-pw{padding:8px 12px 11px;text-align:center;color:#666666;font-size:.68rem;letter-spacing:.02em;border-top:1px solid #171717}
|
||||
#hzchat .hz-pw b{color:#888888;font-weight:600}
|
||||
.hz-m.a.streaming::after{content:"▍";color:#888888;margin-left:1px;animation:hzblink 1s steps(1) infinite}
|
||||
@keyframes hzblink{50%{opacity:0}}
|
||||
/* GPUs panel (both views) */
|
||||
#hzgpus{position:fixed;bottom:64px;right:18px;z-index:2147483001;width:320px;max-width:92vw;background:#131317;
|
||||
color:#ececf1;border:1px solid #232329;border-radius:12px;padding:12px 14px;display:none;box-shadow:0 18px 50px #0009}
|
||||
#hzgpus{position:fixed;bottom:64px;right:18px;z-index:2147483001;width:320px;max-width:92vw;background:#1a1a1a;
|
||||
color:#ededed;border:1px solid #1f1f1f;border-radius:12px;padding:12px 14px;display:none;box-shadow:0 18px 50px #0009}
|
||||
#hzgpus.on{display:block}
|
||||
#hzgpus h4{font-size:.82rem;font-weight:600;margin:0 0 8px;display:flex;align-items:center;gap:7px}
|
||||
.hz-node{display:flex;align-items:flex-start;gap:9px;padding:7px 0;border-top:1px solid #1e1e24;font-size:.8rem}
|
||||
.hz-node{display:flex;align-items:flex-start;gap:9px;padding:7px 0;border-top:1px solid #1f1f1f;font-size:.8rem}
|
||||
.hz-node:first-of-type{border-top:none}
|
||||
.hz-node .hz-dot{width:8px;height:8px;border-radius:50%;background:#555;margin-top:5px;flex:0 0 auto}
|
||||
.hz-node.on .hz-dot{background:#3ddc84}
|
||||
.hz-node .hz-nn{flex:1;min-width:0}
|
||||
.hz-node .hz-nn b{font-weight:500}
|
||||
.hz-node .hz-nsub{color:#8a8a95;font-size:.72rem;margin-top:2px}
|
||||
.hz-node .hz-nsub{color:#888888;font-size:.72rem;margin-top:2px}
|
||||
/* Tablet: a touch narrower sidebar; the pill bar + panels already fit. */
|
||||
@media (max-width:900px){#hzchat{width:340px}}
|
||||
/* Phone: chat becomes a full-width bottom sheet (same pattern the old chatpanel
|
||||
@@ -56,8 +68,33 @@
|
||||
.hz-pill{padding:7px 11px}
|
||||
.hz-pill .hz-lbl{display:none} /* icon-only Chat/GPUs → one line, clears content */
|
||||
#hzchat{top:auto;bottom:0;width:100vw;max-width:100vw;height:85vh;
|
||||
border-left:none;border-top:1px solid #232329;border-radius:16px 16px 0 0;
|
||||
border-left:none;border-top:1px solid #1f1f1f;border-radius:16px 16px 0 0;
|
||||
transform:translateY(100%);box-shadow:0 -14px 44px #0007}
|
||||
#hzchat.on{transform:translateY(0)}
|
||||
#hzgpus{left:12px;right:12px;width:auto;bottom:66px}
|
||||
}
|
||||
/* Developers workbench — the bottom drawer (Queue · Logs · Shell), both views.
|
||||
Sits UNDER the pill bar's z so the pills stay clickable above it. */
|
||||
#hzwb{position:fixed;left:0;right:0;bottom:0;height:44vh;min-height:260px;background:#0a0a0a;color:#ededed;
|
||||
border-top:1px solid #1f1f1f;z-index:2147482999;display:none;flex-direction:column;
|
||||
box-shadow:0 -14px 44px #0007;font:400 13px/1.45 Inter,system-ui,-apple-system,sans-serif}
|
||||
#hzwb.on{display:flex}
|
||||
.hzwb-h{display:flex;align-items:center;gap:4px;padding:7px 10px;border-bottom:1px solid #1f1f1f;flex:0 0 auto}
|
||||
.hzwb-tab{background:none;border:none;color:#888888;font:inherit;font-weight:600;padding:6px 11px;border-radius:8px;cursor:pointer}
|
||||
.hzwb-tab:hover{color:#ededed}
|
||||
.hzwb-tab.on{background:#1a1a1a;color:#ededed}
|
||||
.hzwb-sp{flex:1}
|
||||
.hzwb-ic{background:none;border:none;color:#888888;font:inherit;font-size:1.05rem;line-height:1;padding:5px 9px;cursor:pointer;border-radius:8px}
|
||||
.hzwb-ic:hover{color:#ededed;background:#1a1a1a}
|
||||
.hzwb-b{flex:1;min-height:0;overflow:auto;padding:12px 14px}
|
||||
.hzwb-row{padding:2px 0}
|
||||
.hzwb-dim{color:#888888}
|
||||
.hzwb-cmd{color:#a3a3a3;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.8rem;margin-top:8px}
|
||||
.hzwb-out{color:#ededed;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.78rem;
|
||||
white-space:pre-wrap;word-break:break-word;margin:4px 0 0;padding:0}
|
||||
.hzwb-out.err{color:#ff7a70}
|
||||
#hzwbform{display:flex;align-items:center;gap:8px;padding:9px 14px;border-top:1px solid #1f1f1f;flex:0 0 auto}
|
||||
#hzwbform .hzwb-ps{color:#888888;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
||||
#hzwbform input{flex:1;background:#0a0a0a;border:1px solid #1f1f1f;border-radius:9px;color:#ededed;padding:8px 11px;
|
||||
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.8rem;outline:none}
|
||||
@media (max-width:600px){#hzwb{height:70vh}}
|
||||
|
||||
+262
-9
@@ -16,6 +16,19 @@
|
||||
window.__hzShell = 1;
|
||||
|
||||
var STUDIO = location.pathname === "/studio" || location.pathname === "/studio/";
|
||||
// Hanzo AI chat config, injected server-side as window.HZ. `pk` is a PUBLISHABLE
|
||||
// key (NEXT_PUBLIC_HANZO_PUBLISHABLE_KEY) — safe in the client, scoped to chat
|
||||
// completions; NEVER a server secret. Empty pk ⇒ the secure same-origin copilot
|
||||
// path (cookie-authed) is used instead. `model` follows the composer's pick
|
||||
// (hz_model) so the Chat sidebar and the composer share one model.
|
||||
var HZ = window.HZ || {};
|
||||
var AI_BASE = (HZ.aiBase || "https://api.hanzo.ai").replace(/\/+$/, "");
|
||||
var PK = HZ.pk || "";
|
||||
var USE_STREAM = STUDIO && !!PK; // direct enso streaming on the home; the editor keeps the copilot (graph ops)
|
||||
// The Chat model is always an ASSISTANT model. The composer stores its CREATE model
|
||||
// separately (hz_create_model), so an image pick can never leak here; and a stale
|
||||
// hz_model that isn't a chat model is ignored (falls back to the enso assistant).
|
||||
function chatModel() { try { var m = localStorage.hz_model; if (m && /^(enso|zen5|zen-vl)/.test(m)) return m; } catch (_) {} return HZ.model || "enso"; }
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
var el = function (tag, attrs, html) {
|
||||
var e = document.createElement(tag);
|
||||
@@ -38,8 +51,10 @@
|
||||
var wallet = el("a", { class: "hz-pill", id: "hzwallet", href: "https://pay.hanzo.ai", target: "_blank", rel: "noopener",
|
||||
title: "Wallet — top up at pay.hanzo.ai" }, "wallet …");
|
||||
var gpus = el("button", { class: "hz-pill", id: "hzgpubtn", title: "GPUs" }, '<span class="hz-dot"></span><span class="hz-lbl">GPUs</span>');
|
||||
var chatBtn = el("button", { class: "hz-pill hz-chat", title: "Chat" }, '💬<span class="hz-lbl"> Chat</span>');
|
||||
bar.appendChild(toggle);
|
||||
var chatBtn = el("button", { class: "hz-pill hz-chat", title: "Chat" }, '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg><span class="hz-lbl"> Chat</span>');
|
||||
var devBtn = el("button", { class: "hz-pill", id: "hzdevbtn", title: "Developers workbench — queue, logs, and a read-only API shell" },
|
||||
'>_<span class="hz-lbl"> Developers</span>');
|
||||
bar.appendChild(devBtn); bar.appendChild(toggle);
|
||||
if (tmplBtn) bar.appendChild(tmplBtn);
|
||||
bar.appendChild(wallet); bar.appendChild(gpus); bar.appendChild(chatBtn);
|
||||
|
||||
@@ -50,25 +65,58 @@
|
||||
'<span class="hz-who"><b id="hzuser">…</b><span id="hzorg"></span></span>' +
|
||||
'<button class="hz-x" id="hzclose" title="Close">×</button></div>' +
|
||||
'<div id="hzmsgs"></div>' +
|
||||
'<form id="hzform"><input id="hzin" placeholder="Ask the copilot…" autocomplete="off"><button type="submit">Send</button></form>';
|
||||
'<form id="hzform"><input id="hzin" placeholder="Ask Hanzo AI…" autocomplete="off"><button type="submit">Send</button></form>' +
|
||||
'<div class="hz-pw">Powered by <b>Hanzo AI</b></div>';
|
||||
|
||||
function mount() {
|
||||
document.body.appendChild(bar);
|
||||
document.body.appendChild(gpuPanel);
|
||||
document.body.appendChild(chat);
|
||||
wire();
|
||||
document.body.appendChild(wb);
|
||||
wire(); wireWb();
|
||||
setDev(devOn()); // developer controls (the composer's </>) follow this ONE flag
|
||||
loadSession(); loadWallet(); loadGpus();
|
||||
if (!STUDIO) editorBoot();
|
||||
}
|
||||
// Developers mode — a persistent, page-spanning flag. `body.hzdev` reveals dev-only
|
||||
// controls (e.g. the composer's </> request preview) via CSS; opening the Developers
|
||||
// workbench turns it on. One flag, no per-control JS coupling.
|
||||
function devOn() { try { return localStorage.hz_dev === "1"; } catch (_) { return false; } }
|
||||
function setDev(on) { try { if (on) localStorage.hz_dev = "1"; } catch (_) {} if (document.body) document.body.classList.toggle("hzdev", !!on); }
|
||||
|
||||
// ── chat: the ONE path — POST /v1/copilot/chat; ops applied via the editor's
|
||||
// existing applier when present (no second chat mechanism) ────────────────────
|
||||
var HISTORY = [];
|
||||
function addMsg(role, text) {
|
||||
var e = $("hzmsgs").querySelector(".hz-empty"); if (e) e.remove(); // first message clears the welcome
|
||||
var m = el("div", { class: "hz-m " + role }, esc(text));
|
||||
$("hzmsgs").appendChild(m); $("hzmsgs").scrollTop = $("hzmsgs").scrollHeight;
|
||||
return m;
|
||||
}
|
||||
// Empty state: who the assistant is, one line on what it does, and 3 tappable starters —
|
||||
// so an opened, empty chat is never a blank box. Editor vs home get fitting starters.
|
||||
function chatStarters() {
|
||||
return STUDIO
|
||||
? ["A poster for a jazz night", "Ideas for a product launch image", "How do I make a logo transparent?"]
|
||||
: ["What does this workflow do?", "Add a 4x upscale to the output", "Make the image sharper"];
|
||||
}
|
||||
function chatEmptyState() {
|
||||
var box = $("hzmsgs"); if (!box || HISTORY.length || box.querySelector(".hz-empty")) return;
|
||||
var wrap = el("div", { class: "hz-empty" });
|
||||
wrap.innerHTML =
|
||||
'<div class="hz-eav">✦</div><div class="hz-etitle">Hanzo Assistant</div>' +
|
||||
'<div class="hz-esub">' + (STUDIO
|
||||
? "Brainstorm ideas, sharpen a prompt, or ask how to make something."
|
||||
: "Ask me to change your workflow, or how any node works.") + '</div>' +
|
||||
'<div class="hz-starters">' + chatStarters().map(function (s) {
|
||||
return '<button class="hz-starter" type="button">' + esc(s) + "</button>";
|
||||
}).join("") + "</div>";
|
||||
box.appendChild(wrap);
|
||||
wrap.querySelectorAll(".hz-starter").forEach(function (b) {
|
||||
b.onclick = function () { $("hzin").value = ""; send(b.textContent).catch(function () {}); };
|
||||
});
|
||||
}
|
||||
function openChat() { chat.classList.add("on"); if (!HISTORY.length) chatEmptyState(); var i = $("hzin"); if (i) i.focus(); }
|
||||
async function graphContext() {
|
||||
var app = window.app;
|
||||
if (!app || typeof app.graphToPrompt !== "function") return {};
|
||||
@@ -79,15 +127,84 @@
|
||||
return ctx;
|
||||
} catch (_) { return {}; }
|
||||
}
|
||||
async function send(text) {
|
||||
// The ONE chat send. Home + a publishable key ⇒ stream enso from the gateway
|
||||
// (api.hanzo.ai/v1/chat/completions, OpenAI-compatible SSE). Otherwise the secure
|
||||
// same-origin copilot path (cookie-authed; also carries graph ops in the editor).
|
||||
// Same transcript UI either way.
|
||||
function send(text) {
|
||||
HISTORY.push({ role: "user", content: text });
|
||||
addMsg("u", text);
|
||||
return USE_STREAM ? sendStream() : sendCopilot();
|
||||
}
|
||||
|
||||
// Pure SSE helpers (no DOM) — decomplected so the stream loop and the unit tests
|
||||
// share ONE parser. `sseLines()` returns a stateful splitter that holds a partial
|
||||
// line across chunk boundaries; `sseDelta(line)` maps one line to its content
|
||||
// delta (or null for keep-alives, `[DONE]`, non-data lines, or malformed JSON).
|
||||
function sseLines() {
|
||||
var buf = "";
|
||||
return function (chunk) {
|
||||
buf += chunk;
|
||||
var out = [], nl;
|
||||
while ((nl = buf.indexOf("\n")) >= 0) { out.push(buf.slice(0, nl)); buf = buf.slice(nl + 1); }
|
||||
return out;
|
||||
};
|
||||
}
|
||||
function sseDelta(line) {
|
||||
line = line.replace(/\r$/, "").trim();
|
||||
if (line.slice(0, 5) !== "data:") return null;
|
||||
var d = line.slice(5).trim();
|
||||
if (!d || d === "[DONE]") return null;
|
||||
try {
|
||||
var j = JSON.parse(d);
|
||||
var delta = j.choices && j.choices[0] && j.choices[0].delta && j.choices[0].delta.content;
|
||||
return delta || null;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
// Home + publishable key: stream enso from the gateway. On a TRANSPORT failure
|
||||
// (fetch throws, non-2xx, or no body — e.g. a PK provisioned before the gateway
|
||||
// allows CORS/Authorization for this origin) degrade to the same-origin copilot
|
||||
// instead of dead-ending, so Chat never hard-breaks. A partial stream that drops
|
||||
// mid-flight keeps whatever text already arrived.
|
||||
async function sendStream() {
|
||||
var box = addMsg("a", ""); box.classList.add("streaming");
|
||||
var acc = "";
|
||||
try {
|
||||
var res = await fetch(AI_BASE + "/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Authorization": "Bearer " + PK },
|
||||
body: JSON.stringify({ model: chatModel(), messages: HISTORY.slice(-20), stream: true }),
|
||||
});
|
||||
if (!res.ok || !res.body) { box.remove(); return sendCopilot(); }
|
||||
var reader = res.body.getReader(), dec = new TextDecoder(), feed = sseLines();
|
||||
for (;;) {
|
||||
var rd = await reader.read(); if (rd.done) break;
|
||||
var lines = feed(dec.decode(rd.value, { stream: true }));
|
||||
for (var k = 0; k < lines.length; k++) {
|
||||
var delta = sseDelta(lines[k]);
|
||||
if (delta) { acc += delta; box.textContent = acc; $("hzmsgs").scrollTop = $("hzmsgs").scrollHeight; }
|
||||
}
|
||||
}
|
||||
box.classList.remove("streaming");
|
||||
if (acc) HISTORY.push({ role: "assistant", content: acc }); else box.textContent = "(no response)";
|
||||
} catch (e) {
|
||||
box.classList.remove("streaming");
|
||||
if (acc) HISTORY.push({ role: "assistant", content: acc });
|
||||
else { box.remove(); return sendCopilot(); }
|
||||
}
|
||||
}
|
||||
|
||||
async function sendCopilot() {
|
||||
var pending = addMsg("sys", "…");
|
||||
var payload = Object.assign({ messages: HISTORY.slice(-20) }, await graphContext());
|
||||
var payload = Object.assign({ model: chatModel(), messages: HISTORY.slice(-20) }, await graphContext());
|
||||
var r = await getJSON("/v1/copilot/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
|
||||
pending.remove();
|
||||
var reply = (r.body && r.body.reply) || (r.body && r.body.error) || (r.ok ? "" : "chat failed (" + r.status + ")");
|
||||
// Only ever render the model's reply. A backend error (e.g. the gateway unreachable)
|
||||
// becomes ONE friendly line — never the raw error string or JSON in a chat bubble.
|
||||
var reply = r.body && r.body.reply;
|
||||
if (reply) { HISTORY.push({ role: "assistant", content: reply }); addMsg("a", reply); }
|
||||
else if (!r.ok) { addMsg("sys", "Hanzo AI is unavailable right now — please try again in a moment."); }
|
||||
var ops = (r.body && r.body.ops) || [];
|
||||
if (ops.length && window.HanzoCopilot && typeof window.HanzoCopilot.applyOps === "function") {
|
||||
try { window.HanzoCopilot.applyOps(ops); } catch (e) { addMsg("sys", "couldn't apply changes: " + e.message); }
|
||||
@@ -96,8 +213,129 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Developers workbench: a bottom drawer (Queue · Logs · Shell), both views ──
|
||||
// The same Stripe-Workbench footer-dock pattern the Cloud console ships: real
|
||||
// queue/log data or an honest empty, and a READ-ONLY same-origin API shell
|
||||
// (GET only — mutations belong in the product UI). Guarded: failures render as
|
||||
// output lines, never throw.
|
||||
var wb = el("div", { id: "hzwb" });
|
||||
wb.innerHTML =
|
||||
'<div class="hzwb-h">' +
|
||||
'<button class="hzwb-tab on" data-t="queue">Queue</button>' +
|
||||
'<button class="hzwb-tab" data-t="logs">Logs</button>' +
|
||||
'<button class="hzwb-tab" data-t="shell">Shell</button>' +
|
||||
'<span class="hzwb-sp"></span>' +
|
||||
'<button class="hzwb-ic" id="hzwbre" title="Refresh">⟳</button>' +
|
||||
'<button class="hzwb-ic" id="hzwbx" title="Close">×</button></div>' +
|
||||
'<div class="hzwb-b" id="hzwbbody"></div>' +
|
||||
'<form id="hzwbform" hidden><span class="hzwb-ps">$</span>' +
|
||||
'<input id="hzwbin" placeholder="GET /api/queue — read-only, same origin" autocomplete="off" spellcheck="false"></form>';
|
||||
var WBTAB = "queue";
|
||||
function wbOpen(t) { if (t) WBTAB = t; wb.classList.add("on"); wbRender(); }
|
||||
function wbClose() { wb.classList.remove("on"); }
|
||||
function wbEsc(s, max) {
|
||||
s = String(s == null ? "" : s);
|
||||
if (max && s.length > max) s = s.slice(0, max) + "\n… (" + (s.length - max) + " more characters truncated)";
|
||||
return esc(s);
|
||||
}
|
||||
// GET-only same-origin command: `GET /api/queue` | `/v1/nodes` | `internal/logs/raw`.
|
||||
function wbParse(line) {
|
||||
var w = line.trim().split(/\s+/).filter(Boolean);
|
||||
if (!w.length) return { error: "Enter a path — e.g. GET /api/queue" };
|
||||
var m = w[0].toUpperCase();
|
||||
if (/^(POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$/.test(m)) return { error: "The workbench shell is read-only — only GET is supported." };
|
||||
if (m === "GET") w = w.slice(1);
|
||||
if (w.length !== 1) return { error: "One path per command — e.g. GET /api/queue" };
|
||||
var p = w[0];
|
||||
if (/^([a-z][a-z0-9+.-]*:)?\/\//i.test(p)) return { error: "Same-origin paths only — /api, /v1 or /internal." };
|
||||
p = p.replace(/^\/+/, "");
|
||||
if (!/^(api|v1|internal)\//.test(p) || /\.\.|\/\//.test(p) || !/^[A-Za-z0-9\-_./?=&%,:+]+$/.test(p)) {
|
||||
return { error: "Same-origin paths only — /api, /v1 or /internal." };
|
||||
}
|
||||
return { path: "/" + p };
|
||||
}
|
||||
async function wbRun(line) {
|
||||
var out = $("hzwbbody");
|
||||
var cmd = el("div", { class: "hzwb-cmd" }, "$ " + esc(line));
|
||||
out.appendChild(cmd);
|
||||
var parsed = wbParse(line);
|
||||
var res = el("pre", { class: "hzwb-out" });
|
||||
if (parsed.error) { res.classList.add("err"); res.textContent = parsed.error; }
|
||||
else {
|
||||
try {
|
||||
var r = await fetch(parsed.path, { credentials: "same-origin" });
|
||||
var text = await r.text();
|
||||
try { text = JSON.stringify(JSON.parse(text), null, 2); } catch (_) {}
|
||||
if (!r.ok) { res.classList.add("err"); res.innerHTML = wbEsc("HTTP " + r.status + "\n" + text, 4000); }
|
||||
else res.innerHTML = wbEsc(text, 20000);
|
||||
} catch (e) { res.classList.add("err"); res.textContent = "request failed: " + e.message; }
|
||||
}
|
||||
out.appendChild(res); out.scrollTop = out.scrollHeight;
|
||||
}
|
||||
async function wbQueue() {
|
||||
var out = $("hzwbbody");
|
||||
out.innerHTML = '<div class="hzwb-dim">Loading queue…</div>';
|
||||
var q = await getJSON("/api/queue"), g = await getJSON("/v1/render-queue"), h = await getJSON("/api/history?max_items=8");
|
||||
var run = (q.body && q.body.queue_running || []).length, pen = (q.body && q.body.queue_pending || []).length;
|
||||
var jobs = (g.body && g.body.jobs) || [];
|
||||
var hist = h.body && typeof h.body === "object" ? Object.keys(h.body) : [];
|
||||
var html = '<div class="hzwb-row"><b>Local engine</b> — ' + run + " running · " + pen + " pending" +
|
||||
' · <b>GPU jobs</b> — ' + jobs.length + "</div>";
|
||||
jobs.slice(0, 8).forEach(function (j) {
|
||||
html += '<div class="hzwb-row hzwb-dim">' + esc((j.status || "queued") + " · " + (j.node || j.identity || "gpu") + " · " + String(j.prompt || j.kind || j.id || "").slice(0, 60)) + "</div>";
|
||||
});
|
||||
if (hist.length) {
|
||||
html += '<div class="hzwb-row" style="margin-top:8px"><b>Recent history</b></div>';
|
||||
hist.slice(-8).reverse().forEach(function (k) {
|
||||
var it = h.body[k] || {}, st = (it.status && (it.status.status_str || (it.status.completed ? "success" : ""))) || "—";
|
||||
var outs = it.outputs ? Object.keys(it.outputs).length : 0;
|
||||
html += '<div class="hzwb-row hzwb-dim">' + esc(k.slice(0, 8) + " · " + st + " · " + outs + " output node" + (outs === 1 ? "" : "s")) + "</div>";
|
||||
});
|
||||
}
|
||||
if (!run && !pen && !jobs.length && !hist.length) html += '<div class="hzwb-dim">Queue is empty — nothing has run yet.</div>';
|
||||
out.innerHTML = html;
|
||||
}
|
||||
async function wbLogs() {
|
||||
var out = $("hzwbbody");
|
||||
out.innerHTML = '<div class="hzwb-dim">Loading logs…</div>';
|
||||
var r = await getJSON("/internal/logs/raw");
|
||||
var entries = (r.body && r.body.entries) || [];
|
||||
if (!r.ok) { out.innerHTML = '<div class="hzwb-dim">Logs unavailable (HTTP ' + r.status + ").</div>"; return; }
|
||||
if (!entries.length) { out.innerHTML = '<div class="hzwb-dim">No log lines yet.</div>'; return; }
|
||||
out.innerHTML = '<pre class="hzwb-out">' + entries.slice(-250).map(function (e2) {
|
||||
return wbEsc(String(e2.t || "").slice(11, 19) + " " + String(e2.m || "").replace(/\s+$/, ""));
|
||||
}).join("\n") + "</pre>";
|
||||
out.scrollTop = out.scrollHeight;
|
||||
}
|
||||
function wbRender() {
|
||||
wb.querySelectorAll(".hzwb-tab").forEach(function (b) { b.classList.toggle("on", b.dataset.t === WBTAB); });
|
||||
$("hzwbform").hidden = WBTAB !== "shell";
|
||||
$("hzwbre").hidden = WBTAB === "shell";
|
||||
if (WBTAB === "queue") wbQueue().catch(function () {});
|
||||
else if (WBTAB === "logs") wbLogs().catch(function () {});
|
||||
else {
|
||||
var out = $("hzwbbody");
|
||||
if (!out.querySelector(".hzwb-cmd")) out.innerHTML = '<div class="hzwb-dim">Read-only API shell — try <b>GET /api/queue</b>, <b>/v1/nodes</b> or <b>/internal/logs/raw</b>. Runs as you, same origin.</div>';
|
||||
$("hzwbin").focus();
|
||||
}
|
||||
}
|
||||
function wireWb() {
|
||||
devBtn.onclick = function () { setDev(true); wb.classList.contains("on") ? wbClose() : wbOpen(); };
|
||||
$("hzwbx").onclick = wbClose;
|
||||
$("hzwbre").onclick = function () { wbRender(); };
|
||||
wb.querySelectorAll(".hzwb-tab").forEach(function (b) {
|
||||
b.onclick = function () { WBTAB = b.dataset.t; $("hzwbbody").innerHTML = ""; wbRender(); };
|
||||
});
|
||||
$("hzwbform").onsubmit = function (e) {
|
||||
e.preventDefault();
|
||||
var v = $("hzwbin").value.trim(); if (!v) return;
|
||||
$("hzwbin").value = "";
|
||||
wbRun(v).catch(function () {});
|
||||
};
|
||||
}
|
||||
|
||||
function wire() {
|
||||
chatBtn.onclick = function () { chat.classList.toggle("on"); if (chat.classList.contains("on")) $("hzin").focus(); };
|
||||
chatBtn.onclick = function () { if (chat.classList.contains("on")) chat.classList.remove("on"); else openChat(); };
|
||||
$("hzclose").onclick = function () { chat.classList.remove("on"); };
|
||||
$("hzform").onsubmit = function (e) {
|
||||
e.preventDefault();
|
||||
@@ -144,9 +382,12 @@
|
||||
gpus.classList.toggle("on", online > 0);
|
||||
$("hznodes").innerHTML = nodes.length ? nodes.map(function (n) {
|
||||
var s = byNode[n.name] || { running: null, depth: 0 };
|
||||
// Authoritative depth from /v1/nodes (gpu-jobs activities) when present; the
|
||||
// running-render label still comes from the org's live queue/status.
|
||||
var depth = (typeof n.depth === "number") ? n.depth : s.depth;
|
||||
var sub = (n.online ? "online" : "offline") +
|
||||
(s.running ? " · running: " + esc(String(s.running).slice(0, 40)) : (n.online ? " · idle" : "")) +
|
||||
" · queue " + s.depth;
|
||||
" · queue " + depth;
|
||||
return '<div class="hz-node ' + (n.online ? "on" : "") + '"><span class="hz-dot"></span>' +
|
||||
'<div class="hz-nn"><b>' + esc(n.name) + '</b><div class="hz-nsub">' + sub + "</div></div></div>";
|
||||
}).join("") : '<div class="hz-nsub">No GPU nodes connected. Run <b>hanzo gpu connect</b> on a box to add one.</div>';
|
||||
@@ -196,6 +437,18 @@
|
||||
alert(r.ok ? '★ Saved template "' + (r.body.name || name.trim()) + '"' : "Save failed: " + ((r.body && r.body.error) || r.status));
|
||||
}
|
||||
|
||||
// The ONE public chat entry, so other surfaces (the composer's assistant-intent ↑) reuse
|
||||
// this exact sidebar + transport instead of building a second chat. `send` opens the
|
||||
// panel and sends; an optional assistant model is remembered for the sidebar.
|
||||
window.HanzoChat = {
|
||||
open: openChat,
|
||||
send: function (text, model) {
|
||||
if (model) { try { localStorage.hz_model = model; } catch (_) {} }
|
||||
openChat();
|
||||
if (text && text.trim()) send(text.trim()).catch(function () {});
|
||||
},
|
||||
};
|
||||
|
||||
if (document.body) mount();
|
||||
else document.addEventListener("DOMContentLoaded", mount);
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
"""Stacks — Procreate-style organizational folders for the Studio gallery.
|
||||
|
||||
A Stack groups library assets into a named folder (drag one image onto another,
|
||||
or multi-select → Stack). Stacks are ONLY an organizational layer: the assets
|
||||
themselves stay exactly where they are (library.json rows + files), and lineage,
|
||||
jobs and storage remain the source of truth. Removing an image from a Stack
|
||||
returns it to the gallery; it is never a delete. Deleting a Stack defaults to
|
||||
preserving every image.
|
||||
|
||||
Backed by ONE per-org SQLite file (stdlib sqlite3, WAL) at ``orgs/<org>/stacks.db``
|
||||
— the proven ``tasks_queue.py`` pattern: WAL + NORMAL + busy_timeout, and every
|
||||
mutation runs under ``BEGIN IMMEDIATE`` so create / add / move / remove / reorder /
|
||||
delete are atomic. Tenancy is structural: a different org is a different FILE, so
|
||||
a stack from another org is unreachable by construction (every route 404s across
|
||||
orgs). One stack per asset is enforced by ``UNIQUE(asset_id)`` on the membership
|
||||
table — a membership row exists only while the asset lives in a (non-deleted)
|
||||
Stack, so "one non-deleted Stack per asset" falls out of the constraint.
|
||||
|
||||
The schema is exactly the spec's ``stacks`` + ``stack_assets`` join, plus two
|
||||
small sidecar tables that keep generation-integration and the per-org setting in
|
||||
the same tenant-scoped file:
|
||||
|
||||
stack_dest pending "outputs of this render → this Stack" routing (prefix→id)
|
||||
settings per-org key/value (e.g. auto_stack_batches)
|
||||
|
||||
Standard library only — no new dependency.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
import folder_paths
|
||||
|
||||
_UNTITLED = "Untitled Stack"
|
||||
_MAX_NAME = 80
|
||||
_MAX_DESC = 2000
|
||||
_MAX_ASSET = 512
|
||||
_MAX_ASSETS = 2000 # a Stack is a folder, not a database — bound it
|
||||
_DEST_TTL = 6 * 3600.0 # a render lands within minutes; prune stale routing
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS stacks (
|
||||
id TEXT PRIMARY KEY,
|
||||
organization_id TEXT NOT NULL,
|
||||
workspace_id TEXT,
|
||||
owner_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
cover_asset_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
archived_at INTEGER,
|
||||
deleted_at INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS stack_assets (
|
||||
id TEXT PRIMARY KEY,
|
||||
stack_id TEXT NOT NULL,
|
||||
asset_id TEXT NOT NULL UNIQUE, -- one Stack per asset
|
||||
position INTEGER NOT NULL,
|
||||
added_by TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_stack_assets_stack ON stack_assets(stack_id, position);
|
||||
CREATE TABLE IF NOT EXISTS stack_dest (
|
||||
prefix TEXT PRIMARY KEY,
|
||||
stack_id TEXT NOT NULL,
|
||||
added_by TEXT,
|
||||
created_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
_conns: dict[str, sqlite3.Connection] = {}
|
||||
_locks: dict[str, threading.Lock] = {}
|
||||
_registry_lock = threading.Lock()
|
||||
|
||||
|
||||
def _db_path(org: str) -> Path:
|
||||
"""``orgs/<org>/stacks.db`` — a per-org sibling of the output tree that holds
|
||||
library.json/worklog.json/templates, so a Stack DB is tenant-scoped exactly
|
||||
like every other per-org file (same resolution as ``_templates_root``)."""
|
||||
out = Path(folder_paths.get_org_output_directory(org))
|
||||
base = out.parent if out.parent.name == org else out
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return base / "stacks.db"
|
||||
|
||||
|
||||
def _conn(org: str) -> tuple[sqlite3.Connection, threading.Lock]:
|
||||
"""The org's connection + its write lock, opened once and reused (one durable
|
||||
handle per tenant file, like the render queue)."""
|
||||
path = str(_db_path(org))
|
||||
with _registry_lock:
|
||||
c = _conns.get(path)
|
||||
if c is None:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
c = sqlite3.connect(path, check_same_thread=False, isolation_level=None)
|
||||
c.row_factory = sqlite3.Row
|
||||
c.execute("PRAGMA journal_mode=WAL")
|
||||
c.execute("PRAGMA synchronous=NORMAL")
|
||||
c.execute("PRAGMA busy_timeout=5000")
|
||||
c.executescript(_SCHEMA)
|
||||
_conns[path] = c
|
||||
_locks[path] = threading.Lock()
|
||||
return c, _locks[path]
|
||||
|
||||
|
||||
def _now() -> int:
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def _clean_ids(raw) -> list[str]:
|
||||
"""A client asset-id list → safe, de-duped, order-preserving, bounded."""
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for x in (raw if isinstance(raw, list) else []):
|
||||
if not isinstance(x, str):
|
||||
continue
|
||||
s = x.strip()[:_MAX_ASSET]
|
||||
if s and s not in seen:
|
||||
seen.add(s)
|
||||
out.append(s)
|
||||
if len(out) >= _MAX_ASSETS:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _row(stack: sqlite3.Row) -> dict:
|
||||
return {
|
||||
"id": stack["id"],
|
||||
"organizationId": stack["organization_id"],
|
||||
"workspaceId": stack["workspace_id"],
|
||||
"ownerId": stack["owner_id"],
|
||||
"name": stack["name"],
|
||||
"description": stack["description"] or "",
|
||||
"coverAssetId": stack["cover_asset_id"],
|
||||
"createdAt": stack["created_at"],
|
||||
"updatedAt": stack["updated_at"],
|
||||
"archivedAt": stack["archived_at"],
|
||||
}
|
||||
|
||||
|
||||
# ── membership primitives (all run inside a caller-held BEGIN IMMEDIATE) ─────────
|
||||
def _detach(c: sqlite3.Connection, asset_id: str, now: int) -> None:
|
||||
"""Remove an asset from whatever Stack currently holds it, keeping that Stack's
|
||||
cover valid — the move-between-Stacks and create-steals-asset invariant. Does
|
||||
NOT touch the asset itself (files/library rows are never Stacks' concern)."""
|
||||
row = c.execute("SELECT stack_id FROM stack_assets WHERE asset_id=?", (asset_id,)).fetchone()
|
||||
if row is None:
|
||||
return
|
||||
src = row["stack_id"]
|
||||
c.execute("DELETE FROM stack_assets WHERE asset_id=?", (asset_id,))
|
||||
cover = c.execute("SELECT cover_asset_id FROM stacks WHERE id=?", (src,)).fetchone()
|
||||
if cover and cover["cover_asset_id"] == asset_id:
|
||||
nxt = c.execute("SELECT asset_id FROM stack_assets WHERE stack_id=? ORDER BY position LIMIT 1",
|
||||
(src,)).fetchone()
|
||||
c.execute("UPDATE stacks SET cover_asset_id=?, updated_at=? WHERE id=?",
|
||||
(nxt["asset_id"] if nxt else None, now, src))
|
||||
else:
|
||||
c.execute("UPDATE stacks SET updated_at=? WHERE id=?", (now, src))
|
||||
|
||||
|
||||
def _append(c: sqlite3.Connection, stack_id: str, asset_ids: list[str], added_by: str, now: int) -> None:
|
||||
"""Append assets to a Stack in order, each first detached from its old Stack."""
|
||||
nextpos = c.execute("SELECT COALESCE(MAX(position)+1,0) AS n FROM stack_assets WHERE stack_id=?",
|
||||
(stack_id,)).fetchone()["n"]
|
||||
for aid in asset_ids:
|
||||
_detach(c, aid, now)
|
||||
c.execute("INSERT INTO stack_assets(id, stack_id, asset_id, position, added_by, created_at)"
|
||||
" VALUES(?,?,?,?,?,?)",
|
||||
(uuid.uuid4().hex, stack_id, aid, nextpos, added_by, now))
|
||||
nextpos += 1
|
||||
|
||||
|
||||
def _members(c: sqlite3.Connection, stack_id: str) -> list[dict]:
|
||||
rows = c.execute("SELECT asset_id, position, added_by, created_at FROM stack_assets"
|
||||
" WHERE stack_id=? ORDER BY position", (stack_id,)).fetchall()
|
||||
return [{"assetId": r["asset_id"], "position": r["position"],
|
||||
"addedBy": r["added_by"], "createdAt": r["created_at"]} for r in rows]
|
||||
|
||||
|
||||
# ── public API (each opens its own BEGIN IMMEDIATE; tenancy = the org's file) ────
|
||||
def create(org: str, name: str, asset_ids, *, owner: str = "", workspace: str | None = None) -> dict:
|
||||
name = (name or "").strip()[:_MAX_NAME] or _UNTITLED
|
||||
ids = _clean_ids(asset_ids)
|
||||
sid = uuid.uuid4().hex
|
||||
now = _now()
|
||||
c, lock = _conn(org)
|
||||
with lock:
|
||||
c.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
c.execute("INSERT INTO stacks(id, organization_id, workspace_id, owner_id, name,"
|
||||
" description, cover_asset_id, created_at, updated_at)"
|
||||
" VALUES(?,?,?,?,?,?,?,?,?)",
|
||||
(sid, org, workspace, owner, name, "", ids[0] if ids else None, now, now))
|
||||
_append(c, sid, ids, owner, now)
|
||||
c.execute("COMMIT")
|
||||
except Exception:
|
||||
c.execute("ROLLBACK")
|
||||
raise
|
||||
return get(org, sid)
|
||||
|
||||
|
||||
def get(org: str, sid: str) -> dict | None:
|
||||
c, _ = _conn(org)
|
||||
row = c.execute("SELECT * FROM stacks WHERE id=? AND organization_id=? AND deleted_at IS NULL",
|
||||
(sid, org)).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
out = _row(row)
|
||||
out["assets"] = _members(c, sid)
|
||||
out["count"] = len(out["assets"])
|
||||
return out
|
||||
|
||||
|
||||
def list_stacks(org: str, *, include_archived: bool = True) -> list[dict]:
|
||||
"""All of the org's live Stacks, newest-touched first, each with its member
|
||||
asset ids (the gallery hides stacked assets from the loose grid and draws the
|
||||
layered thumbnail from the cover + first members)."""
|
||||
c, _ = _conn(org)
|
||||
q = "SELECT * FROM stacks WHERE organization_id=? AND deleted_at IS NULL"
|
||||
if not include_archived:
|
||||
q += " AND archived_at IS NULL"
|
||||
q += " ORDER BY updated_at DESC"
|
||||
out = []
|
||||
for row in c.execute(q, (org,)).fetchall():
|
||||
s = _row(row)
|
||||
members = _members(c, row["id"])
|
||||
s["assets"] = members
|
||||
s["assetIds"] = [m["assetId"] for m in members]
|
||||
s["count"] = len(members)
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def patch(org: str, sid: str, fields: dict) -> dict | None:
|
||||
now = _now()
|
||||
c, lock = _conn(org)
|
||||
with lock:
|
||||
c.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
row = c.execute("SELECT id FROM stacks WHERE id=? AND organization_id=? AND deleted_at IS NULL",
|
||||
(sid, org)).fetchone()
|
||||
if row is None:
|
||||
c.execute("COMMIT")
|
||||
return None
|
||||
updates: list[tuple[str, object]] = []
|
||||
if "name" in fields:
|
||||
updates.append(("name", (str(fields.get("name") or "").strip()[:_MAX_NAME]) or _UNTITLED))
|
||||
if "description" in fields:
|
||||
updates.append(("description", str(fields.get("description") or "")[:_MAX_DESC]))
|
||||
if "cover" in fields or "coverAssetId" in fields:
|
||||
cover = str(fields.get("cover") or fields.get("coverAssetId") or "").strip()[:_MAX_ASSET]
|
||||
# A cover must be a member — else the card would show a ghost.
|
||||
member = c.execute("SELECT 1 FROM stack_assets WHERE stack_id=? AND asset_id=?",
|
||||
(sid, cover)).fetchone()
|
||||
if cover and member is None:
|
||||
raise ValueError("cover must be an asset in this Stack")
|
||||
updates.append(("cover_asset_id", cover or None))
|
||||
if "archived" in fields:
|
||||
updates.append(("archived_at", now if fields.get("archived") else None))
|
||||
updates.append(("updated_at", now))
|
||||
cols = ", ".join(f"{col}=?" for col, _ in updates)
|
||||
args = [val for _, val in updates] + [sid, org]
|
||||
c.execute(f"UPDATE stacks SET {cols} WHERE id=? AND organization_id=?", args)
|
||||
c.execute("COMMIT")
|
||||
except Exception:
|
||||
c.execute("ROLLBACK")
|
||||
raise
|
||||
return get(org, sid)
|
||||
|
||||
|
||||
def add_assets(org: str, sid: str, asset_ids, *, added_by: str = "") -> dict | None:
|
||||
ids = _clean_ids(asset_ids)
|
||||
now = _now()
|
||||
c, lock = _conn(org)
|
||||
with lock:
|
||||
c.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
row = c.execute("SELECT cover_asset_id FROM stacks WHERE id=? AND organization_id=? AND deleted_at IS NULL",
|
||||
(sid, org)).fetchone()
|
||||
if row is None:
|
||||
c.execute("COMMIT")
|
||||
return None
|
||||
_append(c, sid, ids, added_by, now)
|
||||
if row["cover_asset_id"] is None and ids:
|
||||
c.execute("UPDATE stacks SET cover_asset_id=? WHERE id=?", (ids[0], sid))
|
||||
c.execute("UPDATE stacks SET updated_at=? WHERE id=?", (now, sid))
|
||||
c.execute("COMMIT")
|
||||
except Exception:
|
||||
c.execute("ROLLBACK")
|
||||
raise
|
||||
return get(org, sid)
|
||||
|
||||
|
||||
def remove_assets(org: str, sid: str, asset_ids) -> dict | None:
|
||||
"""Remove assets from a Stack — they return to the main gallery, never deleted.
|
||||
Positions compact; a removed cover falls back to the new first member."""
|
||||
ids = _clean_ids(asset_ids)
|
||||
now = _now()
|
||||
c, lock = _conn(org)
|
||||
with lock:
|
||||
c.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
row = c.execute("SELECT cover_asset_id FROM stacks WHERE id=? AND organization_id=? AND deleted_at IS NULL",
|
||||
(sid, org)).fetchone()
|
||||
if row is None:
|
||||
c.execute("COMMIT")
|
||||
return None
|
||||
for aid in ids:
|
||||
c.execute("DELETE FROM stack_assets WHERE stack_id=? AND asset_id=?", (sid, aid))
|
||||
# compact positions to keep them contiguous and stable
|
||||
rest = c.execute("SELECT id FROM stack_assets WHERE stack_id=? ORDER BY position",
|
||||
(sid,)).fetchall()
|
||||
for i, r in enumerate(rest):
|
||||
c.execute("UPDATE stack_assets SET position=? WHERE id=?", (i, r["id"]))
|
||||
cover = row["cover_asset_id"]
|
||||
if cover in ids:
|
||||
nxt = c.execute("SELECT asset_id FROM stack_assets WHERE stack_id=? ORDER BY position LIMIT 1",
|
||||
(sid,)).fetchone()
|
||||
c.execute("UPDATE stacks SET cover_asset_id=? WHERE id=?",
|
||||
(nxt["asset_id"] if nxt else None, sid))
|
||||
c.execute("UPDATE stacks SET updated_at=? WHERE id=?", (now, sid))
|
||||
c.execute("COMMIT")
|
||||
except Exception:
|
||||
c.execute("ROLLBACK")
|
||||
raise
|
||||
return get(org, sid)
|
||||
|
||||
|
||||
def reorder(org: str, sid: str, asset_ids) -> dict | None:
|
||||
"""Persist a new order. The given ids must be exactly the Stack's current
|
||||
membership (same set) — a partial/foreign list is rejected so the order can
|
||||
never silently drop or invent a member."""
|
||||
ids = _clean_ids(asset_ids)
|
||||
now = _now()
|
||||
c, lock = _conn(org)
|
||||
with lock:
|
||||
c.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
row = c.execute("SELECT id FROM stacks WHERE id=? AND organization_id=? AND deleted_at IS NULL",
|
||||
(sid, org)).fetchone()
|
||||
if row is None:
|
||||
c.execute("COMMIT")
|
||||
return None
|
||||
cur = {r["asset_id"] for r in c.execute("SELECT asset_id FROM stack_assets WHERE stack_id=?", (sid,)).fetchall()}
|
||||
if set(ids) != cur:
|
||||
raise ValueError("order must list exactly the Stack's assets")
|
||||
for i, aid in enumerate(ids):
|
||||
c.execute("UPDATE stack_assets SET position=? WHERE stack_id=? AND asset_id=?", (i, sid, aid))
|
||||
c.execute("UPDATE stacks SET updated_at=? WHERE id=?", (now, sid))
|
||||
c.execute("COMMIT")
|
||||
except Exception:
|
||||
c.execute("ROLLBACK")
|
||||
raise
|
||||
return get(org, sid)
|
||||
|
||||
|
||||
def _dissolve(org: str, sid: str) -> list[str] | None:
|
||||
"""Soft-delete the container and free its members (membership rows removed so
|
||||
the assets are unstacked and one-per-asset stays true). Returns the freed asset
|
||||
ids, or None if the Stack isn't the caller-org's live Stack. Backs both Unstack
|
||||
and Delete-Stack."""
|
||||
now = _now()
|
||||
c, lock = _conn(org)
|
||||
with lock:
|
||||
c.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
row = c.execute("SELECT id FROM stacks WHERE id=? AND organization_id=? AND deleted_at IS NULL",
|
||||
(sid, org)).fetchone()
|
||||
if row is None:
|
||||
c.execute("COMMIT")
|
||||
return None
|
||||
members = [r["asset_id"] for r in
|
||||
c.execute("SELECT asset_id FROM stack_assets WHERE stack_id=? ORDER BY position", (sid,)).fetchall()]
|
||||
c.execute("DELETE FROM stack_assets WHERE stack_id=?", (sid,))
|
||||
c.execute("UPDATE stacks SET deleted_at=?, updated_at=? WHERE id=?", (now, now, sid))
|
||||
c.execute("COMMIT")
|
||||
except Exception:
|
||||
c.execute("ROLLBACK")
|
||||
raise
|
||||
return members
|
||||
|
||||
|
||||
def unstack(org: str, sid: str) -> list[str] | None:
|
||||
"""Delete the container, preserve every asset to the main gallery."""
|
||||
return _dissolve(org, sid)
|
||||
|
||||
|
||||
def delete(org: str, sid: str, mode: str = "only") -> dict | None:
|
||||
"""Delete a Stack. ``only`` (default) returns its images to the gallery;
|
||||
``images`` also asks the caller to soft-delete them. Files are never touched.
|
||||
Returns ``{ok, mode, assetIds}`` (assetIds = the freed/soft-deletable assets),
|
||||
or None across orgs / already gone."""
|
||||
freed = _dissolve(org, sid)
|
||||
if freed is None:
|
||||
return None
|
||||
return {"ok": True, "mode": ("images" if mode == "images" else "only"), "assetIds": freed}
|
||||
|
||||
|
||||
def stack_of(org: str, asset_id: str) -> str | None:
|
||||
c, _ = _conn(org)
|
||||
row = c.execute("SELECT stack_id FROM stack_assets WHERE asset_id=?", (asset_id,)).fetchone()
|
||||
return row["stack_id"] if row else None
|
||||
|
||||
|
||||
# ── generation integration: route a render's outputs into a chosen Stack ─────────
|
||||
def route_prefix(org: str, output_prefix: str, sid: str, *, added_by: str = "") -> None:
|
||||
"""Remember "every output of the render at ``output_prefix`` goes into Stack
|
||||
``sid``". Recorded at dispatch; consumed by :func:`absorb` when each output
|
||||
lands (so ALL images of a multi-output job land in the same Stack). Stale
|
||||
routing is pruned — a render lands within minutes."""
|
||||
if not output_prefix or not sid:
|
||||
return
|
||||
now = time.time()
|
||||
c, lock = _conn(org)
|
||||
with lock:
|
||||
c.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
c.execute("DELETE FROM stack_dest WHERE created_at < ?", (now - _DEST_TTL,))
|
||||
c.execute("INSERT INTO stack_dest(prefix, stack_id, added_by, created_at) VALUES(?,?,?,?)"
|
||||
" ON CONFLICT(prefix) DO UPDATE SET stack_id=excluded.stack_id,"
|
||||
" added_by=excluded.added_by, created_at=excluded.created_at",
|
||||
(output_prefix, sid, added_by, now))
|
||||
c.execute("COMMIT")
|
||||
except Exception:
|
||||
c.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
|
||||
_COUNTER = re.compile(r"_\d+_\.\w+$")
|
||||
|
||||
|
||||
def absorb(org: str, rel: str, *, added_by: str = "") -> str | None:
|
||||
"""A just-ingested asset ``rel`` → the Stack its render was routed to, if any.
|
||||
Matches a landed ``<prefix>_<counter>_.png`` back to its ``output_prefix`` (and
|
||||
the ingested-path case where the stored path IS the prefix). Idempotent via the
|
||||
membership UNIQUE. Returns the Stack id it joined, or None."""
|
||||
if not rel:
|
||||
return None
|
||||
c, _ = _conn(org)
|
||||
cand = c.execute("SELECT stack_id FROM stack_dest WHERE prefix=?", (rel,)).fetchone()
|
||||
if cand is None:
|
||||
prefix = _COUNTER.sub("", rel)
|
||||
if prefix != rel:
|
||||
cand = c.execute("SELECT stack_id FROM stack_dest WHERE prefix=?", (prefix,)).fetchone()
|
||||
if cand is None:
|
||||
return None
|
||||
sid = cand["stack_id"]
|
||||
if add_assets(org, sid, [rel], added_by=added_by or "generation") is None:
|
||||
return None
|
||||
return sid
|
||||
|
||||
|
||||
# ── per-org settings (auto-stack batch generations) ─────────────────────────────
|
||||
def get_setting(org: str, key: str, default=None):
|
||||
c, _ = _conn(org)
|
||||
row = c.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
|
||||
if row is None:
|
||||
return default
|
||||
try:
|
||||
return json.loads(row["value"])
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def set_setting(org: str, key: str, value) -> None:
|
||||
c, lock = _conn(org)
|
||||
with lock:
|
||||
c.execute("INSERT INTO settings(key, value) VALUES(?,?)"
|
||||
" ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
(key, json.dumps(value)))
|
||||
|
||||
|
||||
# ── HTTP routes: /v1/stacks… (house style: /v1, never /api). Dependencies are
|
||||
# INJECTED (org / owner / workspace resolvers + the library soft-deleter) so this
|
||||
# module stays decoupled from studio_home — no import cycle, one stacks concern. ──
|
||||
def add_stacks_routes(routes: web.RouteTableDef, server, *, org_of, owner_of, workspace_of,
|
||||
soft_delete_assets) -> None:
|
||||
|
||||
async def _json(request):
|
||||
try:
|
||||
return await request.json()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@routes.get("/v1/stacks")
|
||||
async def list_route(request: web.Request):
|
||||
org = org_of(request)
|
||||
return web.json_response({"org": org, "stacks": list_stacks(org)})
|
||||
|
||||
@routes.post("/v1/stacks")
|
||||
async def create_route(request: web.Request):
|
||||
body = await _json(request)
|
||||
if body is None:
|
||||
return web.json_response({"error": "invalid JSON"}, status=400)
|
||||
stack = create(org_of(request), body.get("name"), body.get("assetIds"),
|
||||
owner=owner_of(request), workspace=workspace_of(request))
|
||||
return web.json_response(stack)
|
||||
|
||||
# /v1/stacks/settings MUST precede /v1/stacks/{id} — else "settings" reads as an id.
|
||||
@routes.get("/v1/stacks/settings")
|
||||
async def get_settings_route(request: web.Request):
|
||||
org = org_of(request)
|
||||
return web.json_response({"org": org, "autoStack": bool(get_setting(org, "auto_stack_batches", False))})
|
||||
|
||||
@routes.patch("/v1/stacks/settings")
|
||||
async def set_settings_route(request: web.Request):
|
||||
body = await _json(request)
|
||||
if body is None:
|
||||
return web.json_response({"error": "invalid JSON"}, status=400)
|
||||
org = org_of(request)
|
||||
if "autoStack" in body:
|
||||
set_setting(org, "auto_stack_batches", bool(body.get("autoStack")))
|
||||
return web.json_response({"org": org, "autoStack": bool(get_setting(org, "auto_stack_batches", False))})
|
||||
|
||||
@routes.get("/v1/stacks/{id}")
|
||||
async def get_route(request: web.Request):
|
||||
stack = get(org_of(request), request.match_info["id"])
|
||||
if stack is None:
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
return web.json_response(stack)
|
||||
|
||||
@routes.patch("/v1/stacks/{id}")
|
||||
async def patch_route(request: web.Request):
|
||||
body = await _json(request)
|
||||
if body is None:
|
||||
return web.json_response({"error": "invalid JSON"}, status=400)
|
||||
try:
|
||||
stack = patch(org_of(request), request.match_info["id"], body)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
if stack is None:
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
return web.json_response(stack)
|
||||
|
||||
@routes.delete("/v1/stacks/{id}")
|
||||
async def delete_route(request: web.Request):
|
||||
org = org_of(request)
|
||||
mode = "images" if request.query.get("mode") == "images" else "only"
|
||||
result = delete(org, request.match_info["id"], mode)
|
||||
if result is None:
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
if mode == "images" and result["assetIds"]:
|
||||
soft_delete_assets(org, result["assetIds"])
|
||||
return web.json_response(result)
|
||||
|
||||
@routes.post("/v1/stacks/{id}/assets")
|
||||
async def add_assets_route(request: web.Request):
|
||||
body = await _json(request)
|
||||
if body is None:
|
||||
return web.json_response({"error": "invalid JSON"}, status=400)
|
||||
stack = add_assets(org_of(request), request.match_info["id"], body.get("assetIds"),
|
||||
added_by=owner_of(request))
|
||||
if stack is None:
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
return web.json_response(stack)
|
||||
|
||||
@routes.delete("/v1/stacks/{id}/assets")
|
||||
async def remove_assets_route(request: web.Request):
|
||||
body = await _json(request)
|
||||
if body is None:
|
||||
return web.json_response({"error": "invalid JSON"}, status=400)
|
||||
stack = remove_assets(org_of(request), request.match_info["id"], body.get("assetIds"))
|
||||
if stack is None:
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
return web.json_response(stack)
|
||||
|
||||
@routes.patch("/v1/stacks/{id}/order")
|
||||
async def order_route(request: web.Request):
|
||||
body = await _json(request)
|
||||
if body is None:
|
||||
return web.json_response({"error": "invalid JSON"}, status=400)
|
||||
try:
|
||||
stack = reorder(org_of(request), request.match_info["id"], body.get("assetIds"))
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
if stack is None:
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
return web.json_response(stack)
|
||||
|
||||
@routes.post("/v1/stacks/{id}/unstack")
|
||||
async def unstack_route(request: web.Request):
|
||||
freed = unstack(org_of(request), request.match_info["id"])
|
||||
if freed is None:
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
return web.json_response({"ok": True, "assetIds": freed})
|
||||
+1963
-204
File diff suppressed because it is too large
Load Diff
+1860
-250
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ When Studio runs with --worker-mode, this module handles:
|
||||
3. Reporting device capabilities (GPU model, VRAM, etc.)
|
||||
"""
|
||||
import asyncio
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import time as _time
|
||||
@@ -17,6 +18,7 @@ import aiohttp
|
||||
from aiohttp import web
|
||||
|
||||
import execution
|
||||
from studio.cli_args import args
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,10 +33,31 @@ WORKER_TOKEN = os.environ.get("STUDIO_WORKER_TOKEN", "")
|
||||
|
||||
|
||||
def verify_worker_token(request) -> bool:
|
||||
"""True if the request carries the coordinator shared secret (or none is set)."""
|
||||
"""True iff the request carries the coordinator shared secret. FAILS CLOSED: in
|
||||
worker mode the secret is MANDATORY (main.py refuses to boot without it, and the
|
||||
KMS /v1 migration has emptied that secret before), so an empty/unset token is a
|
||||
valid single-trust-domain dev signal ONLY when NOT in worker mode — never a bypass
|
||||
that reopens the hidden-run hole on a worker box. Constant-time comparison."""
|
||||
if not WORKER_TOKEN:
|
||||
return True
|
||||
return request.headers.get("X-Worker-Token", "") == WORKER_TOKEN
|
||||
return not args.worker_mode
|
||||
return hmac.compare_digest(request.headers.get("X-Worker-Token", ""), WORKER_TOKEN)
|
||||
|
||||
|
||||
def reject_untrusted_worker_submit(request, worker_mode: bool):
|
||||
"""The ONE policy gate for worker-mode /prompt (decomplected: one function, one
|
||||
place). In worker mode this process is a render BACKEND, not a front — the only
|
||||
accepted submit is a job claimed off the org's gpu-jobs queue and handed in over the
|
||||
coordinator seam (POST /v1/worker/execute with X-Worker-Token). A direct /prompt POST
|
||||
without a valid token is refused (403), so no render can reach a GPU without appearing
|
||||
in the org's visible queue. Returns an aiohttp Response to send, or None to allow.
|
||||
Not worker mode → always allowed (returns None)."""
|
||||
if worker_mode and not verify_worker_token(request):
|
||||
return web.json_response({"error": {
|
||||
"type": "worker_mode_forbidden",
|
||||
"message": "worker-mode renders run only from the gpu-jobs queue "
|
||||
"(POST /v1/worker/execute); direct /prompt is disabled.",
|
||||
"details": "", "extra_info": {}}, "node_errors": {}}, status=403)
|
||||
return None
|
||||
|
||||
|
||||
def _worker_headers() -> dict:
|
||||
@@ -172,6 +195,8 @@ def add_worker_routes(routes: web.RouteTableDef, prompt_server):
|
||||
return web.json_response({"error": "No prompt provided"}, status=400)
|
||||
|
||||
prompt = json_data["prompt"]
|
||||
if not isinstance(prompt, dict):
|
||||
return web.json_response({"error": "prompt must be an object"}, status=400)
|
||||
prompt_id = str(json_data.get("prompt_id", uuid.uuid4()))
|
||||
|
||||
partial_execution_targets = json_data.get("partial_execution_targets")
|
||||
@@ -197,8 +222,19 @@ def add_worker_routes(routes: web.RouteTableDef, prompt_server):
|
||||
if sensitive_val in extra_data:
|
||||
sensitive[sensitive_val] = extra_data.pop(sensitive_val)
|
||||
|
||||
number = float(json_data.get("number", 0))
|
||||
try:
|
||||
number = float(json_data.get("number", 0))
|
||||
except (TypeError, ValueError):
|
||||
return web.json_response({"error": "number must be numeric"}, status=400)
|
||||
extra_data["create_time"] = int(_time.time() * 1000)
|
||||
# `org` scopes outputs on THIS worker's LOCAL disk only — a worker-LOCAL label,
|
||||
# never a gallery destination. The durable upload path re-derives the gallery org
|
||||
# from the user's IAM token (not this body field), so a forged body `org` can at
|
||||
# most mislabel a local temp dir, never write into another tenant's gallery. The
|
||||
# prompt worker binds folder_paths.set_execution_org from extra_data["org_id"].
|
||||
org = json_data.get("org") or extra_data.get("org_id")
|
||||
if org:
|
||||
extra_data["org_id"] = org
|
||||
|
||||
# Queue locally — the worker's prompt_worker thread will pick it up
|
||||
prompt_server.prompt_queue.put(
|
||||
|
||||
@@ -59,6 +59,20 @@ def _save(org: str, rows: list[dict]) -> None:
|
||||
tmp.replace(p)
|
||||
|
||||
|
||||
def has_output(org: str, path: str) -> bool:
|
||||
"""Whether any row already owns this landed file — the ingest upsert key
|
||||
(re-ingesting must repair, never duplicate). A dispatch row's output_prefix
|
||||
lacks the SaveImage counter suffix (``fixes/x_ab12cd`` owns
|
||||
``fixes/x_ab12cd_00001_.png``), so the match is prefix-aware, not equality —
|
||||
equality alone doubled every mirrored render with an ingest row."""
|
||||
for r in load(org):
|
||||
pre = r.get("output_prefix") or ""
|
||||
if path and (path == pre or path == r.get("output") or
|
||||
(pre and path.startswith(pre + "_"))):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
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,
|
||||
@@ -92,6 +106,22 @@ def record(org: str, *, kind: str, prompt: str, refs: list, uploads: list,
|
||||
return row
|
||||
|
||||
|
||||
def remove(org: str, ids) -> int:
|
||||
"""Delete work-log rows by id (a queued/cancelled/failed/done entry the user wants
|
||||
out of their queue). Returns how many were removed. The log is just a record of
|
||||
dispatch requests, so dropping a row is safe — it never touches an output image
|
||||
(those live in the library and are archived/deleted there). Idempotent."""
|
||||
want = {str(i) for i in (ids if isinstance(ids, (list, tuple, set)) else [ids]) if i}
|
||||
if not want:
|
||||
return 0
|
||||
rows = load(org)
|
||||
kept = [r for r in rows if str(r.get("id")) not in want]
|
||||
removed = len(rows) - len(kept)
|
||||
if removed:
|
||||
_save(org, kept)
|
||||
return removed
|
||||
|
||||
|
||||
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."""
|
||||
@@ -112,6 +142,28 @@ def set_status(org: str, pid: str, status: str, note: str | None = None) -> bool
|
||||
return hit
|
||||
|
||||
|
||||
def add_event(org: str, output_prefix: str, s: str, note: str | None = None) -> bool:
|
||||
"""Append one event to the row(s) carrying this landed output — an annotation,
|
||||
not a lifecycle move. The render completed (its status is terminal); a later
|
||||
judgment like the quality gate flagging it records an event without touching
|
||||
that status. Keyed by output like ``has_output``. Returns True if a row was
|
||||
annotated."""
|
||||
if not output_prefix:
|
||||
return False
|
||||
rows = load(org)
|
||||
now = int(time.time())
|
||||
hit = False
|
||||
for r in rows:
|
||||
if r.get("output_prefix") == output_prefix or r.get("output") == output_prefix:
|
||||
r.setdefault("events", []).append(
|
||||
{"s": s, "ts": now, **({"note": note} if note else {})})
|
||||
r["updatedAt"] = now
|
||||
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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "hanzo-studio"
|
||||
version = "0.17.3"
|
||||
version = "0.18.3"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -26,6 +26,15 @@ comfy-aimdo>=0.2.0
|
||||
requests
|
||||
PyJWT[crypto]>=2.7.0
|
||||
|
||||
# Office-document renderers (middleware/doc_render.py) — pure-Python, no GPU. WeasyPrint
|
||||
# gives full-CSS PDFs when its native pango/cairo stack is present (see Dockerfile);
|
||||
# where it is not (the light CI unit venv), the built-in PDF fallback takes over, so
|
||||
# every format renders with just these wheels installed.
|
||||
weasyprint>=63
|
||||
python-docx>=1.1.0
|
||||
openpyxl>=3.1.0
|
||||
python-pptx>=1.0.0
|
||||
|
||||
#non essential dependencies:
|
||||
kornia>=0.7.1
|
||||
spandrel
|
||||
|
||||
+120
-15
@@ -29,13 +29,44 @@ Pure stdlib — no PIL, no deps — so it runs in the studio image or anywhere.
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # non-POSIX (never in the studio image); lock is a no-op
|
||||
fcntl = None
|
||||
|
||||
LOCK_NAME = ".library.lock"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def library_lock(root: str):
|
||||
"""Serialize the read-modify-write of library.json across processes — the
|
||||
build-manifest sidecar and the studio app both rebuild-then-replace it, so an
|
||||
unlocked interleave loses status/caption/tags updates. Same lock file name is
|
||||
used by the app writer (studio_home._save_library)."""
|
||||
if fcntl is None:
|
||||
yield
|
||||
return
|
||||
lock_path = os.path.join(root, LOCK_NAME)
|
||||
fh = open(lock_path, "w")
|
||||
try:
|
||||
fcntl.flock(fh, fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
fcntl.flock(fh, fcntl.LOCK_UN)
|
||||
fh.close()
|
||||
|
||||
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
|
||||
VIDEO_EXTS = {".mp4", ".webm"}
|
||||
MODEL3D_EXTS = {".glb"}
|
||||
# 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
|
||||
@@ -54,6 +85,19 @@ def iso(ts: float) -> str:
|
||||
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _sha_of(path: str) -> str | None:
|
||||
"""Streamed content hash (a 40MB PNG must not balloon memory). None on error."""
|
||||
import hashlib
|
||||
try:
|
||||
h = hashlib.md5()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def classify(relpath: str, slugs: set[str]) -> dict | None:
|
||||
"""Map a path relative to the library root -> {design, kind, role}.
|
||||
|
||||
@@ -91,20 +135,23 @@ def classify(relpath: str, slugs: set[str]) -> dict | 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 base, dirs, files in os.walk(root):
|
||||
# Hidden segments are never assets — one rule covering AppleDouble
|
||||
# forks (._foo.png) AND cache trees (.thumbs/ thumbnails indexed as
|
||||
# library rows sent thumb paths into the fix dispatcher).
|
||||
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
||||
for f in files:
|
||||
if f.startswith("."):
|
||||
# Dotfiles are never assets: `._foo.png` AppleDouble forks ride
|
||||
# along with mac transfers, carry an image extension, and are
|
||||
# resource-fork bytes that render 0x0 in the library.
|
||||
continue
|
||||
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_video = ext in VIDEO_EXTS
|
||||
is_model3d = ext in MODEL3D_EXTS
|
||||
is_mktg_text = (ext in MARKETING_TEXT_EXTS
|
||||
and rel.replace(os.sep, "/").startswith("marketing/"))
|
||||
if not (is_image or is_mktg_text):
|
||||
if not (is_image or is_video or is_model3d or is_mktg_text):
|
||||
continue
|
||||
try:
|
||||
if os.path.getsize(full) == 0: # skip a render caught mid-write
|
||||
@@ -138,9 +185,25 @@ def build(root: str) -> dict:
|
||||
if norm == MANIFEST_NAME:
|
||||
continue
|
||||
info = classify(norm, slugs)
|
||||
if info is None:
|
||||
continue
|
||||
prev = prior.get(norm, {})
|
||||
if info is None:
|
||||
# Taxonomy enriches, never gates: a file outside the naming scheme is
|
||||
# still an asset. Dropping it here erased every ingest-indexed render on
|
||||
# each sweep cycle. A video keeps kind="video" so it survives every sweep
|
||||
# as the motion-judged, video-card kind.
|
||||
_e = os.path.splitext(norm)[1].lower()
|
||||
kind = prev.get("kind") or (
|
||||
"model3d" if _e in MODEL3D_EXTS else "video" if _e in VIDEO_EXTS else "render")
|
||||
info = {"design": prev.get("design"), "kind": kind, "role": prev.get("role")}
|
||||
abspath = os.path.join(root, rel)
|
||||
mtime = os.path.getmtime(abspath)
|
||||
# Content hash powers the dedup invariant below. Reuse the prior sha when the
|
||||
# file is byte-stable (same mtime), so a sweep hashes only NEW/changed files —
|
||||
# O(new), not O(all), even on a large gallery. Only images are hashed (docs are
|
||||
# not the byte-duplicate-clutter concern).
|
||||
is_img = norm.lower().endswith((".png", ".jpg", ".jpeg", ".webp"))
|
||||
sha = prev.get("sha") if (prev.get("mtime") == mtime and prev.get("sha")) \
|
||||
else (_sha_of(abspath) if is_img else None)
|
||||
entry = {
|
||||
"path": norm,
|
||||
"design": info["design"],
|
||||
@@ -148,13 +211,39 @@ def build(root: str) -> dict:
|
||||
"role": info["role"],
|
||||
"status": prev.get("status", "draft"),
|
||||
"tags": prev.get("tags", []),
|
||||
"updatedAt": iso(os.path.getmtime(os.path.join(root, rel))),
|
||||
"mtime": mtime,
|
||||
"sha": sha,
|
||||
"updatedAt": iso(mtime),
|
||||
}
|
||||
caption = prev.get("caption")
|
||||
if caption:
|
||||
entry["caption"] = caption
|
||||
assets.append(entry)
|
||||
|
||||
# ── Structural invariant: AT MOST ONE active asset per content hash. A re-rendered
|
||||
# byte-identical duplicate (a retried / double-dispatched render) is auto-marked
|
||||
# deleted here — so the gallery can NEVER accumulate duplicate images, whatever bug
|
||||
# fires upstream. Keeper = a curated asset if one exists, else the earliest by
|
||||
# mtime; distinct renders (different seed → different bytes → different sha) are
|
||||
# untouched. Idempotent (a prior-swept dup stays deleted) and recoverable like any
|
||||
# soft-delete. This is the last line of defense that makes duplicate clutter
|
||||
# impossible-by-construction, independent of dispatch/retry correctness. ──
|
||||
from collections import defaultdict
|
||||
_CURATED = {"approved", "published", "queued", "flagged"}
|
||||
_by_sha: dict = defaultdict(list)
|
||||
for a in assets:
|
||||
if a.get("sha") and a["status"] != "deleted":
|
||||
_by_sha[a["sha"]].append(a)
|
||||
deduped = 0
|
||||
for group in _by_sha.values():
|
||||
if len(group) < 2:
|
||||
continue
|
||||
group.sort(key=lambda a: (a["status"] not in _CURATED, a.get("mtime", 0)))
|
||||
for dup in group[1:]:
|
||||
dup["status"] = "deleted"
|
||||
dup["dedup"] = True
|
||||
deduped += 1
|
||||
|
||||
by_status: dict[str, int] = {}
|
||||
by_kind: dict[str, int] = {}
|
||||
for a in assets:
|
||||
@@ -177,12 +266,24 @@ def build(root: str) -> dict:
|
||||
|
||||
|
||||
def write_atomic(root: str, doc: dict) -> str:
|
||||
# A UNIQUE tmp per writer: a fixed ".library.json.tmp" is shared by every
|
||||
# build-manifest sidecar, so two pods overlapping during a roll interleave into
|
||||
# one inode -> a torn library.json -> load_prior()/reader gets JSONDecodeError
|
||||
# -> {} -> every asset resets to status="draft" (the curation wipe). mkstemp in
|
||||
# the SAME dir keeps the rename atomic and same-filesystem.
|
||||
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)
|
||||
fd, tmp = tempfile.mkstemp(prefix=".library.json.", suffix=".tmp", dir=root)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as fh:
|
||||
json.dump(doc, fh, indent=2, ensure_ascii=False)
|
||||
fh.write("\n")
|
||||
os.replace(tmp, manifest_path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
return manifest_path
|
||||
|
||||
|
||||
@@ -194,8 +295,12 @@ def run_once(root: str, quiet: bool = False, strict: bool = True) -> dict | None
|
||||
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)
|
||||
# Hold the lock across build (which reads the prior manifest to preserve
|
||||
# status/caption/tags) AND the write, so a concurrent app-side status change
|
||||
# can't be read-then-clobbered.
|
||||
with library_lock(root):
|
||||
doc = build(root)
|
||||
path = write_atomic(root, doc)
|
||||
if not quiet:
|
||||
m = doc["_meta"]
|
||||
print(f"library_manifest: {m['count']} assets -> {path} {m['byStatus']}")
|
||||
|
||||
@@ -382,11 +382,14 @@ class PromptServer():
|
||||
|
||||
@routes.get("/ready")
|
||||
async def readiness_check(request):
|
||||
# Check that the prompt worker is alive and base directories exist
|
||||
ready = (
|
||||
os.path.isdir(folder_paths.get_input_directory())
|
||||
and self.prompt_queue is not None
|
||||
)
|
||||
# The render worker must actually be ALIVE — not merely that the queue
|
||||
# object exists (prompt_queue is set once at boot and never cleared, so
|
||||
# it is constant-true and says nothing about the executor). A dead worker
|
||||
# daemon thread accepts /prompt but runs nothing; readiness must fail so
|
||||
# the pod leaves the Service and a roll never promotes a wedged pod.
|
||||
wt = getattr(self, "prompt_worker_thread", None)
|
||||
worker_alive = wt.is_alive() if wt is not None else (self.prompt_queue is not None)
|
||||
ready = os.path.isdir(folder_paths.get_input_directory()) and worker_alive
|
||||
if ready:
|
||||
return web.json_response({"status": "ready"})
|
||||
return web.json_response({"status": "not_ready"}, status=503)
|
||||
@@ -577,7 +580,19 @@ class PromptServer():
|
||||
# Force type=output regardless of what the client sent.
|
||||
forced = dict(post)
|
||||
forced["type"] = "output"
|
||||
return image_upload(forced, request=request)
|
||||
resp = image_upload(forced, request=request)
|
||||
# Fix outputs: restore the render's global colour to its source, undoing
|
||||
# the full-regen (denoise 1.0) white-balance/exposure drift and its
|
||||
# fix-of-a-fix compounding. Pod-side + fail-safe, so it can never affect a
|
||||
# render's success; a render that didn't drift matches to itself untouched.
|
||||
try:
|
||||
saved = json.loads(resp.body) if getattr(resp, "body", None) else {}
|
||||
if saved.get("name"):
|
||||
from middleware.studio_home import colormatch_fix_ingest
|
||||
colormatch_fix_ingest(request, saved.get("subfolder", ""), saved["name"])
|
||||
except Exception:
|
||||
pass
|
||||
return resp
|
||||
|
||||
@routes.post("/upload/mask")
|
||||
async def upload_mask(request):
|
||||
@@ -1028,6 +1043,15 @@ class PromptServer():
|
||||
async def post_prompt(request):
|
||||
logging.info("got prompt")
|
||||
|
||||
# Execution-engine (worker) mode: this process IS a BYO-GPU render backend,
|
||||
# not a front. A direct /prompt POST without the coordinator token is refused
|
||||
# so no render reaches a GPU without appearing in the org's visible queue.
|
||||
# One way onto a GPU: enqueue -> claim -> execute. (Policy: worker_client.)
|
||||
from middleware.worker_client import reject_untrusted_worker_submit
|
||||
_gate = reject_untrusted_worker_submit(request, args.worker_mode)
|
||||
if _gate is not None:
|
||||
return _gate
|
||||
|
||||
# Billing: check balance before accepting prompt
|
||||
if args.enable_billing and args.billing_check_balance:
|
||||
iam_user = request.get("iam_user")
|
||||
@@ -1047,7 +1071,11 @@ class PromptServer():
|
||||
json_data = self.trigger_on_prompt(json_data)
|
||||
|
||||
# --- Prompt routing: check if this should go to a GPU worker ---
|
||||
if not args.worker_mode:
|
||||
# Legacy in-cluster PUSH federation (prompt_router -> compute_config
|
||||
# workers) is OFF by default: the gpu-jobs enqueue below (dispatch_if_worker)
|
||||
# is the ONE submit path. Set STUDIO_LEGACY_PUSH_ROUTER=1 only to resurrect
|
||||
# the old push seam. Keeps exactly one way onto a GPU in prod.
|
||||
if not args.worker_mode and os.environ.get("STUDIO_LEGACY_PUSH_ROUTER") == "1":
|
||||
org_id = self._get_org_id(request)
|
||||
try:
|
||||
route_result = await prompt_router.route_prompt(org_id, json_data)
|
||||
@@ -1100,11 +1128,22 @@ class PromptServer():
|
||||
# every checkpoint-referencing graph ("not in []").
|
||||
if not args.worker_mode:
|
||||
try:
|
||||
from middleware.gpu_dispatch import dispatch_if_worker
|
||||
from middleware.gpu_dispatch import dispatch_if_worker, has_models_for
|
||||
except ImportError: # package-style checkout
|
||||
from .middleware.gpu_dispatch import dispatch_if_worker
|
||||
from .middleware.gpu_dispatch import dispatch_if_worker, has_models_for
|
||||
if dispatch_if_worker(request, org_id, prompt_id, prompt):
|
||||
return web.json_response({"prompt_id": prompt_id, "number": number, "node_errors": {}})
|
||||
# Dispatch didn't happen. If THIS box lacks the models THIS graph
|
||||
# needs (a coordinator pod, even one with a stray SD1.5), local
|
||||
# validation is GUARANTEED to fail with a cryptic "not in []" — the
|
||||
# render belongs on the org's GPU worker, momentarily unreachable.
|
||||
# Return a clear, retryable error instead of the doomed validation.
|
||||
# A box that CAN load the graph falls through and renders it.
|
||||
if not has_models_for(prompt):
|
||||
return web.json_response({"error": {
|
||||
"type": "gpu_worker_unavailable",
|
||||
"message": "GPU render worker is momentarily unavailable — please retry.",
|
||||
"details": "", "extra_info": {}}, "node_errors": {}}, status=503)
|
||||
|
||||
valid = await execution.validate_prompt(prompt_id, prompt, partial_execution_targets)
|
||||
extra_data = {}
|
||||
@@ -1473,6 +1512,17 @@ class PromptServer():
|
||||
web.static('/docs', embedded_docs_path)
|
||||
])
|
||||
|
||||
# Serve the unified Hanzo marketing shell (header + footer bundle) used by
|
||||
# the logged-out login page. Built from web/shell into web/marketing and
|
||||
# committed; assets are public (.js/.css) so they load pre-auth.
|
||||
marketing_path = os.path.join(
|
||||
os.path.dirname(os.path.realpath(__file__)), "web", "marketing"
|
||||
)
|
||||
if os.path.isdir(marketing_path):
|
||||
self.app.add_routes([
|
||||
web.static('/marketing', marketing_path)
|
||||
])
|
||||
|
||||
self.app.add_routes([
|
||||
web.static('/', self.web_root),
|
||||
])
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
# This file is automatically generated by the build process when version is
|
||||
# updated in pyproject.toml.
|
||||
__version__ = "0.17.3"
|
||||
__version__ = "0.18.2"
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
"""Office documents — the non-GPU document pipeline.
|
||||
|
||||
Covers the four concerns end to end:
|
||||
* catalog — every template well-formed, formats within archetype capability,
|
||||
wire shape JSON-serializable (no build fn leaks).
|
||||
* render — schema -> IR -> real bytes for EVERY template in EVERY declared
|
||||
format, asserted by magic numbers (%PDF, OOXML zip, markdown).
|
||||
* AI-fill parse — a mocked gateway completion -> coerced, typed fields; the
|
||||
forwardable-bearer allowlist is fail-closed (JWT/pk- only).
|
||||
* REST + tenancy — generate/library/get/patch/delete/download over a real aiohttp app,
|
||||
per-org isolation, honest 4xx, correct Content-Type/-Disposition.
|
||||
|
||||
Runs without a GPU, without torch, and without the WeasyPrint native stack (the built-in
|
||||
PDF fallback produces a valid %PDF), so it is safe for the dependency-light CI unit gate.
|
||||
"""
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# PDF (built-in fallback) and Markdown are pure-stdlib and always render. DOCX/XLSX/PPTX
|
||||
# need their wheel; when it is absent (a bare env) we SKIP that format instead of failing —
|
||||
# the canonical gate (requirements.txt installed) exercises every format for real.
|
||||
_FMT_LIB = {"docx": "docx", "xlsx": "openpyxl", "pptx": "pptx"}
|
||||
|
||||
|
||||
def _fmt_available(fmt: str) -> bool:
|
||||
lib = _FMT_LIB.get(fmt)
|
||||
return lib is None or importlib.util.find_spec(lib) is not None
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
import folder_paths
|
||||
from middleware import documents as D
|
||||
from middleware import doc_render as R
|
||||
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _valid(fmt: str, b: bytes) -> bool:
|
||||
if fmt == "pdf":
|
||||
return b[:5] == b"%PDF-" and b"%%EOF" in b[-64:]
|
||||
if fmt == "md":
|
||||
try:
|
||||
return len(b) > 10 and bool(b.decode("utf-8").strip()) # real, non-empty UTF-8 text
|
||||
except UnicodeDecodeError:
|
||||
return False
|
||||
try:
|
||||
return zipfile.ZipFile(io.BytesIO(b)).testzip() is None
|
||||
except zipfile.BadZipFile:
|
||||
return False
|
||||
|
||||
|
||||
def _jwt(alg: str) -> str:
|
||||
"""A JWT with the given header alg (payload/sig are inert — only the header matters
|
||||
to the forwardable-bearer allowlist)."""
|
||||
import base64
|
||||
h = base64.urlsafe_b64encode(json.dumps({"alg": alg, "typ": "JWT"}).encode()).rstrip(b"=").decode()
|
||||
return f"{h}.eyJzdWIiOiIxIn0.sig"
|
||||
|
||||
|
||||
_OOXML_PART = {"docx": "word/document.xml", "xlsx": "xl/workbook.xml", "pptx": "ppt/presentation.xml"}
|
||||
|
||||
|
||||
# ── catalog ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_catalog_has_all_common_templates():
|
||||
ids = {t["id"] for t in D.CATALOG}
|
||||
expected = {
|
||||
# Documents
|
||||
"resume", "cover_letter", "business_letter", "memo", "report", "proposal", "sow",
|
||||
"meeting_notes", "invoice", "receipt", "contract", "press_release", "newsletter",
|
||||
"one_pager", "certificate",
|
||||
# Spreadsheets
|
||||
"budget", "expense_tracker", "timesheet", "project_plan",
|
||||
# Presentations
|
||||
"pitch_deck", "business_review", "project_update",
|
||||
}
|
||||
assert expected <= ids, f"missing templates: {expected - ids}"
|
||||
assert len(D.CATALOG) == len(ids), "duplicate template ids"
|
||||
|
||||
|
||||
def test_catalog_invariants():
|
||||
for t in D.CATALOG:
|
||||
assert t["category"] in D.CATEGORY_ORDER
|
||||
assert t["formats"], f"{t['id']} has no formats"
|
||||
# every template's declared formats are within its archetype's capability
|
||||
kind, _ir = D.build_ir(t, t["sample"])
|
||||
assert set(t["formats"]) <= set(R.KIND_FORMATS[kind]), \
|
||||
f"{t['id']} ({kind}) declares formats outside {R.KIND_FORMATS[kind]}"
|
||||
# the primary (first) format is the archetype's native format
|
||||
assert t["formats"][0] == R.KIND_FORMATS[kind][0]
|
||||
# fields typed and keyed
|
||||
for f in t["fields"]:
|
||||
assert f["key"] and f["label"] and f["type"]
|
||||
|
||||
|
||||
def test_public_catalog_is_json_serializable_without_build_fn():
|
||||
grouped = D.catalog_grouped()
|
||||
blob = json.dumps({"categories": grouped}) # must not raise (build fn stripped)
|
||||
assert '"build"' not in blob
|
||||
names = [g["name"] for g in grouped]
|
||||
assert names == [c for c in D.CATEGORY_ORDER] # grouped in the canonical order
|
||||
total = sum(len(g["templates"]) for g in grouped)
|
||||
assert total == len(D.CATALOG)
|
||||
|
||||
|
||||
# ── render matrix: every template × every declared format ───────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("tmpl", D.CATALOG, ids=[t["id"] for t in D.CATALOG])
|
||||
def test_every_template_renders_every_format(tmpl):
|
||||
for fmt in tmpl["formats"]:
|
||||
if not _fmt_available(fmt):
|
||||
continue # wheel not installed here; the canonical gate covers it
|
||||
data, title = D.render_document(tmpl, tmpl["sample"], fmt)
|
||||
assert isinstance(data, (bytes, bytearray)) and data, f"{tmpl['id']}/{fmt} produced no bytes"
|
||||
assert _valid(fmt, data), f"{tmpl['id']}/{fmt} invalid magic bytes"
|
||||
if fmt in _OOXML_PART:
|
||||
names = zipfile.ZipFile(io.BytesIO(data)).namelist()
|
||||
assert _OOXML_PART[fmt] in names, f"{tmpl['id']}/{fmt} missing {_OOXML_PART[fmt]}"
|
||||
assert title
|
||||
|
||||
|
||||
def test_pdf_fallback_is_a_valid_pdf_without_weasyprint():
|
||||
# The built-in writer must stand alone (CI/pods without pango). Assert directly.
|
||||
ir = R.doc_ir("T", [R.title("Hello"), R.para("World " * 40),
|
||||
R.table(["A", "B"], [["1", "2"], ["3", "4"]], foot=[["", "sum"]]),
|
||||
R.bullets(["one", "two"]), R.signature("sig")])
|
||||
pdf = R._builtin_pdf(ir)
|
||||
assert pdf[:5] == b"%PDF-" and b"%%EOF" in pdf[-64:]
|
||||
assert pdf.count(b" obj") == pdf.count(b"endobj") # balanced objects
|
||||
assert b"/Root 1 0 R" in pdf and b"startxref" in pdf
|
||||
|
||||
|
||||
def test_invoice_math_flows_into_the_document():
|
||||
inv = D.get_template("invoice")
|
||||
fields = dict(inv["sample"], tax_rate=10, items=[
|
||||
{"description": "A", "qty": 2, "unit_price": 100}, # 200
|
||||
{"description": "B", "qty": 1, "unit_price": 50}, # 50 -> subtotal 250, tax 25, total 275
|
||||
])
|
||||
md = D.render_document(inv, fields, "md")[0].decode()
|
||||
assert "Subtotal" in md and "250.00" in md
|
||||
assert "275.00" in md # total = subtotal + 10% tax
|
||||
|
||||
|
||||
# ── AI-fill: parse + coercion + fail-closed bearer allowlist ────────────────────────
|
||||
|
||||
def test_parse_fill_from_tool_call_coerces_types():
|
||||
inv = D.get_template("invoice")
|
||||
completion = {"choices": [{"message": {"tool_calls": [{"function": {
|
||||
"name": "fill_document",
|
||||
"arguments": json.dumps({"fields": {
|
||||
"number": "INV-9", "to_name": "Acme Corp", "tax_rate": "8.5",
|
||||
"items": [{"description": "Consulting", "qty": "3", "unit_price": "$1,000"}],
|
||||
"bogus_key": "dropped",
|
||||
}}),
|
||||
}}]}}]}
|
||||
fields = D.parse_fill(completion, inv)
|
||||
assert fields["number"] == "INV-9"
|
||||
assert fields["tax_rate"] == 8.5 and isinstance(fields["tax_rate"], float)
|
||||
assert fields["items"][0] == {"description": "Consulting", "qty": 3.0, "unit_price": 1000.0}
|
||||
assert "bogus_key" not in fields # unknown keys dropped
|
||||
assert "from_name" in fields # every declared field present
|
||||
|
||||
|
||||
def test_parse_fill_from_embedded_json_content():
|
||||
memo = D.get_template("memo")
|
||||
completion = {"choices": [{"message": {"content":
|
||||
'Here you go: {"fields": {"subject": "Policy", "body": "Text"}}'}}]}
|
||||
fields = D.parse_fill(completion, memo)
|
||||
assert fields["subject"] == "Policy" and fields["body"] == "Text"
|
||||
|
||||
|
||||
def test_parse_fill_tolerates_garbage():
|
||||
memo = D.get_template("memo")
|
||||
assert D.parse_fill({}, memo) == {}
|
||||
assert D.parse_fill({"choices": [{"message": {"content": "no json here"}}]}, memo) == {}
|
||||
|
||||
|
||||
class _Req:
|
||||
def __init__(self, auth=None, cookies=None):
|
||||
self.headers = {"Authorization": auth} if auth else {}
|
||||
self.cookies = cookies or {}
|
||||
|
||||
|
||||
def test_forwardable_bearer_is_fail_closed(monkeypatch):
|
||||
for env in D._SERVER_TOKEN_ENV:
|
||||
monkeypatch.delenv(env, raising=False)
|
||||
rs, hs = _jwt("RS256"), _jwt("HS256")
|
||||
assert D._ai_token(_Req("Bearer " + rs)) == "Bearer " + rs # JWKS-verifiable JWT
|
||||
assert D._ai_token(_Req("Bearer " + hs)) == "" # HS256 session token REFUSED
|
||||
assert D._ai_token(_Req("Bearer pk-publishable")) == "Bearer pk-publishable"
|
||||
assert D._ai_token(_Req("Bearer sk-secret")) == "" # secret keys refused
|
||||
assert D._ai_token(_Req("Bearer hk-secret")) == ""
|
||||
assert D._ai_token(_Req("Bearer rk-secret")) == ""
|
||||
assert D._ai_token(_Req(None)) == "" # no token
|
||||
assert D._ai_token(_Req("Bearer notoken")) == "" # opaque, not JWT/pk-
|
||||
# session-cookie path (what the browser sends) is honored for a verifiable JWT
|
||||
assert D._ai_token(_Req(None, {"access_token": rs})) == "Bearer " + rs
|
||||
|
||||
|
||||
def test_ai_token_prefers_server_gateway_key(monkeypatch):
|
||||
for env in D._SERVER_TOKEN_ENV:
|
||||
monkeypatch.delenv(env, raising=False)
|
||||
hs = _jwt("HS256")
|
||||
# No server key + only an HS256 session token → no usable AI token (fail-closed → form).
|
||||
assert D._ai_token(_Req("Bearer " + hs)) == ""
|
||||
# A configured server gateway key (server-side only, never page source) is used and wins.
|
||||
monkeypatch.setenv("STUDIO_DOC_TOKEN", "hk-server-key")
|
||||
assert D._ai_token(_Req("Bearer " + hs)) == "Bearer hk-server-key"
|
||||
|
||||
|
||||
|
||||
# ── REST surface over a real aiohttp app ────────────────────────────────────────────
|
||||
# Driven on a self-managed event loop so these are plain SYNC tests — no dependency on
|
||||
# the pytest-asyncio / pytest-aiohttp async-mode configuration (which varies across
|
||||
# environments). One loop per test; the app + client live and die with the fixture.
|
||||
|
||||
import asyncio
|
||||
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
|
||||
class _Resp:
|
||||
"""A fully-materialized response (body read eagerly) with sync accessors."""
|
||||
|
||||
def __init__(self, status, headers, body):
|
||||
self.status = status
|
||||
self.headers = headers
|
||||
self._body = body
|
||||
|
||||
def json(self):
|
||||
return json.loads(self._body)
|
||||
|
||||
def read(self):
|
||||
return self._body
|
||||
|
||||
def text(self):
|
||||
return self._body.decode("utf-8", "replace")
|
||||
|
||||
|
||||
class _SyncClient:
|
||||
def __init__(self, tc, loop):
|
||||
self._tc = tc
|
||||
self._loop = loop
|
||||
|
||||
def _request(self, method, url, **kw):
|
||||
async def go():
|
||||
r = await self._tc.request(method, url, **kw)
|
||||
body = await r.read()
|
||||
return _Resp(r.status, dict(r.headers), body)
|
||||
return self._loop.run_until_complete(go())
|
||||
|
||||
def get(self, url, **kw):
|
||||
return self._request("GET", url, **kw)
|
||||
|
||||
def post(self, url, **kw):
|
||||
return self._request("POST", url, **kw)
|
||||
|
||||
def patch(self, url, **kw):
|
||||
return self._request("PATCH", url, **kw)
|
||||
|
||||
def delete(self, url, **kw):
|
||||
return self._request("DELETE", url, **kw)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
"""A live app with the documents routes and a tmp per-org tree. ``X-Test-Org`` header
|
||||
selects the tenant (the injected org_of), so one client exercises multi-tenancy."""
|
||||
def outd(org=None):
|
||||
d = tmp_path / "orgs" / (org or "default") / "output"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return str(d)
|
||||
|
||||
monkeypatch.setattr(folder_paths, "get_org_output_directory", outd)
|
||||
|
||||
def org_of(request):
|
||||
return request.headers.get("X-Test-Org", "default")
|
||||
|
||||
def owner_of(request):
|
||||
return request.headers.get("X-Test-User", "")
|
||||
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
D.add_documents_routes(routes, object(), org_of=org_of, owner_of=owner_of)
|
||||
app.add_routes(routes)
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
tc = TestClient(TestServer(app), loop=loop)
|
||||
loop.run_until_complete(tc.start_server())
|
||||
try:
|
||||
yield _SyncClient(tc, loop)
|
||||
finally:
|
||||
loop.run_until_complete(tc.close())
|
||||
loop.close()
|
||||
|
||||
|
||||
def _gen(client, template_id, **body):
|
||||
body["templateId"] = template_id
|
||||
return client.post("/v1/documents/generate", json=body)
|
||||
|
||||
|
||||
def test_catalog_endpoint(client):
|
||||
r = client.get("/v1/documents")
|
||||
assert r.status == 200
|
||||
cats = {g["name"] for g in r.json()["categories"]}
|
||||
assert {"Documents", "Spreadsheets", "Presentations"} <= cats
|
||||
|
||||
|
||||
def test_generate_from_fields_then_download_pdf_and_docx(client):
|
||||
"""The guarantee: a document renders end-to-end with NO LLM — form fields only."""
|
||||
inv = D.get_template("invoice")
|
||||
r = _gen(client, "invoice", fields=inv["sample"])
|
||||
assert r.status == 200, r.text()
|
||||
doc = r.json()
|
||||
assert doc["id"] and doc["title"].startswith("Invoice")
|
||||
assert set(doc["download"]) == set(inv["formats"])
|
||||
|
||||
fmts = [("pdf", "application/pdf")]
|
||||
if _fmt_available("docx"):
|
||||
fmts.append(("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"))
|
||||
for fmt, want_ct in fmts:
|
||||
dl = client.get(doc["download"][fmt])
|
||||
assert dl.status == 200
|
||||
assert dl.headers["Content-Type"] == want_ct
|
||||
assert "attachment;" in dl.headers["Content-Disposition"]
|
||||
assert dl.headers["Content-Disposition"].endswith(f'.{fmt}"')
|
||||
assert _valid(fmt, dl.read())
|
||||
|
||||
|
||||
def test_generate_each_archetype_native_format(client):
|
||||
for tid, fmt in (("resume", "pdf"), ("budget", "xlsx"), ("pitch_deck", "pptx")):
|
||||
if not _fmt_available(fmt):
|
||||
continue
|
||||
t = D.get_template(tid)
|
||||
doc = _gen(client, tid, fields=t["sample"]).json()
|
||||
dl = client.get(doc["download"][fmt])
|
||||
assert dl.status == 200
|
||||
assert dl.headers["Content-Type"] == R.content_type(fmt)
|
||||
assert _valid(fmt, dl.read())
|
||||
|
||||
|
||||
def test_generate_uses_ai_fill_when_description_given(client, monkeypatch):
|
||||
# Mock the gateway call: description -> canned fields (no network).
|
||||
async def fake_ai_fill(request, tmpl, description):
|
||||
assert "Acme" in description
|
||||
return D.coerce_fields(tmpl, {"to_name": "Acme Corp", "number": "INV-42",
|
||||
"items": [{"description": "Work", "qty": 1, "unit_price": 5000}]})
|
||||
monkeypatch.setattr(D, "ai_fill", fake_ai_fill)
|
||||
|
||||
doc = _gen(client, "invoice", description="Invoice for Acme Corp, $5,000, net-30").json()
|
||||
assert doc["fields"]["to_name"] == "Acme Corp"
|
||||
assert doc["title"] == "Invoice INV-42"
|
||||
md = client.get(doc["download"]["md"]).read()
|
||||
assert b"5,000.00" in md and b"Acme Corp" in md
|
||||
|
||||
|
||||
def test_explicit_fields_override_ai(client, monkeypatch):
|
||||
async def fake_ai_fill(request, tmpl, description):
|
||||
return D.coerce_fields(tmpl, {"subject": "AI subject", "body": "AI body"})
|
||||
monkeypatch.setattr(D, "ai_fill", fake_ai_fill)
|
||||
doc = _gen(client, "memo", description="anything", fields={"subject": "Human subject"}).json()
|
||||
assert doc["fields"]["subject"] == "Human subject" # explicit wins
|
||||
assert doc["fields"]["body"] == "AI body" # AI fills the gap
|
||||
|
||||
|
||||
def test_generate_fail_closed_without_token_or_fields(client, monkeypatch):
|
||||
# description given, but no server key and no forwardable token -> AI unavailable, and
|
||||
# no form fields to fall back to: honest 422 asking for fields, never a blank doc.
|
||||
for env in D._SERVER_TOKEN_ENV:
|
||||
monkeypatch.delenv(env, raising=False)
|
||||
r = _gen(client, "invoice", description="Invoice for Acme")
|
||||
assert r.status == 422
|
||||
j = r.json()
|
||||
assert j["needFields"] is True and j["template"]["id"] == "invoice"
|
||||
|
||||
|
||||
def test_generate_rejects_unknown_template_and_empty_body(client):
|
||||
assert _gen(client, "nope", fields={}).status == 404
|
||||
assert client.post("/v1/documents/generate", json={"templateId": "memo"}).status == 400
|
||||
|
||||
|
||||
def test_library_lists_generated_docs_newest_first(client):
|
||||
_gen(client, "memo", fields={"subject": "One", "body": "b"})
|
||||
_gen(client, "resume", fields=D.get_template("resume")["sample"])
|
||||
docs = client.get("/v1/documents/library").json()["documents"]
|
||||
assert len(docs) == 2
|
||||
assert docs[0]["templateId"] == "resume" # newest first
|
||||
assert {"id", "title", "templateId", "formats", "createdAt"} <= set(docs[0])
|
||||
|
||||
|
||||
def test_get_patch_reflected_in_download(client):
|
||||
doc_id = _gen(client, "memo", fields={"subject": "Before", "body": "b"}).json()["id"]
|
||||
got = client.get(f"/v1/documents/{doc_id}").json()
|
||||
assert got["fields"]["subject"] == "Before"
|
||||
assert got["template"]["id"] == "memo"
|
||||
|
||||
assert client.patch(f"/v1/documents/{doc_id}", json={"fields": {"subject": "After"}}).status == 200
|
||||
md = client.get(f"/v1/documents/{doc_id}/download?format=md").read()
|
||||
assert b"After" in md and b"Before" not in md
|
||||
|
||||
|
||||
def test_delete_removes_document(client):
|
||||
doc_id = _gen(client, "memo", fields={"subject": "x", "body": "b"}).json()["id"]
|
||||
assert client.delete(f"/v1/documents/{doc_id}").status == 200
|
||||
assert client.get(f"/v1/documents/{doc_id}").status == 404
|
||||
assert client.get("/v1/documents/library").json()["documents"] == []
|
||||
|
||||
|
||||
def test_tenancy_isolation_between_orgs(client):
|
||||
doc_id = client.post("/v1/documents/generate",
|
||||
json={"templateId": "memo", "fields": {"subject": "secret"}},
|
||||
headers={"X-Test-Org": "org-a"}).json()["id"]
|
||||
# org-b cannot see or download org-a's document
|
||||
assert client.get(f"/v1/documents/{doc_id}", headers={"X-Test-Org": "org-b"}).status == 404
|
||||
assert client.get(f"/v1/documents/{doc_id}/download?format=pdf",
|
||||
headers={"X-Test-Org": "org-b"}).status == 404
|
||||
assert client.get("/v1/documents/library", headers={"X-Test-Org": "org-b"}).json()["documents"] == []
|
||||
# org-a still has it
|
||||
assert client.get(f"/v1/documents/{doc_id}", headers={"X-Test-Org": "org-a"}).status == 200
|
||||
|
||||
|
||||
def test_download_rejects_bad_id_and_format(client):
|
||||
assert client.get("/v1/documents/not-a-real-id/download").status == 404
|
||||
doc_id = _gen(client, "memo", fields={"subject": "x", "body": "b"}).json()["id"]
|
||||
assert client.get(f"/v1/documents/{doc_id}/download?format=xlsx").status == 400 # not in memo formats
|
||||
|
||||
|
||||
# ── RED-review regressions (@e631d35f): XLSX formula injection, C0 control chars, crafted JWT alg ──
|
||||
|
||||
def test_xlsx_text_cell_formula_is_neutralized():
|
||||
# the guard: a formula-trigger text cell becomes inert text; numbers are untouched.
|
||||
assert R._xlsx_safe("=1+1") == "'=1+1"
|
||||
assert R._xlsx_safe("+a") == "'+a" and R._xlsx_safe("-b") == "'-b" and R._xlsx_safe("@c") == "'@c"
|
||||
assert R._xlsx_safe("plain") == "plain" and R._xlsx_safe(42) == 42 and R._xlsx_safe(3.5) == 3.5
|
||||
if not _fmt_available("xlsx"):
|
||||
pytest.skip("openpyxl not installed")
|
||||
import openpyxl
|
||||
tmpl = next(t for t in D.CATALOG if t["id"] == "budget")
|
||||
fields = {k: (list(v) if isinstance(v, list) else v) for k, v in tmpl["sample"].items()}
|
||||
rows = [dict(r) for r in fields.get("rows", [])] or [dict.fromkeys(("category", "planned", "actual"), "")]
|
||||
first_key = next(iter(rows[0]), "category")
|
||||
rows[0][first_key] = "=cmd|'/c calc.exe'!A1"
|
||||
fields["rows"] = rows
|
||||
data, _ = D.render_document(tmpl, fields, "xlsx")
|
||||
ws = openpyxl.load_workbook(io.BytesIO(data)).worksheets[0]
|
||||
cells = [c for row in ws.iter_rows() for c in row]
|
||||
assert all(c.data_type != "f" for c in cells), "no cell may be a live formula"
|
||||
hit = [c for c in cells if isinstance(c.value, str) and "calc.exe" in c.value]
|
||||
assert hit and all(not c.value.startswith("=") for c in hit), "the =-leading value was neutralized to text"
|
||||
|
||||
|
||||
def test_control_chars_stripped_and_ooxml_stays_valid():
|
||||
# C0 controls (illegal in XML 1.0) are stripped at the coerce boundary; \t \n \r survive.
|
||||
assert D._coerce("a\x00b\x0b\x1fc", "text") == "abc"
|
||||
assert D._coerce("keep\ttab\nnl\rcr", "textarea") == "keep\ttab\nnl\rcr"
|
||||
# end-to-end: a NUL/BEL in a field renders valid docx/pdf/md/xlsx — never a 500 / poison doc.
|
||||
memo = next(t for t in D.CATALOG if t["id"] == "memo")
|
||||
mf = dict(memo["sample"])
|
||||
mf["body"] = "line1\x00\x07line2"
|
||||
for fmt in [f for f in ("pdf", "docx", "md") if _fmt_available(f)]:
|
||||
data, _ = D.render_document(memo, mf, fmt)
|
||||
assert _valid(fmt, data), f"{fmt} must be valid after control-char strip"
|
||||
if _fmt_available("xlsx"):
|
||||
b = next(t for t in D.CATALOG if t["id"] == "budget")
|
||||
bf = {k: (list(v) if isinstance(v, list) else v) for k, v in b["sample"].items()}
|
||||
rows = [dict(r) for r in bf.get("rows", [])] or [{"category": "", "planned": 0, "actual": 0}]
|
||||
rows[0][next(iter(rows[0]), "category")] = "x\x00y"
|
||||
bf["rows"] = rows
|
||||
assert _valid("xlsx", D.render_document(b, bf, "xlsx")[0])
|
||||
|
||||
|
||||
def test_ai_token_delegates_to_canonical_gateway_auth(monkeypatch):
|
||||
"""After the decomplect, documents._ai_token routes through the ONE gateway_auth
|
||||
allowlist (no private copy — they used to drift). Prove the fail-closed behavior via
|
||||
the public _ai_token: a crafted non-string alg must not 500; HS256/secret refused;
|
||||
pk-/asymmetric forwarded; a server key wins."""
|
||||
for env in D._SERVER_TOKEN_ENV:
|
||||
monkeypatch.delenv(env, raising=False)
|
||||
|
||||
def jwt_with_alg(alg_value): # a JWT header carrying an arbitrary (maybe non-string) alg
|
||||
import base64
|
||||
h = base64.urlsafe_b64encode(json.dumps({"alg": alg_value}).encode()).rstrip(b"=").decode()
|
||||
return f"{h}.eyJzdWIiOiIxIn0.sig"
|
||||
|
||||
for bad in (None, [], 123, {"x": 1}, True):
|
||||
assert D._ai_token(_Req("Bearer " + jwt_with_alg(bad))) == "" # crafted alg → no 500
|
||||
assert D._ai_token(_Req("Bearer " + jwt_with_alg("HS256"))) == "" # HS256 session token refused
|
||||
assert D._ai_token(_Req("Bearer sk-secret")) == "" # secret refused
|
||||
assert D._ai_token(_Req("Bearer pk-x")) == "Bearer pk-x" # publishable forwarded
|
||||
assert D._ai_token(_Req("Bearer " + jwt_with_alg("RS256"))) == "Bearer " + jwt_with_alg("RS256")
|
||||
monkeypatch.setenv("STUDIO_DOC_TOKEN", "sk-server") # server key wins
|
||||
assert D._ai_token(_Req("Bearer pk-x")) == "Bearer sk-server"
|
||||
@@ -0,0 +1,50 @@
|
||||
"""library.json write integrity — the curation-wipe class.
|
||||
|
||||
A fixed shared tmp let two build-manifest sidecars (one per pod during a roll)
|
||||
interleave into one inode -> torn JSON -> readers get {} -> every asset resets to
|
||||
draft. These pin: unique tmp per write, no fixed-tmp residue, concurrent writers
|
||||
never produce a torn/invalid file, and the shared lock file coordinates."""
|
||||
|
||||
import concurrent.futures
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "scripts"))
|
||||
|
||||
import library_manifest as lm
|
||||
|
||||
|
||||
def _doc(n):
|
||||
return {"_meta": {"count": n}, "assets": [{"path": f"a{i}.png", "status": "approved"} for i in range(n)]}
|
||||
|
||||
|
||||
def test_write_atomic_uses_unique_tmp_no_fixed_residue(tmp_path):
|
||||
root = str(tmp_path)
|
||||
lm.write_atomic(root, _doc(3))
|
||||
# the manifest is valid and complete
|
||||
doc = json.loads((tmp_path / "library.json").read_text())
|
||||
assert len(doc["assets"]) == 3
|
||||
# the old fixed shared tmp must never be the write path
|
||||
assert not (tmp_path / ".library.json.tmp").exists()
|
||||
|
||||
|
||||
def test_concurrent_writes_never_tear(tmp_path):
|
||||
root = str(tmp_path)
|
||||
# many writers hammering the same manifest — under the fixed-tmp bug this tore;
|
||||
# with unique tmp + atomic rename every reader sees a COMPLETE valid doc.
|
||||
def w(n):
|
||||
lm.write_atomic(root, _doc(n))
|
||||
return json.loads((tmp_path / "library.json").read_text()) # read must always parse
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
|
||||
results = list(ex.map(w, [5, 10, 15, 20, 25, 30, 35, 40] * 3))
|
||||
for doc in results:
|
||||
assert "assets" in doc and isinstance(doc["assets"], list) # never {} from a torn read
|
||||
final = json.loads((tmp_path / "library.json").read_text())
|
||||
assert len(final["assets"]) in (5, 10, 15, 20, 25, 30, 35, 40) # one whole writer won, not a splice
|
||||
|
||||
|
||||
def test_library_lock_serializes(tmp_path):
|
||||
root = str(tmp_path)
|
||||
with lm.library_lock(root):
|
||||
assert (tmp_path / lm.LOCK_NAME).exists() # lock file materializes and is held
|
||||
@@ -89,6 +89,15 @@ def test_count_outputs_handles_empty_and_none():
|
||||
assert bm.count_outputs({"outputs": {"1": {"text": "no media here"}}}) == 0
|
||||
|
||||
|
||||
def test_count_outputs_counts_video_extension_agnostic():
|
||||
"""Metering is extension-agnostic: a SaveVideo history entry (its output reported
|
||||
through `images` by ui.PreviewVideo) meters exactly like an image render — the RFC's
|
||||
shared-untouched claim."""
|
||||
hist = {"outputs": {"12": {"images": [
|
||||
{"filename": "video/x_00001_.mp4", "subfolder": "video", "type": "output"}]}}}
|
||||
assert bm.count_outputs(hist) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# record_render — the fire-and-forget metered event
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -5,6 +5,7 @@ node types are passed in as a set, so this stays as light as the other
|
||||
middleware tests. Async paths run via asyncio.run to avoid a plugin dependency.
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
|
||||
from middleware import copilot
|
||||
@@ -12,6 +13,39 @@ from middleware import copilot
|
||||
VALID = {"KSampler", "SaveImage", "UpscaleModelLoader", "ImageUpscaleWithModel", "CLIPTextEncode"}
|
||||
|
||||
|
||||
def _jwt(alg: str) -> str:
|
||||
h = base64.urlsafe_b64encode(json.dumps({"alg": alg, "typ": "JWT"}).encode()).rstrip(b"=").decode()
|
||||
return f"{h}.eyJzdWIiOiIxIn0.sig"
|
||||
|
||||
|
||||
class _Req:
|
||||
def __init__(self, auth=None, cookies=None):
|
||||
self.headers = {"Authorization": auth} if auth else {}
|
||||
self.cookies = cookies or {}
|
||||
|
||||
|
||||
# ------------------------------------------------- _resolve_target token allowlist
|
||||
def test_resolve_target_never_forwards_hs256_session_token(monkeypatch):
|
||||
"""The regression the whole task turns on: the copilot MUST NOT forward the browser's
|
||||
HS256 session token (gateway → "unsupported signing method: HS256" → 502). It forwards
|
||||
only a gateway-verifiable caller token, and a server key wins."""
|
||||
for env in copilot._SERVER_TOKEN_ENV:
|
||||
monkeypatch.delenv(env, raising=False)
|
||||
hs, rs = _jwt("HS256"), _jwt("RS256")
|
||||
# HS256 session token (header OR cookie) → NOT forwarded
|
||||
assert copilot._resolve_target(_Req("Bearer " + hs))[2] == ""
|
||||
assert copilot._resolve_target(_Req(None, {"access_token": hs}))[2] == ""
|
||||
# a JWKS-verifiable JWT → forwarded (bare token, no "Bearer " prefix — _post_chat adds it)
|
||||
assert copilot._resolve_target(_Req("Bearer " + rs))[2] == rs
|
||||
# a publishable pk- key → forwarded
|
||||
assert copilot._resolve_target(_Req("Bearer pk-xyz"))[2] == "pk-xyz"
|
||||
# a secret key → never forwarded
|
||||
assert copilot._resolve_target(_Req("Bearer sk-secret"))[2] == ""
|
||||
# a configured server gateway key wins, stripped to the bare token
|
||||
monkeypatch.setenv("STUDIO_COPILOT_TOKEN", "hk-server")
|
||||
assert copilot._resolve_target(_Req("Bearer " + hs))[2] == "hk-server"
|
||||
|
||||
|
||||
# --------------------------------------------------------------- validate_ops
|
||||
def test_valid_ops_pass_through():
|
||||
ops = [
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""The ONE fail-closed gateway-bearer allowlist (middleware/gateway_auth.py).
|
||||
|
||||
This is the credential seam that keeps Chat + copilot reaching api.hanzo.ai instead of
|
||||
502-ing on the browser's HS256 session token. The invariants pinned here:
|
||||
|
||||
* a symmetric HS256 hanzo.id session token is NEVER forwarded (the gateway rejects it —
|
||||
"unsupported signing method: HS256");
|
||||
* a secret sk-/hk-/rk- key is refused outright (never leaves the server, never in page
|
||||
source);
|
||||
* only a publishable pk- key or an asymmetric/JWKS JWT (RS/ES/PS/EdDSA) is forwarded;
|
||||
* a configured server-side gateway key wins over any caller token.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from middleware import gateway_auth as G
|
||||
|
||||
|
||||
def _jwt(alg: str) -> str:
|
||||
"""A JWT with the given header alg (payload/sig inert — only the header alg matters)."""
|
||||
h = base64.urlsafe_b64encode(json.dumps({"alg": alg, "typ": "JWT"}).encode()).rstrip(b"=").decode()
|
||||
return f"{h}.eyJzdWIiOiIxIn0.sig"
|
||||
|
||||
|
||||
class _Req:
|
||||
"""The 2 attributes gpu_dispatch._bearer reads: Authorization header + cookies."""
|
||||
def __init__(self, auth=None, cookies=None):
|
||||
self.headers = {"Authorization": auth} if auth else {}
|
||||
self.cookies = cookies or {}
|
||||
|
||||
|
||||
def test_forwardable_bearer_is_fail_closed():
|
||||
rs, es, ps, ed, hs = _jwt("RS256"), _jwt("ES256"), _jwt("PS256"), _jwt("EdDSA"), _jwt("HS256")
|
||||
# asymmetric / JWKS-verifiable JWTs — forwarded
|
||||
assert G.forwardable_bearer(_Req("Bearer " + rs)) == "Bearer " + rs
|
||||
assert G.forwardable_bearer(_Req("Bearer " + es)) == "Bearer " + es
|
||||
assert G.forwardable_bearer(_Req("Bearer " + ps)) == "Bearer " + ps
|
||||
assert G.forwardable_bearer(_Req("Bearer " + ed)) == "Bearer " + ed
|
||||
# the HS256 hanzo.id session token — REFUSED (this is the 502 the fix closes)
|
||||
assert G.forwardable_bearer(_Req("Bearer " + hs)) == ""
|
||||
# publishable key — allowed
|
||||
assert G.forwardable_bearer(_Req("Bearer pk-publishable")) == "Bearer pk-publishable"
|
||||
# secret key families — refused outright, case-insensitively
|
||||
for secret in ("sk-secret", "SK-Secret", "hk-secret", "rk-secret", "sk_secret", "hk_secret"):
|
||||
assert G.forwardable_bearer(_Req("Bearer " + secret)) == ""
|
||||
# no token / opaque non-JWT non-pk value
|
||||
assert G.forwardable_bearer(_Req(None)) == ""
|
||||
assert G.forwardable_bearer(_Req("Bearer notoken")) == ""
|
||||
|
||||
|
||||
def test_forwardable_bearer_reads_session_cookie():
|
||||
# The browser sends the session as a cookie, not an Authorization header. A verifiable
|
||||
# JWT there is honored; an HS256 session cookie is still refused.
|
||||
rs, hs = _jwt("RS256"), _jwt("HS256")
|
||||
assert G.forwardable_bearer(_Req(None, {"access_token": rs})) == "Bearer " + rs
|
||||
assert G.forwardable_bearer(_Req(None, {"hanzo_token": rs})) == "Bearer " + rs
|
||||
assert G.forwardable_bearer(_Req(None, {"access_token": hs})) == ""
|
||||
|
||||
|
||||
def test_server_token_precedence_and_normalization(monkeypatch):
|
||||
envs = ("A_TOK", "B_TOK")
|
||||
for e in envs:
|
||||
monkeypatch.delenv(e, raising=False)
|
||||
assert G.server_token(envs) == ""
|
||||
monkeypatch.setenv("B_TOK", "hk-second")
|
||||
assert G.server_token(envs) == "Bearer hk-second" # bare value gets a Bearer prefix
|
||||
monkeypatch.setenv("A_TOK", "Bearer hk-first")
|
||||
assert G.server_token(envs) == "Bearer hk-first" # first wins; already-prefixed kept as-is
|
||||
|
||||
|
||||
def test_ai_bearer_server_key_wins_else_forwardable(monkeypatch):
|
||||
envs = ("SRV_TOK",)
|
||||
monkeypatch.delenv("SRV_TOK", raising=False)
|
||||
hs, rs = _jwt("HS256"), _jwt("RS256")
|
||||
# no server key + only an HS256 session → nothing usable (fail-closed → honest degrade)
|
||||
assert G.ai_bearer(_Req("Bearer " + hs), server_env=envs) == ""
|
||||
# no server key + a verifiable caller token → that token is forwarded
|
||||
assert G.ai_bearer(_Req("Bearer " + rs), server_env=envs) == "Bearer " + rs
|
||||
# a configured server key wins over any caller token
|
||||
monkeypatch.setenv("SRV_TOK", "hk-server-key")
|
||||
assert G.ai_bearer(_Req("Bearer " + rs), server_env=envs) == "Bearer hk-server-key"
|
||||
|
||||
|
||||
def test_forwardable_bearer_survives_crafted_non_string_alg():
|
||||
"""A JWT header with a non-string alg (null/list/number/dict) must yield "" — never
|
||||
a self-inflicted 500 from alg[:2]/alg.startswith. Mirrors the documents.py RED fix."""
|
||||
def jwt_bad_alg(alg_value):
|
||||
h = base64.urlsafe_b64encode(json.dumps({"alg": alg_value}).encode()).rstrip(b"=").decode()
|
||||
return f"{h}.eyJzdWIiOiIxIn0.sig"
|
||||
for bad in (None, [], 123, {"x": 1}, True):
|
||||
assert G.jwt_alg(jwt_bad_alg(bad)) == ""
|
||||
assert G.forwardable_bearer(_Req("Bearer " + jwt_bad_alg(bad))) == ""
|
||||
assert G.jwt_alg(jwt_bad_alg("RS256")) == "RS256" # real asymmetric alg still read
|
||||
assert G.forwardable_bearer(_Req("Bearer " + jwt_bad_alg("RS256"))) == "Bearer " + jwt_bad_alg("RS256")
|
||||
|
||||
|
||||
def test_alg_allowlist_is_the_exact_jws_set_not_a_prefix_match():
|
||||
"""F3: a prefix match ("RS"/"Ed") admitted non-JWS names like RSA-OAEP / 'Edwina';
|
||||
the allowlist is now the exact JWS asymmetric set."""
|
||||
for good in ("RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "EdDSA"):
|
||||
assert G.forwardable_bearer(_Req("Bearer " + _jwt(good))) == "Bearer " + _jwt(good)
|
||||
for bad in ("RSA-OAEP", "RSA1_5", "Edwina", "ES", "RS", "none", "HS256", "PSK"):
|
||||
assert G.forwardable_bearer(_Req("Bearer " + _jwt(bad))) == ""
|
||||
@@ -31,3 +31,285 @@ def test_no_online_gpu_when_all_offline(monkeypatch):
|
||||
])
|
||||
assert g._online_gpu_nodes("tok") == []
|
||||
assert g._has_online_gpu("tok") is False
|
||||
|
||||
|
||||
# ── The cloud pod NEVER renders locally ──────────────────────────────────────────
|
||||
|
||||
class _Req:
|
||||
"""Minimal stand-in for an aiohttp request that _bearer reads."""
|
||||
def __init__(self, auth="Bearer u"):
|
||||
self.headers = {"Authorization": auth} if auth else {}
|
||||
self.cookies = {}
|
||||
|
||||
|
||||
class _Resp:
|
||||
def read(self):
|
||||
return b"{}"
|
||||
|
||||
|
||||
def _capture_enqueue(monkeypatch):
|
||||
"""Mock the gpu-jobs POST + input collection; return a dict the enqueue fills."""
|
||||
sent: dict = {}
|
||||
|
||||
def fake_urlopen(req, timeout=0):
|
||||
sent["url"] = req.full_url
|
||||
sent["auth"] = req.headers.get("Authorization")
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(g.urllib.request, "urlopen", fake_urlopen)
|
||||
monkeypatch.setattr(g, "_collect_input_images", lambda prompt, org: [])
|
||||
monkeypatch.setattr(g, "_track_dispatched",
|
||||
lambda org, pid, prompt, node="gpu": sent.update(node=node))
|
||||
return sent
|
||||
|
||||
|
||||
def test_modelless_pod_enqueues_even_when_org_sees_no_gpu(monkeypatch):
|
||||
"""The karma case: the org-scoped fleet read shows no GPU (workers live under a
|
||||
sibling org), but a MODEL-LESS pod must still enqueue to gpu-jobs and wait — never
|
||||
self-POST the GPU-less pod (the instant 'failed validation')."""
|
||||
monkeypatch.setattr(g, "_online_gpu_nodes", lambda tok: []) # org-blind
|
||||
monkeypatch.setattr(g, "has_models_for", lambda prompt: False) # cloud coordinator
|
||||
sent = _capture_enqueue(monkeypatch)
|
||||
ok = g.dispatch_if_worker(_Req("Bearer karma"), "karma", "pid1", {})
|
||||
assert ok is True # enqueued to the worker lane, NOT local
|
||||
assert "gpu-jobs" in sent["url"]
|
||||
assert sent["auth"] == "Bearer karma" # tenancy still flows from the user token
|
||||
assert sent["node"] == "gpu" # claimer decided at pick-up
|
||||
|
||||
|
||||
def test_modelless_pod_never_returns_local(monkeypatch):
|
||||
"""Even if the enqueue POST fails, a model-less pod returns False so server.py
|
||||
yields a retryable 503 — it must NEVER fall through to local validation."""
|
||||
monkeypatch.setattr(g, "_online_gpu_nodes", lambda tok: [])
|
||||
monkeypatch.setattr(g, "has_models_for", lambda prompt: False)
|
||||
monkeypatch.setattr(g, "_collect_input_images", lambda prompt, org: [])
|
||||
|
||||
def boom(req, timeout=0):
|
||||
raise OSError("gpu-jobs unreachable")
|
||||
|
||||
monkeypatch.setattr(g.urllib.request, "urlopen", boom)
|
||||
# False here means "not enqueued"; server.py then 503s on a model-less pod. The
|
||||
# point: dispatch never claims a local render happened.
|
||||
assert g.dispatch_if_worker(_Req("Bearer karma"), "karma", "pid", {}) is False
|
||||
|
||||
|
||||
def test_box_with_models_renders_local_when_no_worker(monkeypatch):
|
||||
"""A box that HAS its own models (local dev / worker node) still renders locally
|
||||
when it sees no online worker — unchanged behavior."""
|
||||
monkeypatch.setattr(g, "_online_gpu_nodes", lambda tok: [])
|
||||
monkeypatch.setattr(g, "has_models_for", lambda prompt: True)
|
||||
touched = {"urlopen": False}
|
||||
monkeypatch.setattr(g.urllib.request, "urlopen",
|
||||
lambda *a, **k: touched.update(urlopen=True) or _Resp())
|
||||
assert g.dispatch_if_worker(_Req(), "acme", "pid", {}) is False
|
||||
assert touched["urlopen"] is False # never touched the gpu-jobs API
|
||||
|
||||
|
||||
def test_online_worker_enqueues_with_its_label(monkeypatch):
|
||||
monkeypatch.setattr(g, "_online_gpu_nodes",
|
||||
lambda tok: [{"name": "spark", "status": "online", "gpu": True}])
|
||||
monkeypatch.setattr(g, "has_models_for", lambda prompt: True) # an online worker wins
|
||||
sent = _capture_enqueue(monkeypatch)
|
||||
assert g.dispatch_if_worker(_Req(), "acme", "pid", {}) is True
|
||||
assert sent["node"] == "spark"
|
||||
|
||||
|
||||
def test_no_token_is_not_a_local_render_claim(monkeypatch):
|
||||
assert g.dispatch_if_worker(_Req(auth=""), "acme", "pid", {}) is False
|
||||
|
||||
|
||||
# ── The capacity gate is advisory + service-identity ─────────────────────────────
|
||||
|
||||
def test_service_token_prefers_worker_then_commerce(monkeypatch):
|
||||
monkeypatch.setenv("STUDIO_WORKER_TOKEN", "w")
|
||||
monkeypatch.setenv("STUDIO_COMMERCE_TOKEN", "c")
|
||||
assert g._service_token() == "Bearer w"
|
||||
monkeypatch.delenv("STUDIO_WORKER_TOKEN")
|
||||
assert g._service_token() == "Bearer c"
|
||||
monkeypatch.delenv("STUDIO_COMMERCE_TOKEN")
|
||||
assert g._service_token() == ""
|
||||
|
||||
|
||||
def test_advisory_gate_reads_fleet_with_service_identity(monkeypatch):
|
||||
"""karma's user token sees no GPU; the SERVICE token sees the fleet's worker — the
|
||||
gate reports online, so every org's UI reflects true availability."""
|
||||
seen = []
|
||||
|
||||
def fleet(tok):
|
||||
seen.append(tok)
|
||||
return [{"name": "spark", "status": "online", "gpu": True}] if tok == "Bearer svc" else []
|
||||
|
||||
monkeypatch.setattr(g, "_fleet_machines", fleet)
|
||||
monkeypatch.setenv("STUDIO_WORKER_TOKEN", "svc")
|
||||
monkeypatch.delenv("STUDIO_COMMERCE_TOKEN", raising=False)
|
||||
assert g.has_online_gpu_advisory("Bearer karma") is True
|
||||
assert seen[0] == "Bearer svc" # service identity consulted FIRST
|
||||
|
||||
|
||||
def test_advisory_gate_falls_back_to_user_token(monkeypatch):
|
||||
monkeypatch.delenv("STUDIO_WORKER_TOKEN", raising=False)
|
||||
monkeypatch.delenv("STUDIO_COMMERCE_TOKEN", raising=False)
|
||||
monkeypatch.setattr(g, "_fleet_machines",
|
||||
lambda tok: [{"name": "spark", "status": "online", "gpu": True}]
|
||||
if tok == "Bearer user" else [])
|
||||
assert g.has_online_gpu_advisory("Bearer user") is True
|
||||
|
||||
|
||||
def test_advisory_gate_false_when_nothing_online(monkeypatch):
|
||||
monkeypatch.delenv("STUDIO_WORKER_TOKEN", raising=False)
|
||||
monkeypatch.delenv("STUDIO_COMMERCE_TOKEN", raising=False)
|
||||
monkeypatch.setattr(g, "_fleet_machines", lambda tok: [])
|
||||
assert g.has_online_gpu_advisory("Bearer user") is False
|
||||
|
||||
|
||||
# ── Per-GPU targeting: X-Target-GPU → taskQueue "gpu:<identity>" ──────────────────
|
||||
|
||||
class _ReqH(_Req):
|
||||
"""_Req plus an arbitrary header map (so a test can set X-Target-GPU)."""
|
||||
def __init__(self, auth="Bearer u", headers=None):
|
||||
super().__init__(auth)
|
||||
if headers:
|
||||
self.headers.update(headers)
|
||||
|
||||
|
||||
def _capture_body(monkeypatch):
|
||||
"""Capture the enqueue POST body so a test can assert taskQueue + the URL namespace."""
|
||||
import json as _json
|
||||
sent: dict = {}
|
||||
|
||||
def fake_urlopen(req, timeout=0):
|
||||
sent["url"] = req.full_url
|
||||
sent["body"] = _json.loads(req.data.decode()) if req.data else {}
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(g.urllib.request, "urlopen", fake_urlopen)
|
||||
monkeypatch.setattr(g, "_collect_input_images", lambda prompt, org: [])
|
||||
monkeypatch.setattr(g, "_track_dispatched",
|
||||
lambda org, pid, prompt, node="gpu": sent.update(node=node))
|
||||
return sent
|
||||
|
||||
|
||||
def test_untargeted_uses_shared_gpu_jobs_lane(monkeypatch):
|
||||
"""No X-Target-GPU → the shared 'gpu-jobs' lane any worker drains; namespace unchanged."""
|
||||
monkeypatch.setattr(g, "_online_gpu_nodes", lambda tok: [])
|
||||
monkeypatch.setattr(g, "has_models_for", lambda prompt: False)
|
||||
sent = _capture_body(monkeypatch)
|
||||
assert g.dispatch_if_worker(_Req("Bearer acme"), "acme", "pid", {}) is True
|
||||
assert sent["body"]["taskQueue"] == "gpu-jobs" # shared lane
|
||||
assert "namespaces/gpu-jobs/activities" in sent["url"] # namespace is always gpu-jobs
|
||||
assert sent["node"] == "gpu"
|
||||
|
||||
|
||||
def test_x_target_gpu_routes_to_that_machine_lane(monkeypatch):
|
||||
"""A targeted render — where the target IS one of the caller-org's own online GPUs —
|
||||
enqueues on the machine's OWN lane 'gpu:<identity>' (namespace still gpu-jobs) and is
|
||||
tracked to that node, so only spark claims it. An in-fleet target ALWAYS enqueues,
|
||||
even on a box that could render the graph locally."""
|
||||
monkeypatch.setattr(g, "_online_gpu_nodes",
|
||||
lambda tok: [{"name": "spark", "status": "online", "gpu": True}])
|
||||
monkeypatch.setattr(g, "has_models_for", lambda prompt: True) # would render local…
|
||||
sent = _capture_body(monkeypatch)
|
||||
ok = g.dispatch_if_worker(_ReqH("Bearer acme", {"X-Target-GPU": "spark"}), "acme", "pid", {})
|
||||
assert ok is True # …but an in-fleet target forces the queue
|
||||
assert sent["body"]["taskQueue"] == "gpu:spark" # the machine's own lane
|
||||
assert "namespaces/gpu-jobs/activities" in sent["url"] # namespace unchanged
|
||||
assert sent["node"] == "spark" # tracked to the targeted GPU
|
||||
|
||||
|
||||
def test_out_of_fleet_target_falls_back_to_shared(monkeypatch):
|
||||
"""A target that is NOT one of the caller-org's own online GPUs is ignored — the job
|
||||
rides the shared lane, never a 'gpu:<bogus>' lane no worker claims (a 30-min hang),
|
||||
and an injected label (XSS payload) never becomes a taskQueue / queue-row node."""
|
||||
monkeypatch.setattr(g, "_online_gpu_nodes",
|
||||
lambda tok: [{"name": "spark", "status": "online", "gpu": True}])
|
||||
monkeypatch.setattr(g, "has_models_for", lambda prompt: False)
|
||||
sent = _capture_body(monkeypatch)
|
||||
ok = g.dispatch_if_worker(
|
||||
_ReqH("Bearer acme", {"X-Target-GPU": "<img src=x onerror=alert(1)>"}), "acme", "pid", {})
|
||||
assert ok is True
|
||||
assert sent["body"]["taskQueue"] == "gpu-jobs" # bogus target → shared lane, not gpu:<payload>
|
||||
assert sent["node"] == "spark" # node decided by the real fleet, not the header
|
||||
|
||||
|
||||
def test_blank_target_header_falls_back_to_shared(monkeypatch):
|
||||
"""An empty/whitespace X-Target-GPU is not a target — shared lane."""
|
||||
monkeypatch.setattr(g, "_online_gpu_nodes", lambda tok: [])
|
||||
monkeypatch.setattr(g, "has_models_for", lambda prompt: False)
|
||||
sent = _capture_body(monkeypatch)
|
||||
assert g.dispatch_if_worker(_ReqH("Bearer acme", {"X-Target-GPU": " "}), "acme", "pid", {}) is True
|
||||
assert sent["body"]["taskQueue"] == "gpu-jobs"
|
||||
|
||||
|
||||
# ── A BYO-GPU video job records its REAL prefix, not the "render" fallback ─────────
|
||||
def test_track_savevideo(tmp_path, monkeypatch):
|
||||
import json
|
||||
monkeypatch.setattr(g.folder_paths, "get_org_output_directory", lambda o=None: str(tmp_path))
|
||||
graph = {"7": {"class_type": "LoadImage", "inputs": {"image": "ref.png"}},
|
||||
"12": {"class_type": "SaveVideo", "inputs": {"filename_prefix": "video/beach_ab12cd"}}}
|
||||
g._track_dispatched("acme", "pid-v", graph, node="spark")
|
||||
jobs = json.loads((tmp_path / "render_jobs.json").read_text())
|
||||
assert jobs[-1]["outPrefix"] == "video/beach_ab12cd" # the video prefix, not "render"
|
||||
assert jobs[-1]["prefix"] == "beach_ab12cd"
|
||||
assert jobs[-1]["refs"] == ["ref.png"] and jobs[-1]["node"] == "spark"
|
||||
|
||||
|
||||
# --- enqueue retry across the cloud tasks-backend deploy gap ---
|
||||
import urllib.error # noqa: E402
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
def read(self):
|
||||
return b"{}"
|
||||
|
||||
|
||||
def test_enqueue_retries_through_503_deploy_gap(monkeypatch):
|
||||
"""A 503 'no available server' (cloud Recreate deploy gap) is retried; the
|
||||
enqueue succeeds once the pod is back — no user-visible failure."""
|
||||
monkeypatch.setattr(g.time, "sleep", lambda *_: None) # no real backoff wait
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_urlopen(req, timeout=90):
|
||||
calls["n"] += 1
|
||||
if calls["n"] < 3: # first two attempts hit the empty-pool 503
|
||||
raise urllib.error.HTTPError("u", 503, "no available server", {}, None)
|
||||
return _FakeResp()
|
||||
|
||||
monkeypatch.setattr(g.urllib.request, "urlopen", fake_urlopen)
|
||||
g._enqueue_with_retry(object()) # must NOT raise
|
||||
assert calls["n"] == 3 # retried twice, succeeded on the 3rd
|
||||
|
||||
|
||||
def test_enqueue_does_not_retry_4xx(monkeypatch):
|
||||
"""A 4xx (auth/validation) is not a deploy gap — raise immediately, no retry."""
|
||||
monkeypatch.setattr(g.time, "sleep", lambda *_: None)
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_urlopen(req, timeout=90):
|
||||
calls["n"] += 1
|
||||
raise urllib.error.HTTPError("u", 403, "forbidden", {}, None)
|
||||
|
||||
monkeypatch.setattr(g.urllib.request, "urlopen", fake_urlopen)
|
||||
try:
|
||||
g._enqueue_with_retry(object())
|
||||
raised = False
|
||||
except urllib.error.HTTPError:
|
||||
raised = True
|
||||
assert raised and calls["n"] == 1 # one shot, no retry
|
||||
|
||||
|
||||
def test_enqueue_does_not_retry_timeout(monkeypatch):
|
||||
"""A timeout may have been processed server-side — never retry (no double render)."""
|
||||
monkeypatch.setattr(g.time, "sleep", lambda *_: None)
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_urlopen(req, timeout=90):
|
||||
calls["n"] += 1
|
||||
raise urllib.error.URLError(TimeoutError("timed out"))
|
||||
|
||||
monkeypatch.setattr(g.urllib.request, "urlopen", fake_urlopen)
|
||||
try:
|
||||
g._enqueue_with_retry(object())
|
||||
raised = False
|
||||
except urllib.error.URLError:
|
||||
raised = True
|
||||
assert raised and calls["n"] == 1
|
||||
|
||||
@@ -4,10 +4,14 @@ The JWT path is exercised end to end with a locally generated RSA keypair:
|
||||
sign an access token like Hanzo IAM would, then verify it through the same
|
||||
code the middleware uses. Covers signature, exp, iss, aud and the org claim.
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlencode, urlsplit
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
from middleware import iam_auth_middleware as m
|
||||
@@ -144,6 +148,14 @@ def test_protected_paths(path):
|
||||
assert m._is_public_path(path) is False
|
||||
|
||||
|
||||
def test_video_never_public():
|
||||
"""A tenant video is only ever served by /v1/library/file?path=… (the URL path never
|
||||
ends in a media suffix), so the suffix bypass cannot apply. ".mp4" must NEVER join the
|
||||
static-suffix allowlist, and the org-scoped file route stays auth-gated — pinned here."""
|
||||
assert m._is_public_path("/a/b.mp4") is False
|
||||
assert m._is_public_path("/v1/library/file") is False
|
||||
|
||||
|
||||
# --- server-owned login entry point (/login → OIDC code flow) ---
|
||||
|
||||
def test_login_path_is_public():
|
||||
@@ -196,3 +208,147 @@ def test_handle_login_returns_to_app_root_not_the_login_path():
|
||||
# State is HMAC-signed with the client_id key (see _sign_state usage).
|
||||
payload = m._verify_state(cookie, "hanzo-studio")
|
||||
assert payload is not None and payload["t"] == "/"
|
||||
|
||||
|
||||
# --- browser-vs-API gate: anon navigation → 302 /login, XHR/JSON → 401 ---
|
||||
|
||||
def _gate(headers):
|
||||
"""Run the auth middleware for an unauthenticated GET /studio with the given
|
||||
request headers; return the response. localhost_bypass is off so the token
|
||||
gate actually runs (prod sits behind the ingress, never loopback)."""
|
||||
mw = m.create_iam_auth_middleware("https://hanzo.id", localhost_bypass=False)
|
||||
|
||||
async def _ok(_request):
|
||||
return web.Response(text="ok")
|
||||
|
||||
req = make_mocked_request("GET", "/studio", headers=headers)
|
||||
return asyncio.run(mw(req, _ok))
|
||||
|
||||
|
||||
def test_anon_browser_navigation_redirects_to_login():
|
||||
"""A top-level browser navigation (Accept: text/html) to a protected page is
|
||||
bounced to the server-owned /login entry point, carrying ?next so the OIDC
|
||||
round trip returns to /studio — never a raw 401."""
|
||||
resp = _gate({"Accept": "text/html,application/xhtml+xml"})
|
||||
assert resp.status == 302
|
||||
loc = resp.headers["Location"]
|
||||
assert loc.startswith("/login?")
|
||||
assert parse_qs(urlsplit(loc).query)["next"] == ["/studio"]
|
||||
|
||||
|
||||
def test_anon_plain_curl_redirects_to_login():
|
||||
"""The confirmed repro: a bare client (Accept: */*, no fetch-metadata) is
|
||||
treated as a navigation and redirected, not 401'd."""
|
||||
resp = _gate({"Accept": "*/*"})
|
||||
assert resp.status == 302
|
||||
assert resp.headers["Location"].startswith("/login?next=")
|
||||
|
||||
|
||||
def test_anon_no_accept_header_redirects_to_login():
|
||||
"""No Accept header at all is still ambiguous, not an API call → redirect."""
|
||||
resp = _gate({})
|
||||
assert resp.status == 302
|
||||
assert "/login" in resp.headers["Location"]
|
||||
|
||||
|
||||
def test_anon_sec_fetch_navigate_redirects():
|
||||
"""Modern browsers stamp Sec-Fetch-Mode: navigate on top-level loads."""
|
||||
resp = _gate({"Accept": "*/*", "Sec-Fetch-Mode": "navigate"})
|
||||
assert resp.status == 302
|
||||
assert "/login" in resp.headers["Location"]
|
||||
|
||||
|
||||
def test_anon_xhr_gets_401():
|
||||
"""An XHR (X-Requested-With) must get a JSON 401 so the SPA can react, not a
|
||||
redirect it would follow into HTML."""
|
||||
resp = _gate({"Accept": "*/*", "X-Requested-With": "XMLHttpRequest"})
|
||||
assert resp.status == 401
|
||||
|
||||
|
||||
def test_anon_json_accept_gets_401():
|
||||
"""An explicit JSON client (Accept: application/json) is an API caller."""
|
||||
resp = _gate({"Accept": "application/json"})
|
||||
assert resp.status == 401
|
||||
|
||||
|
||||
def test_anon_fetch_cors_gets_401():
|
||||
"""A programmatic fetch() carries Sec-Fetch-Mode: cors — 401, not redirect."""
|
||||
resp = _gate({"Accept": "*/*", "Sec-Fetch-Mode": "cors"})
|
||||
assert resp.status == 401
|
||||
|
||||
|
||||
def test_is_api_request_predicate():
|
||||
def api(headers):
|
||||
return m._is_api_request(make_mocked_request("GET", "/studio", headers=headers))
|
||||
|
||||
assert api({"Accept": "*/*"}) is False # plain curl → nav
|
||||
assert api({"Accept": "text/html"}) is False # browser → nav
|
||||
assert api({}) is False # unknown → nav
|
||||
assert api({"Sec-Fetch-Mode": "navigate"}) is False # top-level nav
|
||||
assert api({"Sec-Fetch-Mode": "cors"}) is True # fetch/XHR
|
||||
assert api({"Sec-Fetch-Mode": "same-origin"}) is True # fetch/XHR
|
||||
assert api({"X-Requested-With": "XMLHttpRequest"}) is True # legacy AJAX
|
||||
assert api({"Accept": "application/json"}) is True # JSON client
|
||||
|
||||
|
||||
def test_safe_next_predicate():
|
||||
assert m._safe_next("/studio") == "/studio"
|
||||
assert m._safe_next("/studio?org=acme&run=1") == "/studio?org=acme&run=1"
|
||||
assert m._safe_next(None) == "/"
|
||||
assert m._safe_next("") == "/"
|
||||
assert m._safe_next("//evil.example/x") == "/" # protocol-relative
|
||||
assert m._safe_next("https://evil.example") == "/" # absolute
|
||||
assert m._safe_next("/\\evil.example") == "/" # backslash-relative
|
||||
assert m._safe_next("/login") == "/" # would loop
|
||||
assert m._safe_next("/login?next=/x") == "/" # would loop
|
||||
|
||||
|
||||
def test_handle_login_honors_next_param():
|
||||
"""A redirect from the auth gate carries ?next=/studio; login must return the
|
||||
user there after the OIDC round trip (the signed state proves the target)."""
|
||||
mw = m.create_iam_auth_middleware("https://hanzo.id")
|
||||
req = make_mocked_request(
|
||||
"GET", "/login?" + urlencode({"next": "/studio"}),
|
||||
headers={"X-Forwarded-Proto": "https", "X-Forwarded-Host": "studio.hanzo.ai"},
|
||||
)
|
||||
resp = asyncio.run(mw.handle_login(req))
|
||||
assert resp.status == 302
|
||||
payload = m._verify_state(resp.cookies[m._STATE_COOKIE].value, "hanzo-studio")
|
||||
assert payload["t"] == "/studio"
|
||||
|
||||
|
||||
def test_handle_login_rejects_open_redirect_next():
|
||||
"""An off-origin, protocol-relative or self-referential next is dropped to
|
||||
'/', closing the open-redirect and login-loop vectors."""
|
||||
mw = m.create_iam_auth_middleware("https://hanzo.id")
|
||||
for bad in ("//evil.example/x", "https://evil.example", "/login"):
|
||||
req = make_mocked_request(
|
||||
"GET", "/login?" + urlencode({"next": bad}),
|
||||
headers={"X-Forwarded-Proto": "https", "X-Forwarded-Host": "studio.hanzo.ai"},
|
||||
)
|
||||
resp = asyncio.run(mw.handle_login(req))
|
||||
payload = m._verify_state(resp.cookies[m._STATE_COOKIE].value, "hanzo-studio")
|
||||
assert payload["t"] == "/", f"next={bad!r} should be rejected"
|
||||
|
||||
|
||||
def test_anon_framed_navigation_gets_connect_card_not_redirect():
|
||||
"""A FRAMED navigation (the console embeds Studio; Sec-Fetch-Dest: iframe)
|
||||
must NOT be 302'd into the OIDC dance — the IdP's pages refuse framing and
|
||||
the frame dies on a browser error page. It gets the same-origin CONNECT
|
||||
card: 200 HTML with a top-level sign-in affordance + session poll, framable
|
||||
only by our own consoles."""
|
||||
resp = _gate({"Accept": "text/html", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Dest": "iframe"})
|
||||
assert resp.status == 200
|
||||
assert "text/html" in resp.headers["Content-Type"]
|
||||
assert "Connect Hanzo Studio" in resp.text
|
||||
assert "/login?" in resp.text and "next=%2Fstudio" in resp.text
|
||||
assert resp.headers["Cache-Control"] == "no-store"
|
||||
assert "frame-ancestors" in resp.headers["Content-Security-Policy"]
|
||||
|
||||
|
||||
def test_anon_top_level_navigation_still_redirects_with_dest_document():
|
||||
"""The framed branch keys on Sec-Fetch-Dest — a top-level document load
|
||||
(dest: document) keeps the classic 302 into /login."""
|
||||
resp = _gate({"Accept": "text/html", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Dest": "document"})
|
||||
assert resp.status == 302
|
||||
assert resp.headers["Location"].startswith("/login?")
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Quality gate — the verdict must survive REASONING judges.
|
||||
|
||||
zen-vl became a reasoning model: with a starved max_tokens the whole budget went
|
||||
to reasoning_content, content came back null, the strict parse saw nothing and
|
||||
the gate failed open on EVERY render (flagged count reached zero while corrupted
|
||||
outputs sat in the grid). These pin the fixed parsing."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
from middleware import quality_gate as qg
|
||||
|
||||
|
||||
def _completion(content=None, reasoning=None):
|
||||
msg = {"role": "assistant", "content": content}
|
||||
if reasoning is not None:
|
||||
msg["reasoning_content"] = reasoning
|
||||
return {"choices": [{"message": msg}]}
|
||||
|
||||
|
||||
def test_reply_reads_plain_content():
|
||||
assert qg._reply_text(_completion(content="FAIL — mottled skin")) == "FAIL — mottled skin"
|
||||
|
||||
|
||||
def test_reply_reads_parts_array():
|
||||
assert qg._reply_text(_completion(content=[{"type": "text", "text": "PASS"}])) == "PASS"
|
||||
|
||||
|
||||
def test_reply_falls_back_to_reasoning_content():
|
||||
got = qg._reply_text(_completion(content=None, reasoning="the skin shows mosaic damage. FAIL"))
|
||||
assert "FAIL" in got
|
||||
|
||||
|
||||
def test_reply_empty_when_nothing():
|
||||
assert qg._reply_text(_completion(content=None)) == ""
|
||||
|
||||
|
||||
def test_verdict_from_reasoning_reply_flags():
|
||||
reply = qg._reply_text(_completion(content=None, reasoning="Looking closely... verdict: FAIL"))
|
||||
assert qg.parse_verdict(reply) == "fail"
|
||||
|
||||
|
||||
def test_judge_payload_not_starved():
|
||||
import inspect
|
||||
src = inspect.getsource(qg.judge)
|
||||
assert '"max_tokens": 8,' not in src
|
||||
|
||||
|
||||
def test_verdict_last_token_wins_over_deliberation():
|
||||
assert qg.parse_verdict("I must answer PASS or FAIL. The skin is mottled: FAIL") == "fail"
|
||||
assert qg.parse_verdict("Could be a FAIL, but the artifacts are compression. PASS") == "pass"
|
||||
assert qg.parse_verdict("no verdict words here") == "inconclusive"
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Quality gate — the VIDEO judge (middleware/quality_gate.py).
|
||||
|
||||
The motion gate samples frames across a clip and asks the MOTION judge; a clear FAIL
|
||||
flags the row exactly like the image gate. A video whose frames can't be sampled is
|
||||
marked visibly UNGATED (gate='skipped' + an 'ungated' work-log event) — NEVER a silent
|
||||
pass. The image path still fails open, byte-for-byte unchanged.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from middleware import quality_gate as qg
|
||||
from middleware import worklog as wl
|
||||
|
||||
av = pytest.importorskip("av")
|
||||
np = pytest.importorskip("numpy")
|
||||
|
||||
|
||||
def _write_mp4(path: Path, frames: int = 6) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
c = av.open(str(path), mode="w")
|
||||
st = c.add_stream("libx264", rate=24)
|
||||
st.width, st.height, st.pix_fmt = 64, 64, "yuv420p"
|
||||
for i in range(frames):
|
||||
arr = np.full((64, 64, 3), (i * 40) % 255, dtype=np.uint8)
|
||||
fr = av.VideoFrame.from_ndarray(arr, format="rgb24")
|
||||
for pkt in st.encode(fr):
|
||||
c.mux(pkt)
|
||||
for pkt in st.encode():
|
||||
c.mux(pkt)
|
||||
c.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def orgdir(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(wl.folder_paths, "get_org_output_directory", lambda o=None: str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
# ── frame sampling ────────────────────────────────────────────────────────────────
|
||||
def test_frames_samples_video(tmp_path):
|
||||
mp4 = tmp_path / "clip.mp4"
|
||||
_write_mp4(mp4)
|
||||
frames = qg._frames(mp4)
|
||||
assert frames is not None and 1 <= len(frames) <= 3
|
||||
assert all(isinstance(b, str) and b for b in frames) # base64 JPEG strings
|
||||
|
||||
# a garbage container yields None (→ ungated, never a silent pass)
|
||||
bad = tmp_path / "bad.mp4"
|
||||
bad.write_bytes(b"\x00\x00\x00 ftyp" + b"garbage")
|
||||
assert qg._frames(bad) is None
|
||||
|
||||
|
||||
# ── the payload carries N frames + the MOTION prompt ──────────────────────────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_video_judge_payload(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
class _Resp:
|
||||
status = 200
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def json(self):
|
||||
return {"choices": [{"message": {"content": "PASS"}}]}
|
||||
|
||||
class _Session:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
def post(self, url, json=None, headers=None):
|
||||
seen["payload"] = json
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(qg.aiohttp, "ClientSession", _Session)
|
||||
reply = await qg.judge(["b64one", "b64two"], "Bearer u", qg.MOTION)
|
||||
assert reply == "PASS"
|
||||
content = seen["payload"]["messages"][0]["content"]
|
||||
texts = [c for c in content if c["type"] == "text"]
|
||||
images = [c for c in content if c["type"] == "image_url"]
|
||||
assert len(texts) == 1 and texts[0]["text"] == qg.MOTION
|
||||
assert len(images) == 2 # one part per frame
|
||||
|
||||
|
||||
# ── a clear FAIL flags the row ────────────────────────────────────────────────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_video_fail_flags(orgdir, monkeypatch):
|
||||
root = orgdir
|
||||
_write_mp4(root / "video" / "c.mp4")
|
||||
(root / "library.json").write_text(json.dumps(
|
||||
{"assets": [{"path": "video/c.mp4", "status": "draft"}]}))
|
||||
|
||||
async def fake_judge(images, bearer, prompt):
|
||||
assert prompt == qg.MOTION # the motion contract, not PROMPT
|
||||
return "FAIL — hair morphs between frames"
|
||||
|
||||
monkeypatch.setattr(qg, "judge", fake_judge)
|
||||
await qg.run("acme", root, "video/c.mp4", "")
|
||||
lib = json.loads((root / "library.json").read_text())
|
||||
assert lib["assets"][0]["status"] == "flagged"
|
||||
|
||||
|
||||
# ── an unsamplable video is UNGATED — badge + event, never silent ─────────────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_unreadable_video_is_ungated_not_silent(orgdir, monkeypatch):
|
||||
root = orgdir
|
||||
(root / "video").mkdir(parents=True, exist_ok=True)
|
||||
(root / "video" / "broken.mp4").write_bytes(b"\x00\x00\x00 ftyp" + b"not decodable")
|
||||
(root / "library.json").write_text(json.dumps(
|
||||
{"assets": [{"path": "video/broken.mp4", "status": "draft"}]}))
|
||||
# a work-log row for this output so the 'ungated' event has somewhere to land
|
||||
wl.record("acme", kind="video", prompt="", refs=[], uploads=[], parents=[],
|
||||
output_prefix="video/broken.mp4", pid="up-x", status="done")
|
||||
|
||||
async def never(*a, **k):
|
||||
raise AssertionError("judge must not be called for an unsamplable video")
|
||||
|
||||
monkeypatch.setattr(qg, "judge", never)
|
||||
await qg.run("acme", root, "video/broken.mp4", "")
|
||||
|
||||
lib = json.loads((root / "library.json").read_text())
|
||||
assert lib["assets"][0].get("gate") == "skipped" # the visible badge field
|
||||
assert lib["assets"][0]["status"] == "draft" # status untouched
|
||||
events = [e["s"] for r in wl.load("acme") for e in r.get("events", [])]
|
||||
assert "ungated" in events # the work-log trail, not silent
|
||||
|
||||
|
||||
# ── the image path still fails OPEN, unchanged ────────────────────────────────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_still_fails_open(orgdir, monkeypatch):
|
||||
root = orgdir
|
||||
(root / "x.png").write_bytes(b"not a real png") # unreadable → _frames None
|
||||
(root / "library.json").write_text(json.dumps(
|
||||
{"assets": [{"path": "x.png", "status": "draft"}]}))
|
||||
|
||||
async def never(*a, **k):
|
||||
raise AssertionError("judge must not run on an unreadable image")
|
||||
|
||||
monkeypatch.setattr(qg, "judge", never)
|
||||
await qg.run("acme", root, "x.png", "")
|
||||
lib = json.loads((root / "library.json").read_text())
|
||||
assert lib["assets"][0]["status"] == "draft" # fail open: not flagged
|
||||
assert "gate" not in lib["assets"][0] # and NOT marked skipped (images fail open)
|
||||
@@ -0,0 +1,366 @@
|
||||
"""Unit tests for the fix pipeline (middleware/studio_home.py):
|
||||
|
||||
* the fix/compose graphs name only models that exist on the worker AND carry none
|
||||
of the Flux-Kontext reference-method wiring that stippled Qwen edits;
|
||||
* the honesty guard flips an implausibly-fast completion to FAILED;
|
||||
* iteration hygiene falls the fix base back to the nearest clean ancestor.
|
||||
|
||||
Pure/filesystem only — no torch, no GPU, no network.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from middleware import studio_home as sh
|
||||
|
||||
# What the render worker actually has on disk (spark: models/{diffusion_models,
|
||||
# text_encoders,vae}). The graph must never name anything outside this.
|
||||
AVAILABLE = {
|
||||
"diffusion_models": {"qwen-image-edit-2511.safetensors", "flux1-fill-dev.safetensors",
|
||||
"flux-2-klein-4b.safetensors", "wan2.2_ti2v_5B_fp16.safetensors"},
|
||||
"text_encoders": {"qwen2.5-vl-7b.safetensors", "clip_l.safetensors",
|
||||
"t5xxl_fp16.safetensors", "flux2-text-encoder.safetensors"},
|
||||
"vae": {"qwen-image-vae.safetensors", "flux-ae.safetensors", "flux2-vae.safetensors"},
|
||||
}
|
||||
_LOADERS = {"UNETLoader": ("diffusion_models", "unet_name"),
|
||||
"CheckpointLoaderSimple": ("checkpoints", "ckpt_name"),
|
||||
"CLIPLoader": ("text_encoders", "clip_name"),
|
||||
"VAELoader": ("vae", "vae_name")}
|
||||
|
||||
|
||||
def _assert_models_exist(graph):
|
||||
checked = 0
|
||||
for node in graph.values():
|
||||
spec = _LOADERS.get(node.get("class_type"))
|
||||
if not spec:
|
||||
continue
|
||||
folder, key = spec
|
||||
name = node["inputs"][key]
|
||||
assert name in AVAILABLE[folder], f"{name!r} is not on the worker ({folder})"
|
||||
checked += 1
|
||||
return checked
|
||||
|
||||
|
||||
# ── graph construction ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_fix_graph_names_only_existing_models():
|
||||
g = sh._fix_graph("ref.png", "brighten the skin tone", "fixes/x")
|
||||
assert _assert_models_exist(g) == 3 # unet + clip + vae all verified
|
||||
|
||||
|
||||
def test_fix_graph_is_the_canonical_qwen_edit():
|
||||
"""ComfyUI's own Qwen-Image-Edit template: NO Flux-Kontext reference-method
|
||||
override (the stipple cause), and the SAME FluxKontextImageScale'd reference feeds
|
||||
BOTH the edit conditioning and the VAEEncode sampling latent — matched size, so no
|
||||
frame-within-frame border."""
|
||||
g = sh._fix_graph("ref.png", "recolor the top", "fixes/x")
|
||||
types = [n["class_type"] for n in g.values()]
|
||||
assert "FluxKontextMultiReferenceLatentMethod" not in types # the stipple cause
|
||||
assert types.count("TextEncodeQwenImageEditPlus") == 2 # positive + negative
|
||||
ks = next(n for n in g.values() if n["class_type"] == "KSampler")
|
||||
# LOW-denoise edit (default 0.25) of the SOURCE latent — never 1.0 (that is Regenerate).
|
||||
assert ks["inputs"]["denoise"] == sh.EDIT_DENOISE_DEFAULT == 0.25
|
||||
latent = g[ks["inputs"]["latent_image"][0]]
|
||||
assert latent["class_type"] == "VAEEncode"
|
||||
scaled = latent["inputs"]["pixels"] # the sampling latent's source
|
||||
assert g[scaled[0]]["class_type"] == "FluxKontextImageScale" # the scaled reference
|
||||
assert g["6"]["inputs"]["image1"] == scaled # conditioning uses the SAME scaled ref
|
||||
|
||||
|
||||
def test_compose_graph_clean_family_and_models():
|
||||
g = sh._compose_graph(["a.png", "b.png"], "merge the two looks", "composes/x")
|
||||
types = [n["class_type"] for n in g.values()]
|
||||
assert "FluxKontextMultiReferenceLatentMethod" not in types
|
||||
assert _assert_models_exist(g) == 3
|
||||
|
||||
|
||||
# ── the honesty guard ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_implausibly_fast_only_flags_unsampled_renders():
|
||||
now = 1_000_000
|
||||
r30 = {"ts": now, "params": {"steps": 30}}
|
||||
assert sh._implausibly_fast(r30, now + 1) is True # 1s for 30 steps: impossible
|
||||
assert sh._implausibly_fast(r30, now + 4) is True # <5s: impossible
|
||||
assert sh._implausibly_fast(r30, now + 350) is False # a real 30-step render
|
||||
assert sh._implausibly_fast({"ts": now, "params": {"steps": 4}}, now + 1) is False # exempt
|
||||
assert sh._implausibly_fast({"ts": now, "params": {}}, now + 1) is False # unknown steps
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def org_root(tmp_path, monkeypatch):
|
||||
"""A per-org output dir that is BOTH the library root and the worklog dir, as in
|
||||
the pod layout (orgs/<org>/output)."""
|
||||
def out_dir(o=None):
|
||||
d = tmp_path / "orgs" / (o or "default") / "output"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return str(d)
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", out_dir)
|
||||
return out_dir
|
||||
|
||||
|
||||
def _land(root: Path, rel: str, mtime: int):
|
||||
p = root / rel
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_bytes(b"\x89PNG\r\n")
|
||||
os.utime(p, (mtime, mtime))
|
||||
|
||||
|
||||
def test_finalize_landed_done_failed_and_stale(org_root):
|
||||
org = "acme"
|
||||
root = Path(org_root(org))
|
||||
for pid, prefix in (("fast", "fixes/fast"), ("slow", "fixes/slow"), ("stale", "fixes/stale")):
|
||||
sh.worklog.record(org, kind="fix", prompt="p", refs=[], uploads=[], parents=[],
|
||||
output_prefix=prefix, params={"steps": 30}, pid=pid, status="queued")
|
||||
ts = {r["id"]: r["ts"] for r in sh.worklog.load(org)}
|
||||
_land(root, "fixes/fast_00001_.png", ts["fast"] + 1) # landed 1s after dispatch
|
||||
_land(root, "fixes/slow_00001_.png", ts["slow"] + 350) # a real render
|
||||
_land(root, "fixes/stale_00001_.png", ts["stale"] - 100) # predates dispatch: sibling
|
||||
|
||||
rows = sh.worklog.load(org)
|
||||
sh._finalize_landed(org, root, rows, int(time.time()) + 500)
|
||||
|
||||
status = {r["id"]: r["status"] for r in sh.worklog.load(org)}
|
||||
assert status["fast"] == "failed" # honesty guard: engine did not sample
|
||||
assert status["slow"] == "done"
|
||||
assert status["stale"] == "queued" # pre-existing sibling, never delivered as this job
|
||||
failed = next(r for r in sh.worklog.load(org) if r["id"] == "fast")
|
||||
assert any("did not sample" in (e.get("note") or "") for e in failed.get("events", []))
|
||||
|
||||
|
||||
# ── iteration hygiene: clean-ancestor fallback ────────────────────────────────────
|
||||
|
||||
def test_clean_base_falls_back_to_nearest_clean_ancestor(org_root):
|
||||
org = "acme"
|
||||
root = Path(org_root(org))
|
||||
# lineage: root.png (approved) → fix1 (flagged) → fix2 (flagged, the chosen base)
|
||||
for rel in ("root.png", "fixes/root_00001_.png", "fixes/root_00001__00001_.png"):
|
||||
(root / rel).parent.mkdir(parents=True, exist_ok=True)
|
||||
(root / rel).write_bytes(b"\x89PNG\r\n")
|
||||
(root / "library.json").write_text(json.dumps({"assets": [
|
||||
{"path": "root.png", "status": "approved"},
|
||||
{"path": "fixes/root_00001_.png", "status": "flagged"},
|
||||
{"path": "fixes/root_00001__00001_.png", "status": "flagged"},
|
||||
]}))
|
||||
sh.worklog.record(org, kind="fix", prompt="a", refs=[], uploads=[], parents=["root.png"],
|
||||
output_prefix="fixes/root", pid="p1", status="done")
|
||||
sh.worklog.record(org, kind="fix", prompt="b", refs=[], uploads=[],
|
||||
parents=["fixes/root_00001_.png"],
|
||||
output_prefix="fixes/root_00001_", pid="p2", status="done")
|
||||
|
||||
# the flagged fix2 falls back to the approved root (skipping the flagged fix1)
|
||||
base, substituted = sh._clean_base(org, root, "fixes/root_00001__00001_.png")
|
||||
assert base == "root.png" and substituted is True
|
||||
# a clean base is returned unchanged
|
||||
assert sh._clean_base(org, root, "root.png") == ("root.png", False)
|
||||
assert sh._asset_status(root, "fixes/root_00001_.png") == "flagged"
|
||||
|
||||
|
||||
# ── Job identity: every dispatch owns a unique output namespace ──────────────────
|
||||
def test_job_tag_is_short_hex_and_unique():
|
||||
a, b = sh._job_tag(), sh._job_tag()
|
||||
assert len(a) == 6 and int(a, 16) >= 0
|
||||
assert a != b
|
||||
|
||||
|
||||
# ── View/pose-change routing: repose asks use a free latent, anchored fixes keep
|
||||
# the encoded-base latent — and the prompt tail never contradicts the ask ─────
|
||||
def test_fix_graph_anchored_default_keeps_pose():
|
||||
g = sh._fix_graph("x.png", "fix the coloring", "fixes/x")
|
||||
assert g["10"]["class_type"] == "VAEEncode"
|
||||
p = g["6"]["inputs"]["prompt"]
|
||||
assert "pose, framing" in p # preservation directive
|
||||
assert "apply only the requested change" in p.lower()
|
||||
|
||||
|
||||
def test_fix_graph_never_uses_empty_latent():
|
||||
"""Edit is ALWAYS a low-denoise img2img of the source — a pose/view change is now
|
||||
REGENERATE, not Edit, so the fix graph NEVER falls back to EmptyLatentImage
|
||||
(the old repose auto-switch, which drifted the whole image, is gone)."""
|
||||
for instruction in ("fix the coloring", "same outfit but from the side only",
|
||||
"make her standing and looking away"):
|
||||
g = sh._fix_graph("x.png", instruction, "fixes/x")
|
||||
assert not any("Empty" in n.get("class_type", "") for n in g.values()), instruction
|
||||
ks = next(n for n in g.values() if n["class_type"] == "KSampler")["inputs"]
|
||||
assert g[ks["latent_image"][0]]["class_type"] == "VAEEncode"
|
||||
assert sh._assert_edit_graph(g) == ""
|
||||
|
||||
|
||||
def test_repose_lexicon_matches_view_changes_only():
|
||||
hits = ["show me the same model but from the side only", "turn around",
|
||||
"back view please", "make her facing left", "different angle"]
|
||||
misses = ["fix the coloring", "brighten the skin slightly", "remove the second strap"]
|
||||
for t in hits:
|
||||
assert sh._REPOSE.search(t), t
|
||||
for t in misses:
|
||||
assert not sh._REPOSE.search(t), t
|
||||
|
||||
|
||||
# ── Flywheel retrieval: pointed-out mistakes condition the NEXT generation ──────
|
||||
def test_mistakes_selects_corrections_not_praise():
|
||||
ratings = {
|
||||
"a.png": {"vote": -1, "notes": "sleeves way too wide"},
|
||||
"b.png": {"stars": 1, "notes": "color washed out"},
|
||||
"c.png": {"vote": 1, "notes": "love this lighting"}, # praise — excluded
|
||||
"d.png": {"notes": "strap should be thinner"}, # bare note — a correction
|
||||
"e.png": {"vote": -1}, # no text — nothing to inject
|
||||
}
|
||||
out = sh._mistakes(ratings, ["a.png", "b.png", "c.png", "d.png", "e.png", "missing.png"])
|
||||
assert "sleeves way too wide" in out and "color washed out" in out
|
||||
assert "strap should be thinner" in out
|
||||
assert all("love this lighting" != m for m in out)
|
||||
assert len(out) <= 4
|
||||
|
||||
|
||||
def test_fix_graph_injects_lessons_into_both_encoders():
|
||||
g = sh._fix_graph("x.png", "redo the sleeves", "fixes/x",
|
||||
lessons=["sleeves way too wide", "color washed out"])
|
||||
pos, neg = g["6"]["inputs"]["prompt"], g["7"]["inputs"]["prompt"]
|
||||
assert "do not repeat" in pos and "sleeves way too wide" in pos
|
||||
assert "sleeves way too wide" in neg and "color washed out" in neg
|
||||
|
||||
|
||||
def test_compose_graph_injects_lessons():
|
||||
g = sh._compose_graph(["x.png", "y.png"], "combine them", "composes/x",
|
||||
lessons=["strap should be thinner"])
|
||||
assert "strap should be thinner" in g["6"]["inputs"]["prompt"]
|
||||
assert "strap should be thinner" in g["7"]["inputs"]["prompt"]
|
||||
|
||||
|
||||
# ── Ingest upsert identity: a counter-suffixed landed file belongs to its
|
||||
# dispatch row — mirroring must never add a duplicate ingest row ────────────
|
||||
def test_has_output_matches_counter_suffixed_files(tmp_path, monkeypatch):
|
||||
import middleware.worklog as wl
|
||||
monkeypatch.setattr(wl.folder_paths, "get_org_output_directory", lambda org: str(tmp_path))
|
||||
wl.record("acme", kind="fix", prompt="p", refs=[], uploads=[], parents=[],
|
||||
output_prefix="fixes/x_ab12cd")
|
||||
assert wl.has_output("acme", "fixes/x_ab12cd_00001_.png") # the row owns its landed file
|
||||
assert wl.has_output("acme", "fixes/x_ab12cd") # exact prefix still matches
|
||||
assert not wl.has_output("acme", "fixes/other_00001_.png") # unrelated file → new row ok
|
||||
|
||||
|
||||
# ── Job identity for re-run and template-render (the phantom-image class) ────────
|
||||
def test_retag_points_every_saveimage_at_unique_prefix():
|
||||
g = {"1": {"class_type": "KSampler", "inputs": {"seed": 1}},
|
||||
"9": {"class_type": "SaveImage", "inputs": {"filename_prefix": "fixes/original"}},
|
||||
"10": {"class_type": "SaveImage", "inputs": {"filename_prefix": "fixes/original"}}}
|
||||
sh._retag(g, "reruns/original_ab12cd")
|
||||
assert g["9"]["inputs"]["filename_prefix"] == "reruns/original_ab12cd"
|
||||
assert g["10"]["inputs"]["filename_prefix"] == "reruns/original_ab12cd"
|
||||
# a re-run graph never keeps the source's SaveImage prefix (that was the collision)
|
||||
assert all(n.get("inputs", {}).get("filename_prefix") != "fixes/original"
|
||||
for n in g.values() if n.get("class_type") == "SaveImage")
|
||||
|
||||
|
||||
# ── State isolation & job construction ────────────────────────────────────────────
|
||||
# An Edit is built from the SELECTED source alone; two concurrent edits of DIFFERENT
|
||||
# sources can never share a staged file, a LoadImage ref, a positive prompt, a
|
||||
# sampling latent or a parent. Content-addressed staging (name == sha256 of the
|
||||
# bytes) is the binding proof: the worker's LoadImage resolves the exact selected
|
||||
# image, and different sources are cryptographically un-collidable on a shared
|
||||
# worker input dir — the "unrelated random picture / wrong image" class of bug.
|
||||
from middleware import gpu_dispatch as gd
|
||||
|
||||
|
||||
def _pos_prompt(g):
|
||||
"""The positive prompt actually wired into KSampler (not read by node id)."""
|
||||
ks = next(n for n in g.values() if n["class_type"] == "KSampler")
|
||||
return g[ks["inputs"]["positive"][0]]["inputs"]["prompt"]
|
||||
|
||||
|
||||
def _load_ref(g):
|
||||
return next(n for n in g.values() if n["class_type"] == "LoadImage")["inputs"]["image"]
|
||||
|
||||
|
||||
def _save_prefix(g):
|
||||
return next(n for n in g.values() if n["class_type"] in sh._SAVERS)["inputs"]["filename_prefix"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def in_dir(tmp_path, monkeypatch):
|
||||
"""Per-org input dir (orgs/<org>/input) where sources are staged for the worker —
|
||||
patched on BOTH modules that touch it: studio_home stages, gpu_dispatch inlines."""
|
||||
def _d(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_input_directory", _d)
|
||||
monkeypatch.setattr(gd.folder_paths, "get_org_input_directory", _d)
|
||||
return _d
|
||||
|
||||
|
||||
def test_stage_source_is_content_addressed(in_dir, tmp_path):
|
||||
d = Path(in_dir("acme"))
|
||||
a = tmp_path / "a.png"
|
||||
a.write_bytes(b"AAAA-dress-bytes")
|
||||
b = tmp_path / "b.png"
|
||||
b.write_bytes(b"BBBB-clasp-bytes")
|
||||
na, nb = sh._stage_source(d, a, "fix"), sh._stage_source(d, b, "fix")
|
||||
# different bytes -> different name (cryptographic, not a salted-hash coincidence)
|
||||
assert na != nb
|
||||
# the name IS the sha256 of the bytes: a verifiable binding to the EXACT source
|
||||
assert na == f"fix_src_{hashlib.sha256(a.read_bytes()).hexdigest()[:16]}.png"
|
||||
assert (d / na).read_bytes() == a.read_bytes() # staged bytes are the source
|
||||
assert sh._stage_source(d, a, "fix") == na # idempotent: same source, same name
|
||||
a.write_bytes(b"AAAA-dress-bytez") # one byte changes the address
|
||||
assert sh._stage_source(d, a, "fix") != na
|
||||
|
||||
|
||||
def test_two_concurrent_edits_are_isolated(in_dir, tmp_path):
|
||||
"""The reported scenario: Job A (design A, 'make the dress red') and Job B
|
||||
(design B, 'add a silver clasp') constructed back-to-back share NOTHING."""
|
||||
d = Path(in_dir("acme"))
|
||||
srcA = tmp_path / "A.png"
|
||||
srcA.write_bytes(b"\x89PNG-design-A" + b"\x00" * 64)
|
||||
srcB = tmp_path / "B.png"
|
||||
srcB.write_bytes(b"\x89PNG-design-B" + b"\x00" * 64)
|
||||
refA, refB = sh._stage_source(d, srcA, "fix"), sh._stage_source(d, srcB, "fix")
|
||||
gA = sh._fix_graph(refA, "make the dress red", "fixes/A_" + sh._job_tag())
|
||||
gB = sh._fix_graph(refB, "add a silver clasp", "fixes/B_" + sh._job_tag())
|
||||
# 1) distinct staged sources, each graph LoadImages its OWN source
|
||||
assert refA != refB
|
||||
assert _load_ref(gA) == refA and _load_ref(gB) == refB
|
||||
# 2) prompts do not bleed across jobs
|
||||
assert "make the dress red" in _pos_prompt(gA) and "clasp" not in _pos_prompt(gA)
|
||||
assert "add a silver clasp" in _pos_prompt(gB) and "dress red" not in _pos_prompt(gB)
|
||||
# 3) both are true low-denoise edits of their own source — no text-to-image fallback
|
||||
assert sh._assert_edit_graph(gA) == "" and sh._assert_edit_graph(gB) == ""
|
||||
for g in (gA, gB):
|
||||
ks = next(n for n in g.values() if n["class_type"] == "KSampler")
|
||||
assert g[ks["inputs"]["latent_image"][0]]["class_type"] == "VAEEncode"
|
||||
# 4) distinct output namespaces (no phantom-image cross-claim)
|
||||
assert _save_prefix(gA) != _save_prefix(gB)
|
||||
|
||||
|
||||
def test_collect_input_images_ships_exact_source_and_is_org_scoped(in_dir, tmp_path):
|
||||
"""gpu_dispatch inlines the bytes the worker will LoadImage. It must ship the
|
||||
SELECTED org's copy exactly — never a same-named file from another org."""
|
||||
dA, dB = Path(in_dir("acme")), Path(in_dir("other"))
|
||||
src = tmp_path / "s.png"
|
||||
src.write_bytes(b"\x89PNG-real-source" + b"\x01" * 40)
|
||||
ref = sh._stage_source(dA, src, "fix")
|
||||
(dB / ref).write_bytes(b"\x89PNG-WRONG-org" + b"\x02" * 40) # decoy, same basename
|
||||
shipped = gd._collect_input_images(sh._fix_graph(ref, "brighten", "fixes/x"), "acme")
|
||||
got = {s["name"]: base64.b64decode(s["data"]) for s in shipped}
|
||||
assert ref in got and got[ref] == src.read_bytes() # acme's exact bytes
|
||||
assert b"WRONG-org" not in got[ref]
|
||||
|
||||
|
||||
def test_assert_edit_graph_rejects_text_to_image_fallback():
|
||||
"""The hard invariant: an Edit can NEVER be silently served by a text-to-image
|
||||
(EmptyLatentImage) graph. If source resolution ever produced one, the assert
|
||||
REJECTS it — dispatch raises, never renders an unrelated picture."""
|
||||
t2i = {
|
||||
"1": {"class_type": "EmptySD3LatentImage", "inputs": {"width": 1024, "height": 1024}},
|
||||
"2": {"class_type": "KSampler", "inputs": {"latent_image": ["1", 0], "denoise": 1.0}},
|
||||
"3": {"class_type": "SaveImage", "inputs": {"images": ["2", 0], "filename_prefix": "x"}},
|
||||
}
|
||||
reason = sh._assert_edit_graph(t2i)
|
||||
assert reason and "EmptyLatentImage" in reason # rejected with a clear cause
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Studio 3D lane — Hunyuan3D-2.1 image-to-mesh (.glb) over the shared library/queue/
|
||||
flywheel. Mirrors the video-lane tests: the proven graph builder, the dispatch route
|
||||
(org from the token, unique job tag, one required reference), and .glb ingest/serve."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
from middleware import studio_home as sh
|
||||
from middleware import worklog as wl
|
||||
|
||||
|
||||
class _Req:
|
||||
def __init__(self, *, body=None, query=None, org="acme"):
|
||||
self._body = body
|
||||
self.query = query or {}
|
||||
self.headers = {}
|
||||
self._user = {"org_id": org, "sub": f"u@{org}"} if org else None
|
||||
self.cookies = {}
|
||||
self.content_length = None
|
||||
def get(self, key, default=None):
|
||||
return self._user if key == "iam_user" else default
|
||||
async def json(self):
|
||||
return self._body if self._body is not None else {}
|
||||
|
||||
|
||||
class _FakeQueue:
|
||||
def get_tasks_remaining(self):
|
||||
return 0
|
||||
|
||||
|
||||
class _FakeServer:
|
||||
port = 1
|
||||
def __init__(self):
|
||||
self.prompt_queue = _FakeQueue()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def orgaware(tmp_path, monkeypatch):
|
||||
def outd(o="default"):
|
||||
d = tmp_path / "orgs" / o / "output"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return str(d)
|
||||
def ind(o="default"):
|
||||
d = tmp_path / "orgs" / o / "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))
|
||||
monkeypatch.setattr(wl.folder_paths, "get_org_output_directory", outd)
|
||||
return outd, ind
|
||||
|
||||
|
||||
def _handler(server, method, path, monkeypatch):
|
||||
monkeypatch.setattr(sh.stacks_store, "add_stacks_routes", lambda *a, **k: None)
|
||||
routes = web.RouteTableDef()
|
||||
sh.add_studio_home_routes(routes, server)
|
||||
for r in routes:
|
||||
if getattr(r, "method", None) == method and getattr(r, "path", None) == path:
|
||||
return r.handler
|
||||
raise AssertionError(f"no route {method} {path}")
|
||||
|
||||
|
||||
# ── 1. the proven Hunyuan3D graph ──────────────────────────────────────────────────
|
||||
def test_model3d_graph_is_the_proven_hunyuan3d():
|
||||
g = sh._model3d_graph("ref.png", "model3d/x_ab12cd", seed=7)
|
||||
cls = {k: v["class_type"] for k, v in g.items()}
|
||||
# the ONE bundled checkpoint (not separate loaders — that produced noise)
|
||||
assert g["1"]["class_type"] == "ImageOnlyCheckpointLoader"
|
||||
assert g["1"]["inputs"]["ckpt_name"] == sh._HY3D_CKPT
|
||||
# the flow-matching wrapper WITHOUT which the sampler yields fragments
|
||||
assert "ModelSamplingAuraFlow" in cls.values()
|
||||
assert g["5"]["inputs"]["shift"] == 1.0 and g["5"]["inputs"]["model"] == ["1", 0]
|
||||
# image → clip-vision → conditioning; latent is EmptyLatentHunyuan3Dv2(4096)
|
||||
assert g["2"]["inputs"]["image"] == "ref.png"
|
||||
assert g["6"]["inputs"]["resolution"] == 4096
|
||||
assert g["7"]["inputs"]["seed"] == 7 and g["7"]["inputs"]["cfg"] == 5.0
|
||||
# ends at SaveGLB with the unique prefix
|
||||
assert g["10"]["class_type"] == "SaveGLB"
|
||||
assert g["10"]["inputs"]["filename_prefix"] == "model3d/x_ab12cd"
|
||||
assert "VAEDecodeHunyuan3D" in cls.values() and "VoxelToMesh" in cls.values()
|
||||
|
||||
|
||||
# ── 2. dispatch + route (org from token, unique tag, one required reference) ────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_model3d_route(orgaware, monkeypatch):
|
||||
outd, _ = orgaware
|
||||
root = Path(outd("acme"))
|
||||
root.joinpath("library.json").write_text('{"assets":[]}')
|
||||
(root / "designs").mkdir(parents=True, exist_ok=True)
|
||||
from PIL import Image
|
||||
Image.new("RGB", (8, 8)).save(root / "designs" / "hero.png", "PNG")
|
||||
captured = {}
|
||||
async def fake_queue(request, server, graph):
|
||||
captured["graph"] = graph
|
||||
return "pid-3d"
|
||||
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
|
||||
model3d = _handler(_FakeServer(), "POST", "/v1/library/model3d", monkeypatch)
|
||||
|
||||
# a library image → 3D; 'org' in the body is ignored (token org wins)
|
||||
resp = await model3d(_Req(body={"path": "designs/hero.png", "org": "evil"}, org="acme"))
|
||||
assert resp.status == 200
|
||||
import re
|
||||
out = json.loads(resp.text)
|
||||
assert re.match(r"^model3d/.+_[0-9a-f]{6}$", out["output_prefix"]) # unique job tag
|
||||
rows = wl.load("acme")
|
||||
assert len(rows) == 1 and rows[0]["kind"] == "model3d"
|
||||
assert wl.load("evil") == []
|
||||
assert any(n.get("class_type") == "SaveGLB" for n in captured["graph"].values())
|
||||
|
||||
# NO reference → 400 (there is no text-to-3D)
|
||||
r2 = await model3d(_Req(body={}, org="acme"))
|
||||
assert r2.status == 400
|
||||
# both a path and an upload → 400
|
||||
r3 = await model3d(_Req(body={"path": "a.png", "upload": "u.png"}, org="acme"))
|
||||
assert r3.status == 400
|
||||
|
||||
|
||||
# ── 3. .glb ingest + serve ─────────────────────────────────────────────────────────
|
||||
def test_glb_is_recognized_as_model3d():
|
||||
assert ".glb" in sh._UPLOAD_EXT
|
||||
assert sh._CTYPE[".glb"] == "model/gltf-binary"
|
||||
assert ".glb" in sh._MODEL3D_EXT and ".glb" in sh._SAVE_EXT
|
||||
@@ -0,0 +1,493 @@
|
||||
"""Unit tests for the Studio video lane (middleware/studio_home.py):
|
||||
|
||||
* the Wan 2.2 TI2V-5B graph builder (t2v omits the reference node; clamps + snaps);
|
||||
* dispatch + the /v1/library/video route (org from the token, never the body);
|
||||
* landed-output + queue detection across .mp4/.webm (the honesty guard covers video);
|
||||
* the ONE indexed ingest door accepts video, defaults kind, judges, colour-matches;
|
||||
* the manifest sweeper keeps a video as kind="video";
|
||||
* serving + the first-frame poster (404 on decode failure, never the raw mp4);
|
||||
* embedded-metadata read parity (Versions / Re-run / provenance) and job re-tagging.
|
||||
|
||||
Filesystem + av only — no torch, no GPU, no network. av/numpy are hard studio deps.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from middleware import studio_home as sh
|
||||
from middleware import worklog as wl
|
||||
|
||||
av = pytest.importorskip("av")
|
||||
np = pytest.importorskip("numpy")
|
||||
|
||||
|
||||
# ── media fixtures: real, tiny, in-test-encoded ───────────────────────────────────
|
||||
def _write_mp4(path: Path, frames: int = 6, meta: dict | None = None) -> None:
|
||||
"""A tiny real mp4 (ftyp at offset 4). ``meta`` embeds container metadata the way
|
||||
SaveVideo does — via movflags=use_metadata_tags, so av reads it back."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
opts = {"movflags": "use_metadata_tags"} if meta else {}
|
||||
c = av.open(str(path), mode="w", options=opts)
|
||||
for k, v in (meta or {}).items():
|
||||
c.metadata[k] = v
|
||||
st = c.add_stream("libx264", rate=24)
|
||||
st.width, st.height, st.pix_fmt = 64, 64, "yuv420p"
|
||||
for i in range(frames):
|
||||
arr = np.full((64, 64, 3), (i * 40) % 255, dtype=np.uint8)
|
||||
fr = av.VideoFrame.from_ndarray(arr, format="rgb24")
|
||||
for pkt in st.encode(fr):
|
||||
c.mux(pkt)
|
||||
for pkt in st.encode():
|
||||
c.mux(pkt)
|
||||
c.close()
|
||||
|
||||
|
||||
def _write_webm(path: Path, frames: int = 6) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
c = av.open(str(path), mode="w")
|
||||
st = c.add_stream("libvpx-vp9", rate=24)
|
||||
st.width, st.height, st.pix_fmt = 64, 64, "yuv420p"
|
||||
for i in range(frames):
|
||||
arr = np.full((64, 64, 3), (i * 40) % 255, dtype=np.uint8)
|
||||
fr = av.VideoFrame.from_ndarray(arr, format="rgb24")
|
||||
for pkt in st.encode(fr):
|
||||
c.mux(pkt)
|
||||
for pkt in st.encode():
|
||||
c.mux(pkt)
|
||||
c.close()
|
||||
|
||||
|
||||
# ── request + server doubles ──────────────────────────────────────────────────────
|
||||
class _Req:
|
||||
"""A minimal stand-in for an aiohttp request the route handlers read: JSON body,
|
||||
query, headers, cookies, and the iam_user the middleware would have attached."""
|
||||
def __init__(self, *, body=None, raw=b"", query=None, headers=None,
|
||||
org="acme", cookies=None, content_length=None):
|
||||
self._body = body
|
||||
self._raw = raw
|
||||
self.query = query or {}
|
||||
self.headers = headers or {}
|
||||
self._user = {"org_id": org, "sub": f"u@{org}"} if org else None
|
||||
self.cookies = cookies or {}
|
||||
self.content_length = content_length if content_length is not None else (len(raw) or None)
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._user if key == "iam_user" else default
|
||||
|
||||
async def json(self):
|
||||
return self._body if self._body is not None else {}
|
||||
|
||||
async def read(self):
|
||||
return self._raw
|
||||
|
||||
|
||||
class _FakeQueue:
|
||||
def get_current_queue_volatile(self):
|
||||
return ([], [])
|
||||
|
||||
|
||||
class _FakeServer:
|
||||
port = 1
|
||||
|
||||
def __init__(self):
|
||||
self.prompt_queue = _FakeQueue()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
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))
|
||||
monkeypatch.setattr(wl.folder_paths, "get_org_output_directory", outd)
|
||||
return outd, ind
|
||||
|
||||
|
||||
def _handler(server, method, path, monkeypatch):
|
||||
"""Extract ONE registered route handler, without the stacks sub-router or a live app."""
|
||||
monkeypatch.setattr(sh.stacks_store, "add_stacks_routes", lambda *a, **k: None)
|
||||
routes = web.RouteTableDef()
|
||||
sh.add_studio_home_routes(routes, server)
|
||||
for r in routes:
|
||||
if getattr(r, "method", None) == method and getattr(r, "path", None) == path:
|
||||
return r.handler
|
||||
raise AssertionError(f"no route {method} {path}")
|
||||
|
||||
|
||||
def _lib(root: Path, assets):
|
||||
(root / "library.json").write_text(json.dumps({"assets": assets}))
|
||||
|
||||
|
||||
# ── 1. the graph builder ──────────────────────────────────────────────────────────
|
||||
def test_video_graph():
|
||||
# text-to-video: NO LoadImage node, and NO start_image key on the latent
|
||||
t2v = sh._video_graph("a woman walks on a sunlit beach", "video/x", length=73)
|
||||
assert "7" not in t2v
|
||||
assert "start_image" not in t2v["8"]["inputs"]
|
||||
assert t2v["8"]["inputs"]["length"] == 73 # 4n+1, unchanged
|
||||
|
||||
# image-to-video: LoadImage → start_image; clamp/snap of the dims + length
|
||||
g = sh._video_graph("beach scene", "video/y_ab12cd", ref_name="ref.png",
|
||||
width=650, height=641, length=72)
|
||||
assert g["7"]["class_type"] == "LoadImage" and g["7"]["inputs"]["image"] == "ref.png"
|
||||
assert g["8"]["inputs"]["start_image"] == ["7", 0]
|
||||
assert (g["8"]["inputs"]["width"], g["8"]["inputs"]["height"]) == (640, 640) # step of 32
|
||||
assert g["8"]["inputs"]["length"] == 69 # 72 → nearest 4n+1 below
|
||||
|
||||
ks = g["9"]["inputs"]
|
||||
assert ks["steps"] == 20 and ks["cfg"] == 5.0
|
||||
assert ks["sampler_name"] == "uni_pc" and ks["scheduler"] == "simple"
|
||||
assert g["4"]["class_type"] == "ModelSamplingSD3" and g["4"]["inputs"]["shift"] == 8
|
||||
sv = g["12"]
|
||||
assert sv["class_type"] == "SaveVideo"
|
||||
assert sv["inputs"] == {"video": ["11", 0], "filename_prefix": "video/y_ab12cd",
|
||||
"format": "mp4", "codec": "h264"}
|
||||
assert g["6"]["inputs"]["text"] == sh._WAN_NEG
|
||||
assert sh._WAN_NEG.startswith("色调艳丽") and "倒着走" in sh._WAN_NEG
|
||||
|
||||
# lessons condition BOTH encoders, exactly like _fix_graph
|
||||
gl = sh._video_graph("beach", "video/z", lessons=["hair morphs between frames"])
|
||||
assert "do not repeat" in gl["5"]["inputs"]["text"]
|
||||
assert "hair morphs between frames" in gl["5"]["inputs"]["text"]
|
||||
assert "hair morphs between frames" in gl["6"]["inputs"]["text"]
|
||||
|
||||
|
||||
# ── 2. dispatch + the route (org from the token, not the body) ─────────────────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_route(orgaware, monkeypatch):
|
||||
outd, _ = orgaware
|
||||
captured = {}
|
||||
|
||||
async def fake_queue(request, server, graph):
|
||||
captured["graph"] = graph
|
||||
return "pid-v"
|
||||
|
||||
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
|
||||
video = _handler(_FakeServer(), "POST", "/v1/library/video", monkeypatch)
|
||||
|
||||
# an 'org' field in the body is IGNORED — the row lands under the TOKEN org
|
||||
resp = await video(_Req(body={"prompt": "a woman walking on a beach at sunset",
|
||||
"org": "evil"}, org="acme"))
|
||||
assert resp.status == 200
|
||||
out = json.loads(resp.text)
|
||||
import re
|
||||
assert re.match(r"^video/.+_[0-9a-f]{6}$", out["output_prefix"])
|
||||
rows = wl.load("acme")
|
||||
assert len(rows) == 1 and rows[0]["kind"] == "video"
|
||||
assert rows[0]["lane"] in ("local", "gpu")
|
||||
assert wl.load("evil") == [] # never under the body's org
|
||||
# the graph that queued is the Wan graph (t2v: no LoadImage)
|
||||
assert any(n.get("class_type") == "SaveVideo" for n in captured["graph"].values())
|
||||
assert not any(n.get("class_type") == "LoadImage" for n in captured["graph"].values())
|
||||
|
||||
# prompt < 3 chars → 400
|
||||
r2 = await video(_Req(body={"prompt": "hi"}, org="acme"))
|
||||
assert r2.status == 400
|
||||
|
||||
# two references (a library path AND an upload) → 400
|
||||
r3 = await video(_Req(body={"prompt": "a valid video prompt", "path": "a.png",
|
||||
"upload": "u.png"}, org="acme"))
|
||||
assert r3.status == 400
|
||||
|
||||
|
||||
# ── 3. landed-output + the honesty guard, over video ──────────────────────────────
|
||||
def test_landed_video(orgaware):
|
||||
outd, _ = orgaware
|
||||
root = Path(outd("acme"))
|
||||
(root / "video").mkdir(parents=True, exist_ok=True)
|
||||
(root / "video" / "x_ab12cd_00001_.mp4").write_bytes(b"\x00\x00\x00 ftyp")
|
||||
assert sh._landed_output(root, "video/x_ab12cd") == "video/x_ab12cd_00001_.mp4"
|
||||
(root / "video" / "w_00001_.webm").write_bytes(b"\x1a\x45\xdf\xa3")
|
||||
assert sh._landed_output(root, "video/w") == "video/w_00001_.webm"
|
||||
|
||||
# child-prefix must NOT be swallowed: "a" never claims "a_00001__00001_.mp4"
|
||||
(root / "video" / "a_00001__00001_.mp4").write_bytes(b"\x00\x00\x00 ftyp")
|
||||
assert sh._landed_output(root, "video/a") is None
|
||||
|
||||
# png unchanged
|
||||
(root / "fixes").mkdir(parents=True, exist_ok=True)
|
||||
(root / "fixes" / "y_00001_.png").write_bytes(b"\x89PNG\r\n")
|
||||
assert sh._landed_output(root, "fixes/y") == "fixes/y_00001_.png"
|
||||
|
||||
# _finalize_landed marks the video row done at the file mtime …
|
||||
wl.record("acme", kind="video", prompt="p", refs=[], uploads=[], parents=[],
|
||||
output_prefix="video/done", params={"steps": 20}, pid="dv", status="queued")
|
||||
ts = next(r["ts"] for r in wl.load("acme") if r["id"] == "dv")
|
||||
dp = root / "video" / "done_00001_.mp4"
|
||||
dp.write_bytes(b"\x00\x00\x00 ftyp")
|
||||
os.utime(dp, (ts + 200, ts + 200))
|
||||
rows = wl.load("acme")
|
||||
sh._finalize_landed("acme", root, rows, int(time.time()) + 500)
|
||||
assert next(r for r in wl.load("acme") if r["id"] == "dv")["status"] == "done"
|
||||
|
||||
# … but a <5s landing for a 20-step render is impossible → failed
|
||||
wl.record("acme", kind="video", prompt="p", refs=[], uploads=[], parents=[],
|
||||
output_prefix="video/fast", params={"steps": 20}, pid="fv", status="queued")
|
||||
tsf = next(r["ts"] for r in wl.load("acme") if r["id"] == "fv")
|
||||
fp = root / "video" / "fast_00001_.mp4"
|
||||
fp.write_bytes(b"\x00\x00\x00 ftyp")
|
||||
os.utime(fp, (tsf + 2, tsf + 2))
|
||||
rows = wl.load("acme")
|
||||
sh._finalize_landed("acme", root, rows, int(time.time()) + 500)
|
||||
failed = next(r for r in wl.load("acme") if r["id"] == "fv")
|
||||
assert failed["status"] == "failed"
|
||||
assert any("did not sample" in (e.get("note") or "") for e in failed.get("events", []))
|
||||
|
||||
|
||||
# ── 4. the live BYO-GPU queue drops a video job when its mp4 lands ─────────────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_queue_video(orgaware, monkeypatch):
|
||||
"""The render-queue is AUTHORITATIVE: it reads the org's gpu-jobs activities from the
|
||||
tasks engine and shows the REAL claiming GPU (activity.identity), dropping terminal
|
||||
jobs. A STARTED studio.render is live; a COMPLETED one falls off."""
|
||||
outd, _ = orgaware
|
||||
root = Path(outd("acme"))
|
||||
_lib(root, [])
|
||||
render_queue = _handler(_FakeServer(), "GET", "/v1/render-queue", monkeypatch)
|
||||
base = {
|
||||
"execution": {"runId": "p", "workflowId": "p"},
|
||||
"type": {"name": "studio.render"},
|
||||
"taskQueue": "gpu:spark", "identity": "spark",
|
||||
"input": {"prompt": {"12": {"class_type": "SaveVideo",
|
||||
"inputs": {"filename_prefix": "video/x"}}}},
|
||||
}
|
||||
|
||||
async def _started(_req):
|
||||
return [{**base, "status": "STARTED"}]
|
||||
monkeypatch.setattr(sh, "_list_gpu_jobs", _started)
|
||||
resp = await render_queue(_Req(org="acme"))
|
||||
jobs = json.loads(resp.text)["jobs"]
|
||||
assert len(jobs) == 1 and jobs[0]["node"] == "spark" # real claimer shown
|
||||
|
||||
async def _completed(_req):
|
||||
return [{**base, "status": "COMPLETED"}]
|
||||
monkeypatch.setattr(sh, "_list_gpu_jobs", _completed)
|
||||
resp = await render_queue(_Req(org="acme"))
|
||||
assert json.loads(resp.text)["jobs"] == [] # engine says done → dropped
|
||||
|
||||
|
||||
# ── 5. worklog params + per-kind ETA bucketing ────────────────────────────────────
|
||||
def test_graph_params_video(orgaware):
|
||||
outd, _ = orgaware
|
||||
g = sh._video_graph("a beach", "video/x", seed=7, length=73)
|
||||
p = sh._graph_params(g)
|
||||
for k in ("steps", "cfg", "sampler", "scheduler", "seed", "width", "height", "length", "fps"):
|
||||
assert k in p, k
|
||||
assert p["fps"] == 24 and p["length"] == 73 and p["seed"] == 7
|
||||
|
||||
wl.record("acme", kind="video", prompt="", refs=[], uploads=[], parents=[],
|
||||
output_prefix="video/a", pid="v1", status="queued")
|
||||
wl.mark_started("acme", "v1", 1000)
|
||||
wl.mark_finished("acme", "v1", 1120)
|
||||
wl.record("acme", kind="fix", prompt="", refs=[], uploads=[], parents=[],
|
||||
output_prefix="fixes/a", pid="f1", status="queued")
|
||||
wl.mark_started("acme", "f1", 1000)
|
||||
wl.mark_finished("acme", "f1", 1030)
|
||||
medians = wl.median_durations(wl.load("acme"))
|
||||
assert medians.get("video") == 120 and medians.get("fix") == 30 # bucketed apart
|
||||
|
||||
|
||||
# ── 6. the ONE indexed ingest door accepts video ──────────────────────────────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_video(orgaware, tmp_path, monkeypatch):
|
||||
outd, _ = orgaware
|
||||
root = Path(outd("acme"))
|
||||
_lib(root, [])
|
||||
scheduled = {}
|
||||
cm = {}
|
||||
monkeypatch.setattr(sh.quality_gate, "schedule",
|
||||
lambda request, org, r, rel: scheduled.update(rel=rel, org=org))
|
||||
monkeypatch.setattr(sh, "colormatch_fix_ingest",
|
||||
lambda request, sub, name: cm.update(sub=sub, name=name))
|
||||
monkeypatch.setattr(sh.stacks_store, "absorb", lambda *a, **k: None) # no stacks DB in this unit
|
||||
upload = _handler(_FakeServer(), "POST", "/v1/library/upload", monkeypatch)
|
||||
|
||||
src = tmp_path / "clip.mp4"
|
||||
_write_mp4(src)
|
||||
mp4 = src.read_bytes()
|
||||
|
||||
resp = await upload(_Req(raw=mp4, query={"name": "clip.mp4"}, org="acme"))
|
||||
body = json.loads(resp.text)
|
||||
assert resp.status == 200 and body["path"] == "renders/clip.mp4"
|
||||
assert (root / "renders" / "clip.mp4").is_file()
|
||||
# kind defaults to "video" by suffix
|
||||
lib = json.loads((root / "library.json").read_text())
|
||||
row = next(a for a in lib["assets"] if a["path"] == "renders/clip.mp4")
|
||||
assert row["kind"] == "video"
|
||||
# the judge fired on the created row; the colour-restore hook saw (sub, name)
|
||||
assert scheduled.get("rel") == "renders/clip.mp4"
|
||||
assert cm == {"sub": "renders", "name": "clip.mp4"}
|
||||
|
||||
# webm (EBML) accepted too
|
||||
wsrc = tmp_path / "clip.webm"
|
||||
_write_webm(wsrc)
|
||||
r_web = await upload(_Req(raw=wsrc.read_bytes(), query={"name": "clip.webm"}, org="acme"))
|
||||
assert r_web.status == 200
|
||||
|
||||
# garbage named .mp4 → rejected; ftyp at OFFSET 0 (not 4) → rejected (regression pin)
|
||||
r_bad = await upload(_Req(raw=b"totally not a video, just text bytes here", query={"name": "x.mp4"}, org="acme"))
|
||||
assert r_bad.status == 400
|
||||
r_off0 = await upload(_Req(raw=b"ftyp" + b"\x00" * 40, query={"name": "y.mp4"}, org="acme"))
|
||||
assert r_off0.status == 400
|
||||
|
||||
# over the 256MB ceiling → 413 (checked via content_length, no giant allocation)
|
||||
r_big = await upload(_Req(raw=mp4, query={"name": "big.mp4"}, org="acme",
|
||||
content_length=sh._MAX_UPLOAD + 1))
|
||||
assert r_big.status == 413
|
||||
|
||||
|
||||
# ── 7. the manifest sweeper keeps a video as kind="video" ─────────────────────────
|
||||
def test_manifest_video(tmp_path):
|
||||
from scripts import library_manifest as lm
|
||||
root = tmp_path
|
||||
(root / "video").mkdir()
|
||||
(root / "video" / "x.mp4").write_bytes(b"\x00\x00\x00 ftyp")
|
||||
(root / "video" / "empty.mp4").write_bytes(b"") # zero-byte → skipped
|
||||
assert "video/x.mp4" in lm.scan(str(root))
|
||||
assert "video/empty.mp4" not in lm.scan(str(root))
|
||||
|
||||
doc = lm.build(str(root))
|
||||
row = next(a for a in doc["assets"] if a["path"] == "video/x.mp4")
|
||||
assert row["kind"] == "video" and row["status"] == "draft"
|
||||
|
||||
# a curated status + tags survive the next sweep (the erase-every-cycle bug stays dead)
|
||||
lm.write_atomic(str(root), {"assets": [
|
||||
{"path": "video/x.mp4", "kind": "video", "status": "approved", "tags": ["hero"]}]})
|
||||
doc2 = lm.build(str(root))
|
||||
row2 = next(a for a in doc2["assets"] if a["path"] == "video/x.mp4")
|
||||
assert row2["kind"] == "video" and row2["status"] == "approved" and row2["tags"] == ["hero"]
|
||||
|
||||
|
||||
# ── 8. serving + the first-frame poster ───────────────────────────────────────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_serve_and_poster(orgaware, monkeypatch):
|
||||
outd, _ = orgaware
|
||||
root = Path(outd("acme"))
|
||||
_write_mp4(root / "video" / "clip.mp4")
|
||||
_lib(root, [{"path": "video/clip.mp4", "kind": "video", "status": "draft"}])
|
||||
get_file = _handler(_FakeServer(), "GET", "/v1/library/file", monkeypatch)
|
||||
|
||||
# full file served as video/mp4
|
||||
resp = await get_file(_Req(query={"path": "video/clip.mp4"}, org="acme"))
|
||||
assert resp.content_type == "video/mp4"
|
||||
|
||||
# ?thumb=1 → a cached JPEG poster in .thumbs/<key>.jpg
|
||||
tresp = await get_file(_Req(query={"path": "video/clip.mp4", "thumb": "1"}, org="acme"))
|
||||
assert isinstance(tresp, web.FileResponse)
|
||||
key = __import__("re").sub(r"[^a-zA-Z0-9]", "_", str((root / "video" / "clip.mp4").resolve()))[-120:]
|
||||
assert (root / ".thumbs" / f"{key}.jpg").is_file()
|
||||
|
||||
# a poster that cannot decode → 404, NEVER the raw mp4 bytes (use an un-cached path)
|
||||
_write_mp4(root / "video" / "clip2.mp4")
|
||||
_lib(root, [{"path": "video/clip.mp4"}, {"path": "video/clip2.mp4"}])
|
||||
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("cannot decode")
|
||||
|
||||
monkeypatch.setattr(av, "open", boom)
|
||||
r404 = await get_file(_Req(query={"path": "video/clip2.mp4", "thumb": "1"}, org="acme"))
|
||||
assert isinstance(r404, web.Response) and r404.status == 404
|
||||
assert not isinstance(r404, web.FileResponse)
|
||||
|
||||
|
||||
# ── 9. embedded-metadata read parity: provenance, Open-in-Editor, Re-run ──────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_embedded_and_rerun(orgaware, monkeypatch):
|
||||
outd, _ = orgaware
|
||||
root = Path(outd("acme"))
|
||||
graph = sh._video_graph("a woman on a beach", "video/x_ab12cd", seed=42, length=73)
|
||||
mp4 = root / "video" / "x_ab12cd_00001_.mp4"
|
||||
_write_mp4(mp4, meta={"prompt": json.dumps(graph)})
|
||||
_lib(root, [{"path": "video/x_ab12cd_00001_.mp4", "kind": "video", "status": "draft"}])
|
||||
|
||||
# _embedded reads the container metadata back
|
||||
info = sh._embedded(mp4)
|
||||
assert "prompt" in info and json.loads(info["prompt"])["12"]["class_type"] == "SaveVideo"
|
||||
|
||||
# _provenance yields the video params
|
||||
prov = sh._provenance(mp4)
|
||||
assert prov["workflow"] == "video/x_ab12cd" and prov["steps"] == 20
|
||||
assert prov["cfg"] == 5.0 and prov["seed"] == 42 and prov["length"] == 73 and prov["fps"] == 24
|
||||
|
||||
# GET /v1/workflow serves format 'api' for the done video row. The dispatch ts must
|
||||
# predate the file mtime (else finalize reads the mp4 as a pre-existing sibling).
|
||||
ts = int(mp4.stat().st_mtime) - 100
|
||||
wl.record("acme", kind="video", prompt="p", refs=[], uploads=[], parents=[],
|
||||
output_prefix="video/x_ab12cd", params={"steps": 20}, pid="pv", status="queued")
|
||||
rows = wl.load("acme")
|
||||
for x in rows:
|
||||
if x["id"] == "pv":
|
||||
x["ts"] = ts
|
||||
wl._save("acme", rows)
|
||||
get_wf = _handler(_FakeServer(), "GET", "/v1/workflow", monkeypatch)
|
||||
wfresp = await get_wf(_Req(query={"id": "pv"}, org="acme"))
|
||||
wf = json.loads(wfresp.text)
|
||||
assert wf["format"] == "api" and wf["graph"]["12"]["class_type"] == "SaveVideo"
|
||||
|
||||
# POST /v1/library/rerun dispatches kind='rerun' with a FRESH SaveVideo tag
|
||||
captured = {}
|
||||
|
||||
async def fake_queue(request, server, g):
|
||||
captured["graph"] = g
|
||||
return "pid-rerun"
|
||||
|
||||
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
|
||||
rerun = _handler(_FakeServer(), "POST", "/v1/library/rerun", monkeypatch)
|
||||
rr = await rerun(_Req(body={"path": "video/x_ab12cd_00001_.mp4"}, org="acme"))
|
||||
assert rr.status == 200
|
||||
sv = next(n for n in captured["graph"].values() if n["class_type"] == "SaveVideo")
|
||||
import re
|
||||
assert re.match(r"^video/x_[0-9a-f]{6}$", sv["inputs"]["filename_prefix"])
|
||||
assert sv["inputs"]["filename_prefix"] != "video/x_ab12cd" # a fresh namespace
|
||||
assert any(r2["kind"] == "rerun" for r2 in wl.load("acme"))
|
||||
|
||||
|
||||
# ── 10. template re-tag: video AND image savers get a fresh job identity ──────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_template_retag(orgaware, monkeypatch):
|
||||
import re
|
||||
outd, _ = orgaware
|
||||
tdir = Path(outd("acme")).parent / "templates"
|
||||
tdir.mkdir(parents=True, exist_ok=True)
|
||||
captured = {}
|
||||
|
||||
async def fake_queue(request, server, g):
|
||||
captured["graph"] = g
|
||||
return "pid-t"
|
||||
|
||||
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
|
||||
render = _handler(_FakeServer(), "POST", "/v1/templates/render", monkeypatch)
|
||||
|
||||
# a template whose graph saves via SaveVideo
|
||||
vgraph = sh._video_graph("a beach", "video/tmpl", length=73)
|
||||
(tdir / "clip.json").write_text(json.dumps({"name": "Clip", "slug": "clip", "graph": vgraph}))
|
||||
resp = await render(_Req(body={"name": "clip"}, org="acme"))
|
||||
assert resp.status == 200
|
||||
sv = next(n for n in captured["graph"].values() if n["class_type"] == "SaveVideo")
|
||||
assert re.match(r"^video/tmpl_[0-9a-f]{6}$", sv["inputs"]["filename_prefix"])
|
||||
|
||||
# a SaveImage template is re-tagged too (the unique-job-tag rule holds for images)
|
||||
igraph = {"9": {"class_type": "KSampler", "inputs": {"seed": 1, "steps": 30}},
|
||||
"15": {"class_type": "SaveImage", "inputs": {"filename_prefix": "templates/pic"}}}
|
||||
(tdir / "pic.json").write_text(json.dumps({"name": "Pic", "slug": "pic", "graph": igraph}))
|
||||
resp2 = await render(_Req(body={"name": "pic"}, org="acme"))
|
||||
assert resp2.status == 200
|
||||
si = next(n for n in captured["graph"].values() if n["class_type"] == "SaveImage")
|
||||
assert re.match(r"^templates/pic_[0-9a-f]{6}$", si["inputs"]["filename_prefix"])
|
||||
@@ -0,0 +1,164 @@
|
||||
"""worker-mode /prompt gate + the /v1/worker/execute claim seam.
|
||||
|
||||
Covers:
|
||||
- verify_worker_token / reject_untrusted_worker_submit — the ONE policy gate that makes
|
||||
a direct /prompt POST on a worker box refuse un-tokened submits (403), so no render
|
||||
reaches a GPU without appearing in the org's gpu-jobs queue.
|
||||
- POST /v1/worker/execute — validates X-Worker-Token and RUNS the claimed graph on the
|
||||
local prompt_queue, returning {prompt_id}, scoped to the job's org.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
# CPU CI runners have no NVIDIA driver: force studio's CPU mode BEFORE importing
|
||||
# worker_client (which pulls execution -> studio.model_management, whose import-time
|
||||
# device probe would otherwise call torch.cuda and crash on a GPU-less box). Same
|
||||
# switch the studio pod boots with (--cpu); harmless on a GPU box.
|
||||
import studio.cli_args
|
||||
studio.cli_args.args.cpu = True
|
||||
|
||||
from middleware import worker_client as wc
|
||||
|
||||
|
||||
class _Req:
|
||||
def __init__(self, headers=None):
|
||||
self.headers = headers or {}
|
||||
|
||||
|
||||
# ── The gate policy: worker-mode /prompt refuses un-tokened submits ───────────────
|
||||
|
||||
def test_empty_token_dev_bypass_ONLY_outside_worker_mode(monkeypatch):
|
||||
"""FAIL CLOSED: an empty/unset STUDIO_WORKER_TOKEN is a valid single-trust-domain dev
|
||||
signal ONLY when NOT in worker mode. On a worker box (worker_mode=True) an empty token
|
||||
must NOT open /prompt or /v1/worker/execute — that was the fail-open hole (KMS /v1
|
||||
migration has emptied the secret before)."""
|
||||
monkeypatch.setattr(wc, "WORKER_TOKEN", "")
|
||||
monkeypatch.setattr(wc.args, "worker_mode", False)
|
||||
assert wc.verify_worker_token(_Req()) is True # local dev, single trust domain
|
||||
monkeypatch.setattr(wc.args, "worker_mode", True)
|
||||
assert wc.verify_worker_token(_Req()) is False # worker box + empty token → refuse
|
||||
|
||||
|
||||
def test_verify_worker_token_enforced(monkeypatch):
|
||||
monkeypatch.setattr(wc, "WORKER_TOKEN", "s3cret")
|
||||
assert wc.verify_worker_token(_Req({"X-Worker-Token": "s3cret"})) is True
|
||||
assert wc.verify_worker_token(_Req({"X-Worker-Token": "nope"})) is False
|
||||
assert wc.verify_worker_token(_Req()) is False
|
||||
# constant-time path (hmac.compare_digest) accepts only the exact secret
|
||||
assert wc.verify_worker_token(_Req({"X-Worker-Token": "s3cret "})) is False
|
||||
|
||||
|
||||
def test_gate_allows_when_not_worker_mode(monkeypatch):
|
||||
monkeypatch.setattr(wc, "WORKER_TOKEN", "s3cret")
|
||||
assert wc.reject_untrusted_worker_submit(_Req(), worker_mode=False) is None
|
||||
|
||||
|
||||
def test_gate_403_in_worker_mode_without_token(monkeypatch):
|
||||
monkeypatch.setattr(wc, "WORKER_TOKEN", "s3cret")
|
||||
resp = wc.reject_untrusted_worker_submit(_Req(), worker_mode=True)
|
||||
assert resp is not None and resp.status == 403 # direct /prompt refused
|
||||
|
||||
|
||||
def test_gate_403_in_worker_mode_with_EMPTY_token(monkeypatch):
|
||||
"""Fail closed even when the secret is empty (KMS-emptied): worker mode + empty token
|
||||
still refuses a direct /prompt (403) — runtime belt to main.py's refuse-to-start guard."""
|
||||
monkeypatch.setattr(wc, "WORKER_TOKEN", "")
|
||||
monkeypatch.setattr(wc.args, "worker_mode", True)
|
||||
resp = wc.reject_untrusted_worker_submit(_Req(), worker_mode=True)
|
||||
assert resp is not None and resp.status == 403
|
||||
|
||||
|
||||
def test_gate_allows_worker_mode_with_token(monkeypatch):
|
||||
monkeypatch.setattr(wc, "WORKER_TOKEN", "s3cret")
|
||||
ok = _Req({"X-Worker-Token": "s3cret"})
|
||||
assert wc.reject_untrusted_worker_submit(ok, worker_mode=True) is None
|
||||
|
||||
|
||||
# ── The claim seam: POST /v1/worker/execute validates token + runs locally ────────
|
||||
|
||||
class _Queue:
|
||||
def __init__(self):
|
||||
self.items = []
|
||||
|
||||
def put(self, item):
|
||||
self.items.append(item)
|
||||
|
||||
|
||||
class _NRM:
|
||||
def apply_replacements(self, prompt):
|
||||
pass
|
||||
|
||||
|
||||
class _Server:
|
||||
def __init__(self):
|
||||
self.prompt_queue = _Queue()
|
||||
self.node_replace_manager = _NRM()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worker_app(monkeypatch):
|
||||
import execution
|
||||
|
||||
async def fake_validate(pid, prompt, targets=None):
|
||||
return (True, None, list(prompt.keys()), {})
|
||||
|
||||
monkeypatch.setattr(execution, "validate_prompt", fake_validate)
|
||||
monkeypatch.setattr(wc, "WORKER_TOKEN", "s3cret")
|
||||
server = _Server()
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
wc.add_worker_routes(routes, server)
|
||||
app.add_routes(routes)
|
||||
app["srv"] = server
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_execute_rejects_without_token(worker_app, aiohttp_client):
|
||||
client = await aiohttp_client(worker_app)
|
||||
r = await client.post("/v1/worker/execute",
|
||||
json={"prompt": {"9": {"class_type": "SaveImage", "inputs": {}}}})
|
||||
assert r.status == 401
|
||||
assert worker_app["srv"].prompt_queue.items == [] # nothing ran
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_execute_runs_claimed_job_with_token(worker_app, aiohttp_client):
|
||||
client = await aiohttp_client(worker_app)
|
||||
graph = {"9": {"class_type": "SaveImage", "inputs": {}}}
|
||||
r = await client.post("/v1/worker/execute",
|
||||
headers={"X-Worker-Token": "s3cret"},
|
||||
json={"prompt": graph, "prompt_id": "pid-1", "org": "acme"})
|
||||
assert r.status == 200
|
||||
body = await r.json()
|
||||
assert body["prompt_id"] == "pid-1"
|
||||
items = worker_app["srv"].prompt_queue.items
|
||||
assert len(items) == 1 # queued for local render
|
||||
_number, pid, _prompt, extra, _outputs, _sensitive = items[0]
|
||||
assert pid == "pid-1"
|
||||
assert extra["org_id"] == "acme" # worker-LOCAL scope label (gallery org re-derived from IAM)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_execute_rejects_non_dict_prompt(worker_app, aiohttp_client):
|
||||
"""A non-object prompt (e.g. an injected string) is a clean 400, not a 500."""
|
||||
client = await aiohttp_client(worker_app)
|
||||
r = await client.post("/v1/worker/execute", headers={"X-Worker-Token": "s3cret"},
|
||||
json={"prompt": "<img src=x onerror=alert(1)>"})
|
||||
assert r.status == 400
|
||||
assert worker_app["srv"].prompt_queue.items == [] # nothing ran
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_execute_rejects_non_numeric_number(worker_app, aiohttp_client):
|
||||
"""A non-numeric `number` is a clean 400, not a float() 500."""
|
||||
client = await aiohttp_client(worker_app)
|
||||
r = await client.post("/v1/worker/execute", headers={"X-Worker-Token": "s3cret"},
|
||||
json={"prompt": {"9": {"class_type": "SaveImage", "inputs": {}}},
|
||||
"number": "not-a-number"})
|
||||
assert r.status == 400
|
||||
@@ -0,0 +1,139 @@
|
||||
"""The composer's default create path — text-to-image — and the UX invariants the live
|
||||
review demanded. Two concerns:
|
||||
|
||||
* backend — _image_graph is a REAL text-to-image graph (EmptySD3LatentImage → SaveImage,
|
||||
the Z-Image-Turbo recipe), and dispatch_image validates before any dispatch.
|
||||
* frontend — the shipped page defaults the composer to an IMAGE model, routes text→image
|
||||
(no "attach a photo first" dead end), ALWAYS shows a generating state on
|
||||
Create, puts Download first-class on the tile AND the viewer, and stops
|
||||
badging active work as ARCHIVED. Asserted statically against the served HTML
|
||||
so the intuitiveness fixes can't silently regress.
|
||||
"""
|
||||
import asyncio
|
||||
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
|
||||
|
||||
_HTML = (Path(__file__).resolve().parents[2] / "middleware" / "studio_home.html").read_text()
|
||||
|
||||
|
||||
# ── backend: the text-to-image graph ────────────────────────────────────────────────
|
||||
def test_image_graph_is_a_real_text_to_image_create():
|
||||
g = sh._image_graph("a poster for a jazz night", "image/test")
|
||||
classes = {n["class_type"] for n in g.values()}
|
||||
# a true text-to-image CREATE starts from an empty latent (not an edit of a source)…
|
||||
assert "EmptySD3LatentImage" in classes
|
||||
# …and produces a visible saved image through the full load→sample→decode→save chain.
|
||||
assert {"UNETLoader", "CLIPLoader", "VAELoader", "CLIPTextEncode", "KSampler",
|
||||
"VAEDecode", "SaveImage"} <= classes
|
||||
# the user's words land in the positive prompt (node 5), negative (node 6) stays empty.
|
||||
assert "jazz" in g["5"]["inputs"]["text"]
|
||||
assert g["6"]["inputs"]["text"] == ""
|
||||
# the exact model set the shipped Z-Image-Turbo blueprint names.
|
||||
assert g["1"]["inputs"]["unet_name"] == sh._ZIMAGE_UNET
|
||||
assert g["2"]["inputs"]["clip_name"] == sh._ZIMAGE_CLIP and g["2"]["inputs"]["type"] == "lumina2"
|
||||
assert g["3"]["inputs"]["vae_name"] == sh._ZIMAGE_VAE
|
||||
# turbo recipe: 4 steps, cfg 1.0, res_multistep/simple, denoise 1.0 (a fresh render).
|
||||
ks = next(n for n in g.values() if n["class_type"] == "KSampler")["inputs"]
|
||||
assert ks["steps"] == 4 and ks["cfg"] == 1.0 and ks["denoise"] == 1.0
|
||||
assert ks["sampler_name"] == "res_multistep" and ks["scheduler"] == "simple"
|
||||
|
||||
|
||||
def test_image_graph_dims_clamp_and_snap():
|
||||
g = sh._image_graph("x" * 5, "p", width=1000, height=770)
|
||||
lat = g["7"]["inputs"]
|
||||
assert lat["width"] % 16 == 0 and lat["height"] % 16 == 0 # snapped to the 16-px grid
|
||||
g2 = sh._image_graph("x" * 5, "p", width=50, height=9000)
|
||||
assert 256 <= g2["7"]["inputs"]["width"] <= 2048 # clamped low
|
||||
assert 256 <= g2["7"]["inputs"]["height"] <= 2048 # clamped high
|
||||
|
||||
|
||||
def test_dispatch_image_rejects_bad_prompt_before_dispatch():
|
||||
# A too-short/empty prompt fails fast with a human reason — never a silent nothing,
|
||||
# never a network call. (No server/GPU needed: the guard is the first statement.)
|
||||
for bad in ("", "hi", " "):
|
||||
with pytest.raises(sh.DispatchError):
|
||||
asyncio.run(sh.dispatch_image(None, None, "org", bad))
|
||||
|
||||
|
||||
# ── frontend: the served page's intuitiveness invariants ─────────────────────────────
|
||||
def test_composer_defaults_to_an_image_model():
|
||||
# default is Zen Image (an IMAGE model), NOT a chat model, so the first ↑ produces
|
||||
# something visible. Decoupled from the chat model (its own storage key).
|
||||
assert "hz_create_model||'zen-image'" in _HTML
|
||||
assert "hz_create_model" in _HTML and "localStorage.hz_model=v" not in _HTML
|
||||
# the Image group leads the picker; the code model is hidden from the creative composer.
|
||||
assert _HTML.index("{label:'Image'") < _HTML.index("{label:'Assistant'")
|
||||
assert "zen5-coder" not in _HTML
|
||||
|
||||
|
||||
def test_text_only_prompt_routes_to_text_to_image():
|
||||
# the DEFAULT create (words, no photo) hits the new /v1/library/image route — the old
|
||||
# "attach a photo first" dead end is gone.
|
||||
assert "url:'/v1/library/image'" in _HTML
|
||||
assert "Describe what to create, then attach a photo with +" not in _HTML
|
||||
|
||||
|
||||
def test_create_always_shows_a_state_and_fails_friendly():
|
||||
assert "function pulseGenerating(" in _HTML and "pulseGenerating(plan)" in _HTML
|
||||
assert "function createFailed(" in _HTML and "tap ↑ to retry" in _HTML
|
||||
|
||||
|
||||
def test_download_is_first_class_on_tile_and_viewer():
|
||||
assert "function downloadImage(" in _HTML and "function dlMenu(" in _HTML
|
||||
assert "dlMenu(event,'" in _HTML # a per-tile download control
|
||||
assert 'id="zrdl"' in _HTML # a download control in the image viewer
|
||||
# PNG/JPG/WebP format choice is exposed at download.
|
||||
assert "downloadImage(path,'jpg')" in _HTML or "downloadImage(path,v)" in _HTML
|
||||
|
||||
|
||||
def test_active_items_are_not_badged_archived():
|
||||
# the old unconditional status badge (which read every active item as its status /
|
||||
# "archived") is gone; a badge now renders ONLY for deleted/flagged items.
|
||||
assert "'archived':a.status}" not in _HTML
|
||||
assert "a.status==='deleted'||a.status==='flagged'" in _HTML
|
||||
|
||||
|
||||
def test_bars_are_opaque_and_dev_control_is_gated():
|
||||
assert "background:var(--bg);z-index:30" in _HTML # opaque fixed header
|
||||
assert "#livebar::before" in _HTML # full-bleed opaque backing behind the sticky status bar
|
||||
assert "rgba(10,10,11,.82)" not in _HTML # the old translucent header is gone
|
||||
assert "body:not(.hzdev) .pico.dev" in _HTML # </> hidden unless Developers mode
|
||||
assert 'class="pico dev"' in _HTML
|
||||
|
||||
|
||||
def test_mobile_collapses_the_filter_pills():
|
||||
assert ".chips{flex-wrap:nowrap;overflow-x:auto" in _HTML
|
||||
|
||||
|
||||
# ── RED create-lane regressions (@c00384cf): F1 dedup identity, F2 dim coercion ──
|
||||
|
||||
def test_dedup_key_is_stable_identity_not_the_random_prefix():
|
||||
# F1: the key must be the STABLE render identity (org|kind|prompt|refs|uploads) —
|
||||
# NOT output_prefix, which each lane stamps with a random _job_tag() so two
|
||||
# identical submits used to miss and double-bill.
|
||||
k = sh._dedup_key("acme", "image", "a jazz poster", [], [])
|
||||
assert k == sh._dedup_key("acme", "image", "a jazz poster", [], []), "identical creates share a key"
|
||||
assert k != sh._dedup_key("acme", "image", "a DIFFERENT poster", [], []), "different prompt → different key"
|
||||
assert k != sh._dedup_key("other", "image", "a jazz poster", [], []), "different org → different key"
|
||||
assert k != sh._dedup_key("acme", "video", "a jazz poster", [], []), "different lane → different key"
|
||||
assert k != sh._dedup_key("acme", "image", "a jazz poster", ["ref.png"], []), "different refs → different key"
|
||||
|
||||
|
||||
def test_image_and_video_graphs_reject_non_numeric_dims():
|
||||
# F2: a non-numeric width/height/length raises DispatchError (→ clean 400), never a bare ValueError (→ 500).
|
||||
for bad in ("abc", "10px", [], {}):
|
||||
with pytest.raises(sh.DispatchError):
|
||||
sh._image_graph("a valid prompt", "image/x", width=bad)
|
||||
with pytest.raises(sh.DispatchError):
|
||||
sh._image_graph("a valid prompt", "image/x", height=bad)
|
||||
with pytest.raises(sh.DispatchError):
|
||||
sh._video_graph("a valid prompt", "video/x", width=bad)
|
||||
# numeric strings still coerce and render
|
||||
g = sh._image_graph("a valid prompt", "image/x", width="512", height="512")
|
||||
assert g["7"]["inputs"]["width"] == 512 and g["7"]["inputs"]["height"] == 512
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Guard tests for the browser-injected publishable key (middleware/studio_home.py).
|
||||
|
||||
The publishable key is the ONE credential Studio prints into page source, so these
|
||||
pin the invariants that keep a server secret out of the client:
|
||||
|
||||
* ``_publishable`` is an ALLOWLIST -- only ``pk-`` (the gateway's single
|
||||
client-safe prefix, iamauth.go) survives; every server-secret family
|
||||
(``hk-``/``sk-``/``fw_``/``hz_``) and JWT is dropped (fail CLOSED).
|
||||
* ``_hz_config_script`` cannot be broken out of its ``<script>`` tag, even by a
|
||||
hostile value that also satisfies the allowlist.
|
||||
"""
|
||||
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
|
||||
|
||||
_PREFIX, _SUFFIX = "<script>window.HZ=", ";</script>"
|
||||
_LINE_SEP, _PARA_SEP = chr(0x2028), chr(0x2029) # raw JS statement terminators (U+2028/9)
|
||||
|
||||
|
||||
def _parse_hz(script: str) -> dict:
|
||||
assert script.startswith(_PREFIX) and script.endswith(_SUFFIX)
|
||||
payload = script[len(_PREFIX):-len(_SUFFIX)]
|
||||
return json.loads(payload.replace("<\\/", "</")) # reverse the </ neutralization
|
||||
|
||||
|
||||
@pytest.mark.parametrize("secret", [
|
||||
"hk-live-abc", "sk-live-abc", "fw_live_abc", "hz_live_abc", # gateway server-secret families
|
||||
"eyJhbGciOiJIUzI1NiJ9.body.sig", # a JWT
|
||||
"secret-abc", "PK-upper", "pk_underscore", "pkno-dash", # not the pk- publishable prefix
|
||||
" ", "", # blank
|
||||
])
|
||||
def test_publishable_drops_everything_but_pk(secret):
|
||||
assert sh._publishable(secret) == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pk,want", [
|
||||
("pk-abc123", "pk-abc123"),
|
||||
(" pk-abc123 ", "pk-abc123"), # trimmed
|
||||
])
|
||||
def test_publishable_passes_pk(pk, want):
|
||||
assert sh._publishable(pk) == want
|
||||
|
||||
|
||||
def test_hz_config_injects_publishable_env(monkeypatch):
|
||||
for k in sh._PUBLISHABLE_KEY_ENV:
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
monkeypatch.setenv("NEXT_PUBLIC_HANZO_PUBLISHABLE_KEY", "pk-live-xyz")
|
||||
assert _parse_hz(sh._hz_config_script())["pk"] == "pk-live-xyz"
|
||||
|
||||
|
||||
def test_hz_config_drops_secret_env(monkeypatch):
|
||||
for k in sh._PUBLISHABLE_KEY_ENV:
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
monkeypatch.setenv("STUDIO_PUBLISHABLE_KEY", "hz_server_secret") # a real server key
|
||||
assert _parse_hz(sh._hz_config_script())["pk"] == ""
|
||||
|
||||
|
||||
def test_hz_config_empty_when_unset(monkeypatch):
|
||||
for k in sh._PUBLISHABLE_KEY_ENV:
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
assert _parse_hz(sh._hz_config_script())["pk"] == ""
|
||||
|
||||
|
||||
def test_hz_config_script_cannot_break_out(monkeypatch):
|
||||
# A hostile value that still satisfies the pk- allowlist must not be able to
|
||||
# close the <script> tag or smuggle a raw JS line/paragraph separator (U+2028/9).
|
||||
for k in sh._PUBLISHABLE_KEY_ENV:
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
monkeypatch.setenv("NEXT_PUBLIC_HANZO_PUBLISHABLE_KEY", "pk-</script><img>" + _LINE_SEP + _PARA_SEP)
|
||||
script = sh._hz_config_script()
|
||||
body = script[len(_PREFIX):-len(_SUFFIX)]
|
||||
assert "</" not in body # no tag can be opened/closed
|
||||
assert _LINE_SEP not in body and _PARA_SEP not in body # separators escaped, not raw
|
||||
assert _parse_hz(script)["pk"].startswith("pk-") # value survives intact, just neutralized
|
||||
@@ -0,0 +1,76 @@
|
||||
"""The Chat SSE parser lives in middleware/shell.js as two pure functions
|
||||
(``sseLines`` splits streamed chunks into whole lines across buffer boundaries;
|
||||
``sseDelta`` maps one line to its content delta). This test extracts them from the
|
||||
SHIPPED shell and exercises them under node -- chunk-boundary splits, CRLF, [DONE],
|
||||
keep-alives and malformed lines -- so the one parser the browser runs is the one
|
||||
under test. Skipped where node is unavailable, keeping ``uv run pytest`` green.
|
||||
"""
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SHELL = Path(__file__).resolve().parents[2] / "middleware" / "shell.js"
|
||||
|
||||
|
||||
def _fn(src: str, name: str) -> str:
|
||||
"""Return the source of the top-level `function <name>(...) {...}` by brace match."""
|
||||
i = src.index("function " + name + "(")
|
||||
depth = 0
|
||||
start = src.index("{", i)
|
||||
for j in range(start, len(src)):
|
||||
if src[j] == "{":
|
||||
depth += 1
|
||||
elif src[j] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[i:j + 1]
|
||||
raise AssertionError("unbalanced braces extracting " + name)
|
||||
|
||||
|
||||
_CHECKS = r"""
|
||||
function eq(a, b, m) {
|
||||
if (JSON.stringify(a) !== JSON.stringify(b)) {
|
||||
console.error("FAIL " + m + ": " + JSON.stringify(a) + " !== " + JSON.stringify(b));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// sseDelta: line -> content delta (or null)
|
||||
eq(sseDelta('data: {"choices":[{"delta":{"content":"hi"}}]}'), "hi", "basic");
|
||||
eq(sseDelta('data: {"choices":[{"delta":{"content":"x"}}]}\r'), "x", "trailing-CR-tolerated");
|
||||
eq(sseDelta('data: [DONE]'), null, "done-sentinel");
|
||||
eq(sseDelta(': keep-alive'), null, "comment-line");
|
||||
eq(sseDelta('event: ping'), null, "non-data-line");
|
||||
eq(sseDelta('data: {bad json'), null, "malformed-json");
|
||||
eq(sseDelta('data: {"choices":[{"delta":{}}]}'), null, "no-content-field");
|
||||
eq(sseDelta(''), null, "empty-line");
|
||||
|
||||
// sseLines: a line split across two feeds emerges once, whole (chunk boundary)
|
||||
var feed = sseLines();
|
||||
eq(feed('data: {"choices":[{"delta":{"content":"He'), [], "partial-held");
|
||||
var out = feed('llo"}}]}\ndata: [DONE]\n');
|
||||
eq(out.length, 2, "two-lines-after-boundary");
|
||||
eq(sseDelta(out[0]), "Hello", "reassembled-delta");
|
||||
eq(sseDelta(out[1]), null, "trailing-done");
|
||||
|
||||
// sseLines: multiple CRLF-terminated lines in one chunk; the CR rides through for
|
||||
// sseDelta to strip, and a dangling partial (no newline) is withheld.
|
||||
var f2 = sseLines();
|
||||
var o2 = f2("data: a\r\ndata: b\r\ndata: c");
|
||||
eq(o2, ["data: a\r", "data: b\r"], "crlf-split-holds-partial");
|
||||
eq(sseDelta(o2[0]), null, "plain-a-not-json");
|
||||
|
||||
console.log("OK");
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
|
||||
def test_sse_parser_under_node():
|
||||
src = SHELL.read_text()
|
||||
driver = _fn(src, "sseLines") + "\n" + _fn(src, "sseDelta") + "\n" + _CHECKS
|
||||
r = subprocess.run([shutil.which("node"), "-e", driver],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
assert r.returncode == 0, "node failed:\nSTDERR:\n" + r.stderr + "\nSTDOUT:\n" + r.stdout
|
||||
assert "OK" in r.stdout
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Unit tests for the render quality gate (middleware/quality_gate.py)."""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from middleware import quality_gate as qg
|
||||
from middleware import studio_home as sh
|
||||
from middleware import worklog
|
||||
|
||||
|
||||
def test_parse_verdict_strict():
|
||||
assert qg.parse_verdict("PASS") == "pass"
|
||||
assert qg.parse_verdict("pass") == "pass"
|
||||
assert qg.parse_verdict("FAIL") == "fail"
|
||||
assert qg.parse_verdict("fail.") == "fail"
|
||||
assert qg.parse_verdict("FAIL — mottled, speckled skin") == "fail"
|
||||
assert qg.parse_verdict("The skin is clean. PASS") == "pass"
|
||||
# garbage / ambiguous / empty → inconclusive (the gate fails open on these)
|
||||
assert qg.parse_verdict("") == "inconclusive"
|
||||
assert qg.parse_verdict(None) == "inconclusive"
|
||||
assert qg.parse_verdict("MAYBE") == "inconclusive"
|
||||
assert qg.parse_verdict("PASS FAIL") == "fail" # both tokens → the LAST is the verdict (reasoning judges deliberate before concluding)
|
||||
assert qg.parse_verdict("failure to load") == "inconclusive" # not a whole-token FAIL
|
||||
|
||||
|
||||
def test_enabled_off_switch(monkeypatch):
|
||||
for off in ("off", "OFF", "0", "false", "no"):
|
||||
monkeypatch.setenv("STUDIO_GATE", off)
|
||||
assert qg.enabled() is False
|
||||
for on in ("", "on", "1", "true", "yes"):
|
||||
monkeypatch.setenv("STUDIO_GATE", on)
|
||||
assert qg.enabled() is True
|
||||
monkeypatch.delenv("STUDIO_GATE", raising=False)
|
||||
assert qg.enabled() is True # default ON
|
||||
|
||||
|
||||
def test_config_defaults_and_overrides(monkeypatch):
|
||||
monkeypatch.delenv("STUDIO_GATE_MODEL", raising=False)
|
||||
monkeypatch.delenv("STUDIO_GATE_URL", raising=False)
|
||||
assert qg._model() == "zen-vl" # the cloud's VL model
|
||||
assert qg._url().endswith("/v1/chat/completions")
|
||||
monkeypatch.setenv("STUDIO_GATE_URL", "http://127.0.0.1:1234/v1/chat/completions")
|
||||
assert qg._url() == "http://127.0.0.1:1234/v1/chat/completions" # local-engine override
|
||||
monkeypatch.setenv("STUDIO_GATE_MODEL", "zen-vl-local")
|
||||
assert qg._model() == "zen-vl-local"
|
||||
|
||||
|
||||
def _const(v):
|
||||
async def j(images, bearer, prompt):
|
||||
return v
|
||||
return j
|
||||
|
||||
|
||||
def _seed(tmp_path, status="draft", org="acme"):
|
||||
from PIL import Image
|
||||
root = tmp_path / "orgs" / org / "output"
|
||||
(root / "renders").mkdir(parents=True)
|
||||
Image.new("RGB", (8, 8), (200, 150, 120)).save(root / "renders" / "r.png", "PNG")
|
||||
(root / "library.json").write_text(json.dumps(
|
||||
{"assets": [{"path": "renders/r.png", "status": status}]}))
|
||||
return root
|
||||
|
||||
|
||||
def _status(root, rel="renders/r.png"):
|
||||
lib = json.loads((root / "library.json").read_text())
|
||||
return next(a["status"] for a in lib["assets"] if a["path"] == rel)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_flags_on_fail_and_annotates_worklog(tmp_path, monkeypatch):
|
||||
root = _seed(tmp_path)
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
|
||||
worklog.record("acme", kind="render", prompt="", refs=[], uploads=[], parents=[],
|
||||
output_prefix="renders/r.png", node="spark", status="done")
|
||||
seen = {}
|
||||
|
||||
async def fake_judge(images, bearer, prompt):
|
||||
seen["b64"], seen["bearer"], seen["prompt"] = images, bearer, prompt
|
||||
return "FAIL - mottled, speckled skin all over the arms and shoulders here"
|
||||
|
||||
monkeypatch.setattr(qg, "judge", fake_judge)
|
||||
await qg.run("acme", root, "renders/r.png", "Bearer t")
|
||||
|
||||
assert seen["b64"] and seen["bearer"] == "Bearer t" # downscaled + same bearer
|
||||
assert _status(root) == "flagged" # asset hidden from default grid
|
||||
row = next(r for r in worklog.load("acme") if r.get("output_prefix") == "renders/r.png")
|
||||
ev = [e for e in row.get("events", []) if e.get("s") == "flagged"]
|
||||
assert len(ev) == 1 and ev[0]["note"].startswith("quality gate: FAIL")
|
||||
assert len(ev[0]["note"]) <= len("quality gate: ") + 80 # first 80 chars of the reply
|
||||
assert row["status"] == "done" # worklog lifecycle untouched — annotation only
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_fails_open_on_pass_inconclusive_and_error(tmp_path, monkeypatch):
|
||||
root = _seed(tmp_path)
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
|
||||
for verdict in ("PASS", "who knows", None): # pass · inconclusive · judge error (None)
|
||||
monkeypatch.setattr(qg, "judge", _const(verdict))
|
||||
await qg.run("acme", root, "renders/r.png", "Bearer t")
|
||||
assert _status(root) == "draft"
|
||||
|
||||
async def boom(images, bearer, prompt):
|
||||
raise RuntimeError("429 rate limited") # 402/429/timeout/unreachable class
|
||||
|
||||
monkeypatch.setattr(qg, "judge", boom)
|
||||
await qg.run("acme", root, "renders/r.png", "Bearer t") # must not raise
|
||||
assert _status(root) == "draft" # good work is never hidden by accident
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_never_clobbers_curated_status(tmp_path, monkeypatch):
|
||||
root = _seed(tmp_path, status="approved") # a human already approved it
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
|
||||
monkeypatch.setattr(qg, "judge", _const("FAIL"))
|
||||
await qg.run("acme", root, "renders/r.png", "Bearer t")
|
||||
assert _status(root) == "approved" # a late FAIL must not yank curated work
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_missing_or_unreadable_is_a_no_op(tmp_path, monkeypatch):
|
||||
root = _seed(tmp_path)
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
|
||||
called = {"n": 0}
|
||||
|
||||
async def counting_judge(images, bearer, prompt):
|
||||
called["n"] += 1
|
||||
return "FAIL"
|
||||
|
||||
monkeypatch.setattr(qg, "judge", counting_judge)
|
||||
await qg.run("acme", root, "renders/gone.png", "Bearer t") # no such file
|
||||
assert called["n"] == 0 and _status(root) == "draft" # never judged, never flagged
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_is_tenant_scoped(tmp_path, monkeypatch):
|
||||
a_root = _seed(tmp_path, org="acme")
|
||||
b_root = _seed(tmp_path, org="globex")
|
||||
roots = {"acme": a_root, "globex": b_root}
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory",
|
||||
lambda o=None: str(roots.get(o, a_root)))
|
||||
monkeypatch.setattr(qg, "judge", _const("FAIL"))
|
||||
await qg.run("acme", a_root, "renders/r.png", "Bearer t")
|
||||
assert _status(a_root) == "flagged"
|
||||
assert _status(b_root) == "draft" # the other tenant is untouched
|
||||
|
||||
|
||||
def test_schedule_off_switch_never_fires(monkeypatch):
|
||||
"""STUDIO_GATE=off short-circuits before touching the loop — nothing scheduled."""
|
||||
monkeypatch.setenv("STUDIO_GATE", "off")
|
||||
fired = {"n": 0}
|
||||
monkeypatch.setattr(qg.asyncio, "ensure_future", lambda coro: fired.__setitem__("n", fired["n"] + 1))
|
||||
qg.schedule(object(), "acme", Path("/tmp"), "renders/r.png")
|
||||
assert fired["n"] == 0
|
||||
@@ -0,0 +1,358 @@
|
||||
"""Stacks — the Procreate-style gallery folders.
|
||||
|
||||
Covers the per-org SQLite store (middleware/stacks_store.py) and its wiring into
|
||||
the Studio home routes: every REST route, transactional membership, tenancy
|
||||
isolation, one-stack-per-asset, order persistence, and delete/unstack semantics
|
||||
(remove is never delete; delete defaults to preserving; delete-images soft-deletes
|
||||
in the library), plus the generation-integration routing and auto-stack setting.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from middleware import stacks_store as st
|
||||
from middleware import studio_home as sh
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def org(tmp_path, monkeypatch):
|
||||
"""A tmp per-org tree + a fresh connection cache — the SAME folder_paths seam
|
||||
production tenancy uses, so a Stack DB lands at ``orgs/<org>/stacks.db``."""
|
||||
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))
|
||||
for c in st._conns.values():
|
||||
try:
|
||||
c.close()
|
||||
except Exception:
|
||||
pass
|
||||
st._conns.clear()
|
||||
st._locks.clear()
|
||||
yield "acme"
|
||||
|
||||
|
||||
# ── store: the file lives where tenancy expects it ──────────────────────────────
|
||||
def test_db_path_is_org_rooted(org, tmp_path):
|
||||
st.create(org, "x", [])
|
||||
assert (tmp_path / "orgs" / "acme" / "stacks.db").is_file()
|
||||
|
||||
|
||||
# ── create: drag A onto B = a Stack with BOTH, nothing copied/merged/deleted ─────
|
||||
def test_create_from_two_assets_holds_both_cover_first(org):
|
||||
s = st.create(org, "Fashion", ["a.png", "b.png"])
|
||||
assert s["name"] == "Fashion" and s["count"] == 2
|
||||
assert [m["assetId"] for m in s["assets"]] == ["a.png", "b.png"]
|
||||
assert s["coverAssetId"] == "a.png" # default cover = first image
|
||||
# reloading from the DB yields the same membership (nothing copied/merged)
|
||||
got = st.get(org, s["id"])
|
||||
assert [m["assetId"] for m in got["assets"]] == ["a.png", "b.png"]
|
||||
|
||||
|
||||
def test_create_defaults_untitled_and_dedupes(org):
|
||||
s = st.create(org, " ", ["a.png", "a.png", "b.png"])
|
||||
assert s["name"] == "Untitled Stack"
|
||||
assert [m["assetId"] for m in s["assets"]] == ["a.png", "b.png"]
|
||||
|
||||
|
||||
def test_single_image_stack_allowed(org):
|
||||
s = st.create(org, "solo", ["only.png"])
|
||||
assert s["count"] == 1 and st.get(org, s["id"])["count"] == 1
|
||||
|
||||
|
||||
def test_empty_stack_allowed_for_generation_destination(org):
|
||||
s = st.create(org, "New", [])
|
||||
assert s["count"] == 0 and s["coverAssetId"] is None
|
||||
|
||||
|
||||
# ── one stack per asset: adding to a second Stack MOVES it ───────────────────────
|
||||
def test_one_stack_per_asset_move_on_readd(org):
|
||||
a = st.create(org, "A", ["x.png", "y.png"])
|
||||
b = st.create(org, "B", ["z.png"])
|
||||
st.add_assets(org, b["id"], ["x.png"]) # steal x from A
|
||||
assert [m["assetId"] for m in st.get(org, a["id"])["assets"]] == ["y.png"]
|
||||
assert [m["assetId"] for m in st.get(org, b["id"])["assets"]] == ["z.png", "x.png"]
|
||||
assert st.stack_of(org, "x.png") == b["id"]
|
||||
|
||||
|
||||
def test_moving_the_cover_out_repoints_source_cover(org):
|
||||
a = st.create(org, "A", ["cover.png", "second.png"])
|
||||
assert a["coverAssetId"] == "cover.png"
|
||||
b = st.create(org, "B", [])
|
||||
st.add_assets(org, b["id"], ["cover.png"]) # cover leaves A
|
||||
assert st.get(org, a["id"])["coverAssetId"] == "second.png"
|
||||
|
||||
|
||||
# ── add / remove: remove returns to gallery, never deletes ───────────────────────
|
||||
def test_remove_returns_asset_and_repoints_cover(org):
|
||||
s = st.create(org, "S", ["a.png", "b.png", "c.png"])
|
||||
st.remove_assets(org, s["id"], ["a.png"]) # a.png was the cover
|
||||
got = st.get(org, s["id"])
|
||||
assert [m["assetId"] for m in got["assets"]] == ["b.png", "c.png"]
|
||||
assert [m["position"] for m in got["assets"]] == [0, 1] # positions compact
|
||||
assert got["coverAssetId"] == "b.png"
|
||||
assert st.stack_of(org, "a.png") is None # free again, not deleted
|
||||
|
||||
|
||||
# ── reorder: order persists and is validated against membership ──────────────────
|
||||
def test_reorder_persists_and_survives_reload(org):
|
||||
s = st.create(org, "S", ["a.png", "b.png", "c.png"])
|
||||
st.reorder(org, s["id"], ["c.png", "a.png", "b.png"])
|
||||
assert [m["assetId"] for m in st.get(org, s["id"])["assets"]] == ["c.png", "a.png", "b.png"]
|
||||
|
||||
|
||||
def test_reorder_rejects_wrong_set(org):
|
||||
s = st.create(org, "S", ["a.png", "b.png"])
|
||||
with pytest.raises(ValueError):
|
||||
st.reorder(org, s["id"], ["a.png"]) # missing a member
|
||||
with pytest.raises(ValueError):
|
||||
st.reorder(org, s["id"], ["a.png", "b.png", "c.png"]) # foreign member
|
||||
|
||||
|
||||
# ── rename / describe / cover / archive ─────────────────────────────────────────
|
||||
def test_patch_name_description_cover(org):
|
||||
s = st.create(org, "old", ["a.png", "b.png"])
|
||||
p = st.patch(org, s["id"], {"name": "new", "description": "desc", "cover": "b.png"})
|
||||
assert p["name"] == "new" and p["description"] == "desc" and p["coverAssetId"] == "b.png"
|
||||
|
||||
|
||||
def test_cover_must_be_a_member(org):
|
||||
s = st.create(org, "S", ["a.png"])
|
||||
with pytest.raises(ValueError):
|
||||
st.patch(org, s["id"], {"cover": "not-in-stack.png"})
|
||||
|
||||
|
||||
def test_archive_hides_from_default_list(org):
|
||||
s = st.create(org, "S", ["a.png"])
|
||||
st.patch(org, s["id"], {"archived": True})
|
||||
assert st.get(org, s["id"])["archivedAt"] is not None
|
||||
assert s["id"] not in [x["id"] for x in st.list_stacks(org, include_archived=False)]
|
||||
assert s["id"] in [x["id"] for x in st.list_stacks(org, include_archived=True)]
|
||||
|
||||
|
||||
# ── unstack: container gone, EVERY asset preserved ──────────────────────────────
|
||||
def test_unstack_preserves_all_assets(org):
|
||||
s = st.create(org, "S", ["a.png", "b.png", "c.png"])
|
||||
freed = st.unstack(org, s["id"])
|
||||
assert freed == ["a.png", "b.png", "c.png"]
|
||||
assert st.get(org, s["id"]) is None # container dissolved
|
||||
assert all(st.stack_of(org, a) is None for a in freed)
|
||||
|
||||
|
||||
# ── delete: defaults to preserving images; images-mode soft-deletes them ─────────
|
||||
def test_delete_only_returns_images(org):
|
||||
s = st.create(org, "S", ["a.png", "b.png"])
|
||||
out = st.delete(org, s["id"], "only")
|
||||
assert out["mode"] == "only" and out["assetIds"] == ["a.png", "b.png"]
|
||||
assert st.get(org, s["id"]) is None
|
||||
assert st.stack_of(org, "a.png") is None
|
||||
|
||||
|
||||
def test_delete_images_reports_assets_for_soft_delete(org):
|
||||
s = st.create(org, "S", ["a.png", "b.png"])
|
||||
out = st.delete(org, s["id"], "images")
|
||||
assert out["mode"] == "images" and set(out["assetIds"]) == {"a.png", "b.png"}
|
||||
|
||||
|
||||
# ── generation integration: route a prefix, absorb the landed outputs ───────────
|
||||
def test_route_and_absorb_assigns_all_outputs(org):
|
||||
s = st.create(org, "Batch", [])
|
||||
st.route_prefix(org, "renders/foo", s["id"])
|
||||
# SaveImage counter form + a second output of the same job both join
|
||||
assert st.absorb(org, "renders/foo_00001_.png") == s["id"]
|
||||
assert st.absorb(org, "renders/foo_00002_.png") == s["id"]
|
||||
got = st.get(org, s["id"])
|
||||
assert [m["assetId"] for m in got["assets"]] == ["renders/foo_00001_.png", "renders/foo_00002_.png"]
|
||||
assert got["coverAssetId"] == "renders/foo_00001_.png"
|
||||
|
||||
|
||||
def test_absorb_ingested_path_and_miss(org):
|
||||
s = st.create(org, "S", [])
|
||||
st.route_prefix(org, "composes/bar", s["id"])
|
||||
assert st.absorb(org, "composes/bar") == s["id"] # ingested-path (no counter)
|
||||
assert st.absorb(org, "renders/unrelated_00001_.png") is None
|
||||
|
||||
|
||||
# ── per-org settings (auto-stack batch generations) ─────────────────────────────
|
||||
def test_settings_roundtrip(org):
|
||||
assert st.get_setting(org, "auto_stack_batches", False) is False
|
||||
st.set_setting(org, "auto_stack_batches", True)
|
||||
assert st.get_setting(org, "auto_stack_batches", False) is True
|
||||
|
||||
|
||||
def test_dest_stack_auto_creates_for_batch_when_enabled(org):
|
||||
# explicit id wins
|
||||
assert sh._dest_stack(org, "sid-123", prompt="x", multi=True, owner="", workspace=None) == "sid-123"
|
||||
# off → None even for a batch
|
||||
assert sh._dest_stack(org, None, prompt="a red hat", multi=True, owner="", workspace=None) is None
|
||||
st.set_setting(org, "auto_stack_batches", True)
|
||||
# on + single output → still None (auto-stack is for batches)
|
||||
assert sh._dest_stack(org, None, prompt="x", multi=False, owner="", workspace=None) is None
|
||||
# on + batch → a new '<short prompt> — <date>' Stack
|
||||
sid = sh._dest_stack(org, None, prompt="a red hat on a model", multi=True, owner="u1", workspace="proj")
|
||||
got = st.get(org, sid)
|
||||
assert got is not None and got["name"].startswith("a red hat on a") and got["ownerId"] == "u1"
|
||||
assert got["workspaceId"] == "proj"
|
||||
|
||||
|
||||
def test_index_upload_absorbs_landed_output_into_routed_stack(org, tmp_path):
|
||||
"""The real ingest point: a render routed to a Stack, then its output landing
|
||||
via _index_upload, joins that Stack — the ONE membership-assignment hook (and it
|
||||
still returns `created` for the quality gate)."""
|
||||
root = Path(sh.folder_paths.get_org_output_directory(org))
|
||||
(root / "library.json").write_text(json.dumps({"assets": []}))
|
||||
s = st.create(org, "Gen", [])
|
||||
st.route_prefix(org, "renders/gen", s["id"])
|
||||
created = sh._index_upload(org, root, "renders/gen_00001_.png",
|
||||
design=None, kind="render", role=None, node="upload")
|
||||
assert created is True # gate contract preserved
|
||||
assert [m["assetId"] for m in st.get(org, s["id"])["assets"]] == ["renders/gen_00001_.png"]
|
||||
|
||||
|
||||
def test_soft_delete_assets_marks_library_deleted(org, tmp_path):
|
||||
root = Path(sh.folder_paths.get_org_output_directory(org))
|
||||
(root / "library.json").write_text(json.dumps({"assets": [
|
||||
{"path": "a.png", "status": "draft"}, {"path": "b.png", "status": "approved"}]}))
|
||||
sh._soft_delete_assets(org, ["a.png"])
|
||||
lib = json.loads((root / "library.json").read_text())
|
||||
by = {a["path"]: a["status"] for a in lib["assets"]}
|
||||
assert by == {"a.png": "deleted", "b.png": "approved"} # only the named one, files never unlinked
|
||||
assert (root / "library.json").is_file()
|
||||
|
||||
|
||||
# ══ route surface ═══════════════════════════════════════════════════════════════
|
||||
class _FakeServer:
|
||||
port = 1
|
||||
|
||||
|
||||
async def _client(monkeypatch, sub="u1"):
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
@web.middleware
|
||||
async def fake_auth(request, handler):
|
||||
o = request.headers.get("X-Org")
|
||||
if o:
|
||||
request["iam_user"] = {"org_id": o, "orgs": [o], "sub": sub}
|
||||
return await handler(request)
|
||||
|
||||
app = web.Application(middlewares=[fake_auth])
|
||||
routes = web.RouteTableDef()
|
||||
sh.add_studio_home_routes(routes, _FakeServer())
|
||||
app.add_routes(routes)
|
||||
ts = TestServer(app)
|
||||
await ts.start_server()
|
||||
client = TestClient(ts)
|
||||
await client.start_server()
|
||||
return client
|
||||
|
||||
|
||||
async def _mk(client, org, name, ids):
|
||||
r = await client.post("/v1/stacks", json={"name": name, "assetIds": ids}, headers={"X-Org": org})
|
||||
assert r.status == 200
|
||||
return await r.json()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routes_full_lifecycle(org, monkeypatch):
|
||||
client = await _client(monkeypatch)
|
||||
# create
|
||||
s = await _mk(client, "acme", "Fashion", ["a.png", "b.png"])
|
||||
assert s["count"] == 2 and s["ownerId"] == "u1"
|
||||
sid = s["id"]
|
||||
# list
|
||||
lst = await (await client.get("/v1/stacks", headers={"X-Org": "acme"})).json()
|
||||
assert lst["org"] == "acme" and [x["id"] for x in lst["stacks"]] == [sid]
|
||||
# get
|
||||
assert (await (await client.get(f"/v1/stacks/{sid}", headers={"X-Org": "acme"})).json())["name"] == "Fashion"
|
||||
# add
|
||||
r = await client.post(f"/v1/stacks/{sid}/assets", json={"assetIds": ["c.png"]}, headers={"X-Org": "acme"})
|
||||
assert [m["assetId"] for m in (await r.json())["assets"]] == ["a.png", "b.png", "c.png"]
|
||||
# reorder (persists)
|
||||
r = await client.patch(f"/v1/stacks/{sid}/order", json={"assetIds": ["c.png", "b.png", "a.png"]}, headers={"X-Org": "acme"})
|
||||
assert [m["assetId"] for m in (await r.json())["assets"]] == ["c.png", "b.png", "a.png"]
|
||||
reloaded = await (await client.get(f"/v1/stacks/{sid}", headers={"X-Org": "acme"})).json()
|
||||
assert [m["assetId"] for m in reloaded["assets"]] == ["c.png", "b.png", "a.png"]
|
||||
# rename + cover
|
||||
r = await client.patch(f"/v1/stacks/{sid}", json={"name": "Looks", "cover": "b.png"}, headers={"X-Org": "acme"})
|
||||
body = await r.json()
|
||||
assert body["name"] == "Looks" and body["coverAssetId"] == "b.png"
|
||||
# remove (returns to gallery)
|
||||
r = await client.delete(f"/v1/stacks/{sid}/assets", json={"assetIds": ["c.png"]}, headers={"X-Org": "acme"})
|
||||
assert [m["assetId"] for m in (await r.json())["assets"]] == ["b.png", "a.png"]
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routes_unstack_and_delete_default_preserves(org, monkeypatch):
|
||||
client = await _client(monkeypatch)
|
||||
s = await _mk(client, "acme", "S", ["a.png", "b.png"])
|
||||
r = await client.post(f"/v1/stacks/{s['id']}/unstack", headers={"X-Org": "acme"})
|
||||
assert (await r.json())["assetIds"] == ["a.png", "b.png"]
|
||||
assert (await client.get(f"/v1/stacks/{s['id']}", headers={"X-Org": "acme"})).status == 404
|
||||
# delete default (mode=only) preserves images
|
||||
s2 = await _mk(client, "acme", "S2", ["x.png"])
|
||||
r = await client.delete(f"/v1/stacks/{s2['id']}", headers={"X-Org": "acme"})
|
||||
j = await r.json()
|
||||
assert j["mode"] == "only" and j["assetIds"] == ["x.png"]
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routes_delete_images_soft_deletes_library(org, monkeypatch):
|
||||
root = Path(sh.folder_paths.get_org_output_directory("acme"))
|
||||
(root / "library.json").write_text(json.dumps({"assets": [{"path": "a.png", "status": "draft"}]}))
|
||||
client = await _client(monkeypatch)
|
||||
s = await _mk(client, "acme", "S", ["a.png"])
|
||||
r = await client.delete(f"/v1/stacks/{s['id']}?mode=images", headers={"X-Org": "acme"})
|
||||
assert (await r.json())["mode"] == "images"
|
||||
lib = json.loads((root / "library.json").read_text())
|
||||
assert lib["assets"][0]["status"] == "deleted" # soft-deleted, file kept
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routes_tenancy_cross_org_404(org, monkeypatch):
|
||||
client = await _client(monkeypatch)
|
||||
s = await _mk(client, "acme", "Secret", ["a.png"])
|
||||
sid = s["id"]
|
||||
# globex sees an empty list and cannot read/patch/delete acme's Stack
|
||||
assert (await (await client.get("/v1/stacks", headers={"X-Org": "globex"})).json())["stacks"] == []
|
||||
assert (await client.get(f"/v1/stacks/{sid}", headers={"X-Org": "globex"})).status == 404
|
||||
assert (await client.patch(f"/v1/stacks/{sid}", json={"name": "x"}, headers={"X-Org": "globex"})).status == 404
|
||||
assert (await client.post(f"/v1/stacks/{sid}/assets", json={"assetIds": ["z.png"]}, headers={"X-Org": "globex"})).status == 404
|
||||
assert (await client.delete(f"/v1/stacks/{sid}", headers={"X-Org": "globex"})).status == 404
|
||||
assert (await client.post(f"/v1/stacks/{sid}/unstack", headers={"X-Org": "globex"})).status == 404
|
||||
# acme still intact
|
||||
assert (await client.get(f"/v1/stacks/{sid}", headers={"X-Org": "acme"})).status == 200
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routes_validation_and_settings(org, monkeypatch):
|
||||
client = await _client(monkeypatch)
|
||||
# bad cover → 400
|
||||
s = await _mk(client, "acme", "S", ["a.png"])
|
||||
assert (await client.patch(f"/v1/stacks/{s['id']}", json={"cover": "ghost.png"}, headers={"X-Org": "acme"})).status == 400
|
||||
# bad reorder → 400
|
||||
assert (await client.patch(f"/v1/stacks/{s['id']}/order", json={"assetIds": ["a.png", "b.png"]}, headers={"X-Org": "acme"})).status == 400
|
||||
# settings roundtrip
|
||||
assert (await (await client.get("/v1/stacks/settings", headers={"X-Org": "acme"})).json())["autoStack"] is False
|
||||
r = await client.patch("/v1/stacks/settings", json={"autoStack": True}, headers={"X-Org": "acme"})
|
||||
assert (await r.json())["autoStack"] is True
|
||||
# "settings" is NOT read as a stack id
|
||||
assert (await client.get("/v1/stacks/settings", headers={"X-Org": "acme"})).status == 200
|
||||
await client.close()
|
||||
@@ -27,12 +27,92 @@ def test_fix_graph_keeps_recipe_invariants():
|
||||
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
|
||||
# The canonical Qwen edit: NO Flux-Kontext reference-method override (that
|
||||
# `index_timestep_zero` graft onto Qwen conditioning was the noise-stipple cause);
|
||||
# the SAME scaled reference feeds conditioning AND the VAEEncode latent. It is now a
|
||||
# LOW-denoise edit (default 0.25) — never 1.0 (that is Regenerate).
|
||||
assert all(n["class_type"] != "FluxKontextMultiReferenceLatentMethod" for n in g.values())
|
||||
assert ks["denoise"] == sh.EDIT_DENOISE_DEFAULT == 0.25
|
||||
assert g[ks["latent_image"][0]]["class_type"] == "VAEEncode"
|
||||
assert g["6"]["inputs"]["image1"] == ["2", 0] # the scaled reference conditions the edit
|
||||
assert g["10"]["inputs"]["pixels"] == ["2", 0] # the SAME scaled reference sizes the latent
|
||||
assert g["2"]["class_type"] == "FluxKontextImageScale"
|
||||
assert "straps thicker" in g["6"]["inputs"]["prompt"]
|
||||
assert g["15"]["inputs"]["filename_prefix"] == "fixes/test"
|
||||
|
||||
|
||||
# ── Generated-image Edit path: the spec invariants (edit ≠ regenerate) ───────────
|
||||
|
||||
def test_edit_uses_source_latent_never_empty():
|
||||
"""Edit encodes the SOURCE (LoadImage → VAEEncode) as the KSampler latent — never
|
||||
an EmptyLatentImage (that would be a text-to-image regenerate)."""
|
||||
g = sh._fix_graph("fix_src.png", "brighten very slightly", "fixes/e")
|
||||
ks = g["13"]["inputs"]
|
||||
assert g["1"]["class_type"] == "LoadImage"
|
||||
assert g[ks["latent_image"][0]]["class_type"] == "VAEEncode"
|
||||
assert not any("Empty" in n.get("class_type", "") for n in g.values())
|
||||
assert sh._assert_edit_graph(g) == ""
|
||||
|
||||
|
||||
def test_edit_denoise_defaults_near_025_and_maps_strength():
|
||||
assert sh._edit_denoise(None) == 0.25
|
||||
assert sh._edit_denoise(25) == 0.25 # percent
|
||||
assert sh._edit_denoise(0.4) == 0.4 # fraction
|
||||
assert sh._edit_denoise(10) == 0.1
|
||||
assert sh._edit_denoise(100) == 0.95 # 100% clamps below a true 1.0 regen
|
||||
assert sh._edit_denoise("bad") == 0.25
|
||||
assert sh._fix_graph("s.png", "x", "p", denoise=0.4)["13"]["inputs"]["denoise"] == 0.4
|
||||
|
||||
|
||||
def test_regenerate_empty_latent_is_rejected_as_an_edit():
|
||||
"""A graph with EmptyLatentImage / denoise 1.0 must be refused by the edit guard —
|
||||
an Edit can NEVER silently become a Regenerate."""
|
||||
regen = {
|
||||
"10": {"class_type": "EmptySD3LatentImage", "inputs": {}},
|
||||
"13": {"class_type": "KSampler", "inputs": {"latent_image": ["10", 0], "denoise": 1.0}},
|
||||
"15": {"class_type": "SaveImage", "inputs": {}},
|
||||
}
|
||||
assert "EmptyLatentImage" in sh._assert_edit_graph(regen)
|
||||
# denoise 1.0 over a real source latent is still refused (that band is Regenerate).
|
||||
hot = sh._fix_graph("s.png", "x", "p")
|
||||
hot["13"]["inputs"]["denoise"] = 1.0
|
||||
assert "denoise" in sh._assert_edit_graph(hot)
|
||||
|
||||
|
||||
def test_edit_seed_reuse_is_deterministic_and_varying_still_edits_source():
|
||||
a = sh._fix_graph("s.png", "x", "p", seed=123)
|
||||
b = sh._fix_graph("s.png", "x", "p", seed=123)
|
||||
assert a["13"]["inputs"]["seed"] == b["13"]["inputs"]["seed"] == 123 # locked seed → deterministic
|
||||
c = sh._fix_graph("s.png", "x", "p", seed=999)
|
||||
assert c["13"]["inputs"]["seed"] == 999
|
||||
# a varied seed STILL edits the source: latent is the VAEEncode, not an empty one.
|
||||
assert c[c["13"]["inputs"]["latent_image"][0]]["class_type"] == "VAEEncode"
|
||||
|
||||
|
||||
def test_edit_prompt_preserves_and_does_not_expand():
|
||||
"""The edit instruction is wrapped with a PRESERVATION directive, not creative
|
||||
scene/style expansion."""
|
||||
g = sh._fix_graph("s.png", "brighten very slightly", "p")
|
||||
pos = g["6"]["inputs"]["prompt"].lower()
|
||||
assert "brighten very slightly" in pos
|
||||
assert "apply only the requested change" in pos and "leave everything else unchanged" in pos
|
||||
|
||||
|
||||
def test_masked_edit_inpaints_and_composites_outside_pixels():
|
||||
"""Edit selected area: SetLatentNoiseMask restricts sampling, ImageCompositeMasked
|
||||
keeps pixels OUTSIDE the mask (the composite's destination is the original)."""
|
||||
g = sh._inpaint_graph("src.png", "mask.png", "recolor the top", "fixes/m")
|
||||
cts = {n["class_type"] for n in g.values()}
|
||||
assert {"SetLatentNoiseMask", "ImageCompositeMasked", "VAEEncode", "LoadImage"} <= cts
|
||||
ks = g["13"]["inputs"]
|
||||
assert g[ks["latent_image"][0]]["class_type"] == "SetLatentNoiseMask"
|
||||
comp = next(n for n in g.values() if n["class_type"] == "ImageCompositeMasked")
|
||||
assert comp["inputs"]["destination"] == ["2", 0] # composite OVER the original scaled source
|
||||
save = next(n for n in g.values() if n["class_type"] in sh._SAVERS)
|
||||
assert save["inputs"]["images"][0] == "18" # the composited image is saved
|
||||
assert sh._assert_edit_graph(g) == ""
|
||||
|
||||
|
||||
def test_safe_asset_blocks_traversal(tmp_path):
|
||||
root = tmp_path / "out"
|
||||
(root / "designs").mkdir(parents=True)
|
||||
@@ -44,7 +124,7 @@ def test_safe_asset_blocks_traversal(tmp_path):
|
||||
|
||||
|
||||
def test_status_vocab_matches_library_lifecycle():
|
||||
assert sh._STATUSES == ("draft", "approved", "queued", "published", "deleted")
|
||||
assert sh._STATUSES == ("draft", "approved", "queued", "published", "deleted", "flagged")
|
||||
|
||||
|
||||
def test_resolve_uploads_keeps_only_safe_existing_images(tmp_path, monkeypatch):
|
||||
@@ -568,6 +648,125 @@ async def test_library_upload_indexes_into_assets_and_worklog(tmp_path, monkeypa
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_schedules_quality_gate_only_for_new_renders(tmp_path, monkeypatch):
|
||||
"""The gate fires at the ingest choke point exactly when a NEW asset row is
|
||||
created — a fresh render, or a restored-store file with no row yet — and never
|
||||
again for an identical re-post (dedup). Wiring only; the judgment runs
|
||||
out-of-band and is unit-tested against quality_gate.run."""
|
||||
import aiohttp
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
root = Path(sh.folder_paths.get_org_output_directory("acme"))
|
||||
(root / "library.json").write_text('{"assets":[]}')
|
||||
calls = []
|
||||
monkeypatch.setattr(sh.quality_gate, "schedule",
|
||||
lambda request, org, r, rel: calls.append((org, rel)))
|
||||
client = await _client(_FakeServer())
|
||||
png = b"\x89PNG\r\n\x1a\n" + b"gate-bytes"
|
||||
|
||||
form = aiohttp.FormData()
|
||||
form.add_field("image", png, filename="g.png", content_type="image/png")
|
||||
r = await client.post("/v1/library/upload", data=form, headers={"X-Org": "acme"})
|
||||
assert (await r.json())["path"] == "renders/g.png"
|
||||
assert calls == [("acme", "renders/g.png")] # fresh render → judged once
|
||||
|
||||
form2 = aiohttp.FormData()
|
||||
form2.add_field("image", png, filename="g.png", content_type="image/png")
|
||||
r2 = await client.post("/v1/library/upload", data=form2, headers={"X-Org": "acme"})
|
||||
assert (await r2.json()).get("existed") is True
|
||||
assert calls == [("acme", "renders/g.png")] # identical re-post → NOT re-judged
|
||||
|
||||
# a file already on disk but with no library row (restored store) → a new row → judged
|
||||
png2 = b"\x89PNG\r\n\x1a\n" + b"restored"
|
||||
(root / "renders" / "old.png").write_bytes(png2)
|
||||
form3 = aiohttp.FormData()
|
||||
form3.add_field("image", png2, filename="old.png", content_type="image/png")
|
||||
r3 = await client.post("/v1/library/upload", data=form3, headers={"X-Org": "acme"})
|
||||
assert (await r3.json()).get("existed") is True # bytes already there (dedup write)
|
||||
assert calls == [("acme", "renders/g.png"), ("acme", "renders/old.png")]
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_route_accepts_flagged_and_restores(tmp_path, monkeypatch):
|
||||
"""The one lifecycle route gains ``flagged`` (so the gate's status is a first-
|
||||
class one) and drives the UI's Restore back to draft. /v1/library still returns
|
||||
every status — the UI hides flagged; the backend never filters it away."""
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
root = Path(sh.folder_paths.get_org_output_directory("acme"))
|
||||
(root / "a.png").write_bytes(b"\x89PNG")
|
||||
(root / "library.json").write_text(json.dumps({"assets": [{"path": "a.png", "status": "draft"}]}))
|
||||
client = await _client(_FakeServer())
|
||||
r = await client.post("/v1/library/status", json={"path": "a.png", "status": "flagged"}, headers={"X-Org": "acme"})
|
||||
assert r.status == 200 and (await r.json())["status"] == "flagged"
|
||||
lib = await (await client.get("/v1/library", headers={"X-Org": "acme"})).json()
|
||||
assert next(x for x in lib["assets"] if x["path"] == "a.png")["status"] == "flagged"
|
||||
r2 = await client.post("/v1/library/status", json={"path": "a.png", "status": "draft"}, headers={"X-Org": "acme"})
|
||||
assert r2.status == 200 and (await r2.json())["status"] == "draft"
|
||||
assert (await client.post("/v1/library/status", json={"path": "a.png", "status": "bogus"}, headers={"X-Org": "acme"})).status == 400
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_records_negative_training_signal(tmp_path, monkeypatch):
|
||||
"""Curation IS training signal: archiving (status=deleted) or flagging an output
|
||||
records it as a bad response — vote -1 in ratings.json AND an event in feedback.jsonl
|
||||
carrying the deleted/flagged status — and restoring clears that AUTO-negative. The
|
||||
downvote is note-less, so it trains the reward dataset WITHOUT polluting prompt
|
||||
lessons (_mistakes needs note text)."""
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
root = Path(sh.folder_paths.get_org_output_directory("acme"))
|
||||
(root / "a.png").write_bytes(b"\x89PNG")
|
||||
(root / "library.json").write_text(json.dumps({"assets": [{"path": "a.png", "status": "draft"}]}))
|
||||
client = await _client(_FakeServer())
|
||||
|
||||
r = await client.post("/v1/library/status", json={"path": "a.png", "status": "deleted"}, headers={"X-Org": "acme"})
|
||||
assert r.status == 200
|
||||
ratings = json.loads((root / "ratings.json").read_text())
|
||||
assert ratings["a.png"]["vote"] == -1 and ratings["a.png"]["archived"] is True
|
||||
assert not ratings["a.png"].get("notes") # note-less: trains, never injects a lesson
|
||||
fb = [json.loads(x) for x in (root / "feedback.jsonl").read_text().splitlines() if x.strip()]
|
||||
ev = [e for e in fb if e.get("output") == "a.png"][-1]
|
||||
assert ev["vote"] == -1 and ev["status"] == "deleted" # deleted status rides along → negative-only
|
||||
assert sh._mistakes(ratings, {"a.png"}) == [] # a bare downvote never becomes a prompt lesson
|
||||
|
||||
r2 = await client.post("/v1/library/status", json={"path": "a.png", "status": "draft"}, headers={"X-Org": "acme"})
|
||||
assert r2.status == 200
|
||||
ratings2 = json.loads((root / "ratings.json").read_text())
|
||||
assert ratings2["a.png"]["vote"] == 0 and ratings2["a.png"]["archived"] is False # restore clears the auto-negative
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_respects_a_user_judgement(tmp_path, monkeypatch):
|
||||
"""A downvote + note the user typed is user-owned: archiving never overwrites it and
|
||||
restoring never clears it (only the AUTO archive-negative is reversible). Their note
|
||||
stays an injected lesson."""
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
root = Path(sh.folder_paths.get_org_output_directory("acme"))
|
||||
(root / "b.png").write_bytes(b"\x89PNG")
|
||||
(root / "library.json").write_text(json.dumps({"assets": [{"path": "b.png", "status": "draft"}]}))
|
||||
client = await _client(_FakeServer())
|
||||
await client.post("/v1/library/rate", json={"key": "b.png", "path": "b.png", "vote": -1, "notes": "left strap missing"}, headers={"X-Org": "acme"})
|
||||
await client.post("/v1/library/status", json={"path": "b.png", "status": "deleted"}, headers={"X-Org": "acme"})
|
||||
await client.post("/v1/library/status", json={"path": "b.png", "status": "draft"}, headers={"X-Org": "acme"})
|
||||
ratings = json.loads((root / "ratings.json").read_text())
|
||||
assert ratings["b.png"]["vote"] == -1 # survives archive+restore
|
||||
assert ratings["b.png"]["notes"] == "left strap missing"
|
||||
assert sh._mistakes(ratings, {"b.png"}) == ["left strap missing"] # still a real lesson
|
||||
await client.close()
|
||||
|
||||
|
||||
def test_studio_home_hides_flagged_and_offers_review():
|
||||
"""UI contract: flagged is hidden from the normal grid exactly like deleted, and
|
||||
reachable for review + restore. /v1/library returns all statuses, so the grid is
|
||||
the filter — this locks the change I made to it."""
|
||||
html = sh._HTML.read_text()
|
||||
assert "a.status==='deleted'||a.status==='flagged'" in html # hidden from normal views
|
||||
assert "FILTER='flagged'" in html # the Flagged review filter
|
||||
assert "showDel?'Recover':'Restore'" in html # restore affordance
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worklog_search_matches_paths_and_refs_not_just_prompt(tmp_path, monkeypatch):
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
@@ -588,3 +787,148 @@ async def test_worklog_search_matches_paths_and_refs_not_just_prompt(tmp_path, m
|
||||
assert await ids("brighten") == ["r2"] # prompt search still works
|
||||
assert await ids("nomatch") == []
|
||||
await client.close()
|
||||
|
||||
|
||||
# ── Queue/History surface: favorites, stacks, ratings-with-notes, flow templates ──
|
||||
@pytest.mark.asyncio
|
||||
async def test_favorites_toggle_and_tenant_isolation(tmp_path, monkeypatch):
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
client = await _client(_FakeServer())
|
||||
# empty to start
|
||||
assert (await (await client.get("/v1/favorites", headers={"X-Org": "acme"})).json()) == {"org": "acme", "favorites": {}}
|
||||
# favorite a run (key = run id) — echoes back, stamps a ts
|
||||
r = await client.post("/v1/favorites", json={"key": "run-1", "favorite": True}, headers={"X-Org": "acme"})
|
||||
assert r.status == 200 and (await r.json()) == {"ok": True, "key": "run-1", "favorite": True}
|
||||
favs = (await (await client.get("/v1/favorites", headers={"X-Org": "acme"})).json())["favorites"]
|
||||
assert "run-1" in favs and isinstance(favs["run-1"]["ts"], int)
|
||||
# favorite defaults to True when omitted
|
||||
await client.post("/v1/favorites", json={"key": "run-2"}, headers={"X-Org": "acme"})
|
||||
assert set((await (await client.get("/v1/favorites", headers={"X-Org": "acme"})).json())["favorites"]) == {"run-1", "run-2"}
|
||||
# un-favorite removes it (the heart toggles both ways)
|
||||
await client.post("/v1/favorites", json={"key": "run-1", "favorite": False}, headers={"X-Org": "acme"})
|
||||
assert list((await (await client.get("/v1/favorites", headers={"X-Org": "acme"})).json())["favorites"]) == ["run-2"]
|
||||
# validation: empty key -> 400
|
||||
assert (await client.post("/v1/favorites", json={"key": ""}, headers={"X-Org": "acme"})).status == 400
|
||||
# tenant isolation: globex sees none of acme's favorites
|
||||
assert (await (await client.get("/v1/favorites", headers={"X-Org": "globex"})).json())["favorites"] == {}
|
||||
await client.close()
|
||||
|
||||
|
||||
# Stacks are now the per-org SQLite store (Procreate folders) — the full REST
|
||||
# surface, tenancy, one-stack-per-asset, order persistence and delete semantics
|
||||
# live in test_stacks.py, which supersedes the earlier JSON whole-set store.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_stars_notes_path_and_tenant_isolation(tmp_path, monkeypatch):
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
# rate resolves via _library_root -> the org needs a library.json
|
||||
(Path(sh.folder_paths.get_org_output_directory("acme")) / "library.json").write_text('{"assets":[]}')
|
||||
(Path(sh.folder_paths.get_org_output_directory("globex")) / "library.json").write_text('{"assets":[]}')
|
||||
client = await _client(_FakeServer())
|
||||
# 0-5 stars + notes + the run's output path, keyed by run id (the flywheel row)
|
||||
r = await client.post("/v1/library/rate", json={
|
||||
"key": "run-1", "stars": 4, "notes": "hands look off", "path": "fixes/x_00001_.png"},
|
||||
headers={"X-Org": "acme"})
|
||||
assert r.status == 200
|
||||
entry = (await r.json())["entry"]
|
||||
assert entry["stars"] == 4 and entry["notes"] == "hands look off" and entry["path"] == "fixes/x_00001_.png"
|
||||
# visible on reopen; a note-only update keeps the stars (partial update)
|
||||
await client.post("/v1/library/rate", json={"key": "run-1", "notes": "better now"}, headers={"X-Org": "acme"})
|
||||
ratings = await (await client.get("/v1/library/ratings", headers={"X-Org": "acme"})).json()
|
||||
assert ratings["run-1"]["stars"] == 4 and ratings["run-1"]["notes"] == "better now"
|
||||
# a 0-star rating is valid (0-5 inclusive)
|
||||
assert (await client.post("/v1/library/rate", json={"key": "run-1", "stars": 0}, headers={"X-Org": "acme"})).status == 200
|
||||
# validation: out of range -> 400; missing key -> 400
|
||||
assert (await client.post("/v1/library/rate", json={"key": "r", "stars": 6}, headers={"X-Org": "acme"})).status == 400
|
||||
assert (await client.post("/v1/library/rate", json={"key": "", "stars": 3}, headers={"X-Org": "acme"})).status == 400
|
||||
# tenant isolation: globex never sees acme's rating
|
||||
assert (await (await client.get("/v1/library/ratings", headers={"X-Org": "globex"})).json()) == {}
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_template_accepts_flow_payload_not_just_graph(tmp_path, monkeypatch):
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
client = await _client(_FakeServer())
|
||||
# a flow template (no node graph) — what "favorite -> turn into a template" saves
|
||||
flow = {"kind": "fix", "prompt": "make the straps thicker", "refs": ["a.png"], "parents": ["a.png"]}
|
||||
r = await client.post("/v1/templates", json={"name": "Thicken Straps", "flow": flow}, headers={"X-Org": "acme"})
|
||||
assert r.status == 200 and (await r.json())["slug"] == "thicken-straps"
|
||||
# lists (not renderable — no runnable graph) and round-trips the captured flow
|
||||
lst = (await (await client.get("/v1/templates", headers={"X-Org": "acme"})).json())["templates"]
|
||||
assert lst[0]["name"] == "Thicken Straps" and lst[0]["renderable"] is False
|
||||
got = await (await client.get("/v1/templates/thicken-straps", headers={"X-Org": "acme"})).json()
|
||||
assert got["flow"] == flow and "graph" not in got
|
||||
# neither graph nor flow -> 400 (a template must capture something)
|
||||
assert (await client.post("/v1/templates", json={"name": "empty"}, headers={"X-Org": "acme"})).status == 400
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_critique_persists_thread_and_records_lesson(tmp_path, monkeypatch):
|
||||
"""A critique message: 200, replies (canned fallback when the vision call is
|
||||
unavailable), persists a user+assistant thread on the image, and writes the
|
||||
user's words as the note _mistakes reads — so critiquing trains the next render."""
|
||||
from middleware import quality_gate as qg
|
||||
monkeypatch.setattr(qg, "_downscale", lambda p: None) # skip the network vision call
|
||||
outd, _ = _orgaware(tmp_path, monkeypatch)
|
||||
root = Path(sh.folder_paths.get_org_output_directory("acme"))
|
||||
(root / "library.json").write_text('{"assets":[]}')
|
||||
(root / "fixes").mkdir(parents=True, exist_ok=True)
|
||||
from PIL import Image
|
||||
Image.new("RGB", (8, 8)).save(root / "fixes" / "x_00001_.png", "PNG")
|
||||
client = await _client(_FakeServer())
|
||||
rel = "fixes/x_00001_.png"
|
||||
r = await client.post("/v1/library/critique", json={"path": rel, "message": "skin is too orange"}, headers={"X-Org": "acme"})
|
||||
assert r.status == 200
|
||||
body = await r.json()
|
||||
assert body["reply"] and len(body["thread"]) == 2
|
||||
assert body["thread"][0]["role"] == "user" and body["thread"][1]["role"] == "assistant"
|
||||
# the note the flywheel reads carries the critique; reopening returns the thread
|
||||
ratings = await (await client.get("/v1/library/ratings", headers={"X-Org": "acme"})).json()
|
||||
assert "skin is too orange" in ratings[rel]["notes"]
|
||||
got = await (await client.get("/v1/library/critique?path=" + rel, headers={"X-Org": "acme"})).json()
|
||||
assert len(got["thread"]) == 2
|
||||
# guards: unknown asset 404, missing fields 400, tenant isolation
|
||||
assert (await client.post("/v1/library/critique", json={"path": "nope.png", "message": "x"}, headers={"X-Org": "acme"})).status == 404
|
||||
assert (await client.post("/v1/library/critique", json={"path": rel, "message": ""}, headers={"X-Org": "acme"})).status == 400
|
||||
other = await (await client.get("/v1/library/critique?path=" + rel, headers={"X-Org": "globex"})).json()
|
||||
assert other["thread"] == []
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_name_sanitized_against_html_injection(tmp_path, monkeypatch):
|
||||
"""A hostile upload filename (HTML/JS metacharacters) is collapsed to a safe
|
||||
charset at ingest — the stored path can never carry a stored-XSS payload."""
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
root = Path(sh.folder_paths.get_org_output_directory("acme"))
|
||||
(root / "library.json").write_text('{"assets":[]}')
|
||||
client = await _client(_FakeServer())
|
||||
png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
|
||||
r = await client.post('/v1/library/upload?name=<img src=x onerror=alert(1)>.png', data=png, headers={"X-Org": "acme"})
|
||||
assert r.status == 200
|
||||
path = (await r.json())["path"]
|
||||
assert "<" not in path and ">" not in path and '"' not in path and "'" not in path
|
||||
assert path.endswith(".png")
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thumbnail_serves_image_content_type(tmp_path, monkeypatch):
|
||||
"""The grid thumbnail MUST carry an image Content-Type. aiohttp's FileResponse
|
||||
guesses from the extension via mimetypes, which does not know .webp, so it served
|
||||
application/octet-stream — which the browser refuses to render (blank grid,
|
||||
'assets won't load'). Pin it."""
|
||||
from PIL import Image
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
root = Path(sh.folder_paths.get_org_output_directory("acme"))
|
||||
(root / "library.json").write_text('{"assets":[]}')
|
||||
(root / "renders").mkdir(parents=True, exist_ok=True)
|
||||
Image.new("RGB", (64, 96)).save(root / "renders" / "shot.png", "PNG")
|
||||
client = await _client(_FakeServer())
|
||||
r = await client.get("/v1/library/file?thumb=1&path=renders/shot.png", headers={"X-Org": "acme"})
|
||||
assert r.status == 200
|
||||
assert r.headers.get("Content-Type") == "image/webp" # NOT application/octet-stream
|
||||
await client.close()
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Stored-XSS regression for the queue / worklog rows (middleware/studio_home.html).
|
||||
|
||||
A malicious job field — j.node (← X-Target-GPU), j.prefix/title (← SaveImage prefix),
|
||||
desc (← prompt text), j.id (← client prompt_id) — must render INERT. Two layers:
|
||||
(1) executable: the file's OWN esc() neutralizes an <img onerror> payload (run under
|
||||
node; skipped only if node is unavailable);
|
||||
(2) static: the row builders route every field through esc() and drive row actions via
|
||||
esc'd data-* attributes + one delegated listener — never a raw-concatenated onclick.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
HTML = (Path(__file__).resolve().parents[2] / "middleware" / "studio_home.html").read_text()
|
||||
PAYLOAD = "<img src=x onerror=alert(1)>"
|
||||
|
||||
|
||||
def test_file_esc_map_neutralizes_payload():
|
||||
"""Apply the FILE's OWN esc() character map (extracted from studio_home.html) to the
|
||||
payload and assert it renders INERT — every '<'/'>' neutralized, no live tag survives.
|
||||
Pure-Python (no node dep) so it runs in CI; weakening the map (e.g. dropping '<')
|
||||
fails here."""
|
||||
m = re.search(r"const esc=.*?replace\(/\[([^\]]+)\]/g,\s*c=>\((\{[^}]+\})", HTML)
|
||||
assert m, "esc() helper/map not found"
|
||||
charclass, mapsrc = m.group(1), m.group(2)
|
||||
emap = dict(re.findall(r"'(.)':'([^']+)'", mapsrc))
|
||||
assert {"<", ">", "&", '"'} <= set(charclass) and {"<", ">"} <= emap.keys()
|
||||
out = "".join(emap.get(ch, ch) for ch in PAYLOAD)
|
||||
assert "<img" not in out and "<" not in out # every '<' neutralized
|
||||
assert emap["<"] in out # escaped entity present, inert text
|
||||
|
||||
|
||||
def test_gjRow_escapes_fields_and_delegates_cancel():
|
||||
assert "${esc(j.prefix||'render')}" in HTML # SaveImage prefix escaped
|
||||
assert "esc((j.node&&j.node!=='queued')?j.node:'your GPU')" in HTML # X-Target-GPU/identity escaped
|
||||
assert 'data-cancel="${esc(j.id)}"' in HTML # id via esc'd data attr
|
||||
|
||||
|
||||
def test_qitem_escapes_title_desc_and_delegates_cancel():
|
||||
assert "${esc(title)}" in HTML
|
||||
assert "${esc(desc)||'(no description)'}" in HTML
|
||||
assert 'data-cancel="${esc(id)}"' in HTML
|
||||
|
||||
|
||||
def test_no_raw_id_onclick_sinks_remain():
|
||||
for sink in ("cancelJob('${j.id}')", "cancelJob('${id}')", "cancelJob('${it.id}')",
|
||||
"openAmend('${it.id}')", "openContext('${it.id}')", "delWork('${it.id}')"):
|
||||
assert sink not in HTML, f"raw client-id onclick sink still present: {sink}"
|
||||
|
||||
|
||||
def test_one_delegated_listener_wires_data_actions():
|
||||
assert "t.closest('[data-cancel]')" in HTML
|
||||
assert "cancelJob(el.getAttribute('data-cancel'))" in HTML
|
||||
assert "openAmend(el.getAttribute('data-amend'))" in HTML
|
||||
Vendored
+24
-22
@@ -7,6 +7,9 @@
|
||||
<meta name="color-scheme" content="dark">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="alternate icon" href="/favicon.ico">
|
||||
<!-- Unified Hanzo shell chrome (header + pre-footer + footer). Self-contained
|
||||
bundle built from web/shell (@hanzogui/shell), served at /marketing. -->
|
||||
<link rel="stylesheet" href="/marketing/shell.css">
|
||||
<style>
|
||||
/* Hanzo Studio sign-in — matches console.hanzo.ai's design system:
|
||||
* monochrome true-black surfaces, hairline borders, 9px radii, a white
|
||||
@@ -36,15 +39,22 @@
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Growing hero that vertically centers the sign-in card between the
|
||||
unified header and pre-footer/footer. */
|
||||
.hero {
|
||||
flex: 1 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 64px 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
@@ -96,19 +106,15 @@
|
||||
.footnote { font-size: 12px; color: var(--muted); text-align: center; }
|
||||
.footnote a { color: var(--text-2); text-decoration: none; transition: color 0.15s; }
|
||||
.footnote a:hover { color: var(--text); }
|
||||
|
||||
.product {
|
||||
margin-top: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
.product a { color: var(--text-2); text-decoration: none; transition: color 0.15s; }
|
||||
.product a:hover { color: var(--text); }
|
||||
.product .sep { opacity: 0.5; padding: 0 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Unified Hanzo marketing header (surface: studio.hanzo.ai). Rendered by
|
||||
/marketing/shell.js; the sign-in card below is the progressive-enhancement
|
||||
fallback and the primary auth action. -->
|
||||
<div id="hanzo-header"></div>
|
||||
|
||||
<main class="hero">
|
||||
<div class="card">
|
||||
<div class="head">
|
||||
<svg class="mark" viewBox="0 0 67 67" role="img" aria-label="Hanzo" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -136,15 +142,11 @@
|
||||
<a href="https://console.hanzo.ai">Create an account</a>.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<p class="product">
|
||||
<a href="https://docs.hanzo.ai/docs/studio">Docs</a>
|
||||
<span class="sep">·</span>
|
||||
<a href="https://github.com/hanzoai/studio">GitHub</a>
|
||||
<span class="sep">·</span>
|
||||
<a href="https://console.hanzo.ai">Console</a>
|
||||
<span class="sep">·</span>
|
||||
<a href="https://discord.gg/hanzoai">Discord</a>
|
||||
</p>
|
||||
<!-- Unified Hanzo pre-footer CTA + ecosystem footer (rendered by shell.js). -->
|
||||
<div id="hanzo-footer"></div>
|
||||
|
||||
<script type="module" src="/marketing/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
:root{--hanzo-black: #0a0a0b;--hanzo-black-rgb: 10, 10, 11;--hanzo-white: #ffffff;--hanzo-white-rgb: 255, 255, 255;--hanzo-mono-50: #fafafa;--hanzo-mono-100: #f5f5f5;--hanzo-mono-200: #e5e5e5;--hanzo-mono-300: #d4d4d4;--hanzo-mono-400: #a3a3a3;--hanzo-mono-500: #737373;--hanzo-mono-600: #525252;--hanzo-mono-700: #404040;--hanzo-mono-800: #262626;--hanzo-mono-900: #171717;--hanzo-mono-950: #0a0a0a;--brand: var(--hanzo-black);--brand-light: var(--hanzo-mono-800);--brand-dark: #000000;--brand-hover: var(--hanzo-mono-900);--brand-secondary: var(--hanzo-mono-600);--bg-primary: #0a0a0a;--bg-secondary: #141414;--bg-tertiary: #1a1a1a;--bg-card: rgba(23, 23, 23, .5);--bg-light: #ffffff;--bg-light-secondary: #fafafa;--bg-light-tertiary: #f5f5f5;--border: #262626;--border-light: #e5e5e5;--border-focus: var(--hanzo-black);--text-primary: #fafafa;--text-secondary: #a3a3a3;--text-muted: #737373;--text-disabled: #525252;--text-light-primary: #0a0a0b;--text-light-secondary: #525252;--text-light-muted: #737373;--neutral-0: #ffffff;--neutral-50: #fafafa;--neutral-100: #f5f5f5;--neutral-200: #e5e5e5;--neutral-300: #d4d4d4;--neutral-400: #a3a3a3;--neutral-500: #737373;--neutral-600: #525252;--neutral-700: #404040;--neutral-800: #262626;--neutral-900: #171717;--neutral-950: #0a0a0a;--neutral-1000: #000000;--success: #10b981;--success-light: #34d399;--success-dark: #059669;--warning: #f59e0b;--warning-light: #fcd34d;--warning-dark: #d97706;--error: #ef4444;--error-light: #f87171;--error-dark: #dc2626;--info: #3b82f6;--info-light: #60a5fa;--info-dark: #2563eb;--gradient-brand: linear-gradient(135deg, var(--hanzo-mono-800) 0%, var(--hanzo-black) 100%);--gradient-accent: linear-gradient(135deg, var(--hanzo-black) 0%, #000000 100%);--gradient-dark: linear-gradient(135deg, #0a0a0b 0%, #262626 100%);--space-1: .25rem;--space-2: .5rem;--space-3: .75rem;--space-4: 1rem;--space-5: 1.25rem;--space-6: 1.5rem;--space-8: 2rem;--space-10: 2.5rem;--space-12: 3rem;--space-16: 4rem;--space-20: 5rem;--space-24: 6rem;--radius-sm: .125rem;--radius: .25rem;--radius-md: .375rem;--radius-lg: .5rem;--radius-xl: .75rem;--radius-2xl: 1rem;--radius-full: 9999px;--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / .05);--shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--shadow-md: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--transition-fast: .15s cubic-bezier(.4, 0, .2, 1);--transition: .2s cubic-bezier(.4, 0, .2, 1);--transition-slow: .3s cubic-bezier(.4, 0, .2, 1);--font-sans: "Geist Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--font-mono: "Geist Mono", Monaco, monospace}[data-theme=dark],.dark{color-scheme:dark}[data-theme=light],.light{--bg-primary: var(--bg-light);--bg-secondary: var(--bg-light-secondary);--bg-tertiary: var(--bg-light-tertiary);--bg-card: rgba(255, 255, 255, .8);--border: var(--border-light);--text-primary: var(--text-light-primary);--text-secondary: var(--text-light-secondary);--text-muted: var(--text-light-muted);color-scheme:light}
|
||||
Vendored
+54
File diff suppressed because one or more lines are too long
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Builds the self-contained unified-Hanzo-shell bundle for the studio.hanzo.ai
|
||||
* logged-out page. Output (../marketing/shell.{js,css}) is committed and served
|
||||
* by the Python app at /marketing. Rebuild after bumping @hanzogui/shell:
|
||||
* pnpm install && pnpm build
|
||||
*/
|
||||
import { build } from 'esbuild'
|
||||
|
||||
await build({
|
||||
entryPoints: ['src/mount.jsx'],
|
||||
bundle: true,
|
||||
minify: true,
|
||||
format: 'esm',
|
||||
jsx: 'automatic',
|
||||
target: 'es2020',
|
||||
// Production React: prunes dev-only warnings and removes bare `process` refs
|
||||
// (there is no `process` in the browser).
|
||||
define: { 'process.env.NODE_ENV': JSON.stringify('production') },
|
||||
outfile: '../marketing/shell.js',
|
||||
logLevel: 'info',
|
||||
})
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@hanzo/studio-marketing-shell",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Builds the self-contained unified-Hanzo-shell bundle (header + pre-footer + footer) served on the studio.hanzo.ai logged-out page. Output is committed to ../marketing and served by the Python app at /marketing.",
|
||||
"scripts": {
|
||||
"build": "node build.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/brand": "^1.4.1",
|
||||
"@hanzogui/shell": "^7.4.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hanzo/iam": "^0.13.1",
|
||||
"esbuild": "^0.24.2"
|
||||
}
|
||||
}
|
||||
+437
@@ -0,0 +1,437 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@hanzo/brand':
|
||||
specifier: ^1.4.1
|
||||
version: 1.4.1(react@18.3.1)
|
||||
'@hanzogui/shell':
|
||||
specifier: ^7.4.2
|
||||
version: 7.4.2(@hanzo/iam@0.13.8(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1
|
||||
react-dom:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1(react@18.3.1)
|
||||
devDependencies:
|
||||
'@hanzo/iam':
|
||||
specifier: ^0.13.1
|
||||
version: 0.13.8(react@18.3.1)
|
||||
esbuild:
|
||||
specifier: ^0.24.2
|
||||
version: 0.24.2
|
||||
|
||||
packages:
|
||||
|
||||
'@esbuild/aix-ppc64@0.24.2':
|
||||
resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.24.2':
|
||||
resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.24.2':
|
||||
resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.24.2':
|
||||
resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.24.2':
|
||||
resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.24.2':
|
||||
resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.24.2':
|
||||
resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.24.2':
|
||||
resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.24.2':
|
||||
resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.24.2':
|
||||
resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.24.2':
|
||||
resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.24.2':
|
||||
resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.24.2':
|
||||
resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.24.2':
|
||||
resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.24.2':
|
||||
resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.24.2':
|
||||
resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.24.2':
|
||||
resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.24.2':
|
||||
resolution: {integrity: sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.24.2':
|
||||
resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.24.2':
|
||||
resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.24.2':
|
||||
resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/sunos-x64@0.24.2':
|
||||
resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.24.2':
|
||||
resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.24.2':
|
||||
resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.24.2':
|
||||
resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@hanzo/brand@1.4.1':
|
||||
resolution: {integrity: sha512-tApXct8gE/HOYa61h6TWQfDp07l1pmB+vqmxlJtU+MhocbwEkW4VPJrQdPuwF/5GPZYQauNT2uaRpnZdCK8ikw==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
'@hanzo/iam@0.13.8':
|
||||
resolution: {integrity: sha512-6D4lZSSuTBWV/bof5u5l6VEE0VZhNnpF99svirmHTRko5MsZ5sEd3+7xZ28KH0UL0oquvHwRJ6tkvn+E+f5YuQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
'@hanzo/logo@1.0.13':
|
||||
resolution: {integrity: sha512-94VA6S7oVY5UZ816295Ez12p8a76ZGEct14JQwE6r5UCutD3K9TBgx5V01gjwxwVL9tWnXn/yut4PHRAY/9RNQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
'@hanzogui/shell@7.4.2':
|
||||
resolution: {integrity: sha512-Haig4FLJUAgNbHvYxgsX/Y4D+VZW9fzvYoYi6Z19ZKkYCXnFG6S0wQmwZhqFW5KCmlnwUV7jUSJw9vOdvqj7nw==}
|
||||
peerDependencies:
|
||||
'@hanzo/iam': ^0.13.1
|
||||
react: '*'
|
||||
react-dom: '*'
|
||||
|
||||
base64url@3.0.1:
|
||||
resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
esbuild@0.24.2:
|
||||
resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
jose@6.2.4:
|
||||
resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
libphonenumber-js@1.13.9:
|
||||
resolution: {integrity: sha512-VNS5vWMM7r0P66BYv+TQJATxExEgLxN+34hfHDVhDkUsGAE4cRg0shCNSLTXNKm7nIUscC7AfB51TjxEeF7msQ==}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
|
||||
oauth@0.10.2:
|
||||
resolution: {integrity: sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==}
|
||||
|
||||
passport-oauth2@1.8.0:
|
||||
resolution: {integrity: sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
passport-strategy@1.0.0:
|
||||
resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
react-dom@18.3.1:
|
||||
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
|
||||
peerDependencies:
|
||||
react: ^18.3.1
|
||||
|
||||
react@18.3.1:
|
||||
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
scheduler@0.23.2:
|
||||
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
|
||||
|
||||
uid2@0.0.4:
|
||||
resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==}
|
||||
|
||||
utils-merge@1.0.1:
|
||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@esbuild/aix-ppc64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@hanzo/brand@1.4.1(react@18.3.1)':
|
||||
dependencies:
|
||||
'@hanzo/logo': 1.0.13(react@18.3.1)
|
||||
optionalDependencies:
|
||||
react: 18.3.1
|
||||
|
||||
'@hanzo/iam@0.13.8(react@18.3.1)':
|
||||
dependencies:
|
||||
jose: 6.2.4
|
||||
libphonenumber-js: 1.13.9
|
||||
passport-oauth2: 1.8.0
|
||||
optionalDependencies:
|
||||
react: 18.3.1
|
||||
|
||||
'@hanzo/logo@1.0.13(react@18.3.1)':
|
||||
optionalDependencies:
|
||||
react: 18.3.1
|
||||
|
||||
'@hanzogui/shell@7.4.2(@hanzo/iam@0.13.8(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@hanzo/iam': 0.13.8(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
base64url@3.0.1: {}
|
||||
|
||||
esbuild@0.24.2:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.24.2
|
||||
'@esbuild/android-arm': 0.24.2
|
||||
'@esbuild/android-arm64': 0.24.2
|
||||
'@esbuild/android-x64': 0.24.2
|
||||
'@esbuild/darwin-arm64': 0.24.2
|
||||
'@esbuild/darwin-x64': 0.24.2
|
||||
'@esbuild/freebsd-arm64': 0.24.2
|
||||
'@esbuild/freebsd-x64': 0.24.2
|
||||
'@esbuild/linux-arm': 0.24.2
|
||||
'@esbuild/linux-arm64': 0.24.2
|
||||
'@esbuild/linux-ia32': 0.24.2
|
||||
'@esbuild/linux-loong64': 0.24.2
|
||||
'@esbuild/linux-mips64el': 0.24.2
|
||||
'@esbuild/linux-ppc64': 0.24.2
|
||||
'@esbuild/linux-riscv64': 0.24.2
|
||||
'@esbuild/linux-s390x': 0.24.2
|
||||
'@esbuild/linux-x64': 0.24.2
|
||||
'@esbuild/netbsd-arm64': 0.24.2
|
||||
'@esbuild/netbsd-x64': 0.24.2
|
||||
'@esbuild/openbsd-arm64': 0.24.2
|
||||
'@esbuild/openbsd-x64': 0.24.2
|
||||
'@esbuild/sunos-x64': 0.24.2
|
||||
'@esbuild/win32-arm64': 0.24.2
|
||||
'@esbuild/win32-ia32': 0.24.2
|
||||
'@esbuild/win32-x64': 0.24.2
|
||||
|
||||
jose@6.2.4: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
libphonenumber-js@1.13.9: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
oauth@0.10.2: {}
|
||||
|
||||
passport-oauth2@1.8.0:
|
||||
dependencies:
|
||||
base64url: 3.0.1
|
||||
oauth: 0.10.2
|
||||
passport-strategy: 1.0.0
|
||||
uid2: 0.0.4
|
||||
utils-merge: 1.0.1
|
||||
|
||||
passport-strategy@1.0.0: {}
|
||||
|
||||
react-dom@18.3.1(react@18.3.1):
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
react: 18.3.1
|
||||
scheduler: 0.23.2
|
||||
|
||||
react@18.3.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
||||
scheduler@0.23.2:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
||||
uid2@0.0.4: {}
|
||||
|
||||
utils-merge@1.0.1: {}
|
||||
Vendored
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Mounts the unified Hanzo shell onto the studio.hanzo.ai logged-out page.
|
||||
*
|
||||
* The header, pre-footer CTA and footer are the ONE canonical chrome shipped in
|
||||
* `@hanzogui/shell` — consumed as data from the "studio.hanzo.ai" surface, never
|
||||
* reimplemented here. `@hanzo/brand` supplies the CSS custom properties the shell
|
||||
* themes from. esbuild bundles React + the three marketing components into one
|
||||
* self-contained ESM asset (`../marketing/shell.{js,css}`) served at /marketing.
|
||||
*
|
||||
* Only the three logged-out components are imported, so the signed-in graph
|
||||
* (HanzoAppHeader → useTenantAuth → @hanzo/iam) is tree-shaken away.
|
||||
*/
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { HanzoHeader, HanzoPreFooterCTA, HanzoFooter } from '@hanzogui/shell'
|
||||
import '@hanzo/brand/styles/variables.css'
|
||||
|
||||
const SURFACE = 'studio.hanzo.ai'
|
||||
|
||||
// The header's account slot: a same-origin "Sign in" link. `/login` is the OIDC
|
||||
// entry point — the server's handle_login decides framing-aware connect-card vs
|
||||
// IdP 302 — so we never build the IAM URL client-side.
|
||||
const account = (
|
||||
<a
|
||||
href="/login"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
height: 34,
|
||||
padding: '0 16px',
|
||||
borderRadius: 9,
|
||||
background: 'var(--hanzo-white, #ffffff)',
|
||||
color: '#000',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
textDecoration: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
)
|
||||
|
||||
function mount(id, node) {
|
||||
const el = document.getElementById(id)
|
||||
if (el) createRoot(el).render(node)
|
||||
}
|
||||
|
||||
mount('hanzo-header', <HanzoHeader surface={SURFACE} account={account} />)
|
||||
mount(
|
||||
'hanzo-footer',
|
||||
<>
|
||||
<HanzoPreFooterCTA surface={SURFACE} />
|
||||
<HanzoFooter currentProductId="studio" />
|
||||
</>,
|
||||
)
|
||||
Vendored
+73
-43
@@ -1,13 +1,20 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
chat, getSession, getLibrary, getRenderQueue, gpuOnline, thumbURL,
|
||||
type ChatMessage, type Session, type Asset, type RenderJob,
|
||||
} from './api'
|
||||
import { chat, getSession, gpuOnline, type ChatMessage, type Session } from './api'
|
||||
import { Rail } from './rail'
|
||||
import { useLayout, startDrag, clamp, DEFAULT, RAIL_MIN, RAIL_MAX } from './layout'
|
||||
|
||||
// Studio-chat: the specialized chat surface. You say what to create; the cloud
|
||||
// /v1/agent "create" preset drives studio's render tools (fix/compose) and the
|
||||
// outputs land in the library strip. Increment 1 uses a minimal own-UI; the next
|
||||
// increment swaps the transcript/composer for @hanzo/chat's <Chat> on @hanzo/gui.
|
||||
// /v1/agent "create" preset drives studio's render tools (fix/compose) and the runs
|
||||
// land in the queue/history rail on the right — Up next while they render, History
|
||||
// once done. The main area is the conversation / current generation; the rail is the
|
||||
// whole YouTube-watch-page-style queue + history + stacks + ratings surface.
|
||||
|
||||
const EXAMPLES = [
|
||||
'make the Valentina back crotchless',
|
||||
'put design 13 on model 01',
|
||||
'brighten the studio lighting',
|
||||
'compose design 4 and design 9 into one look',
|
||||
]
|
||||
|
||||
export function App() {
|
||||
const [session, setSession] = useState<Session>({})
|
||||
@@ -15,30 +22,26 @@ export function App() {
|
||||
const [conversationId, setConversationId] = useState<string>()
|
||||
const [input, setInput] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [assets, setAssets] = useState<Asset[]>([])
|
||||
const [jobs, setJobs] = useState<RenderJob[]>([])
|
||||
const [gpu, setGpu] = useState(false)
|
||||
const [acct, setAcct] = useState(false)
|
||||
const scroller = useRef<HTMLDivElement>(null)
|
||||
const composer = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Layout is keyed by org+user so each person's dividers/collapse/filter persist.
|
||||
const who = session.authenticated !== undefined ? `${session.org || 'default'}:${session.user || 'anon'}` : ''
|
||||
const { layout, patch, toggle, reset, beginDrag, endDrag } = useLayout(who)
|
||||
|
||||
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)
|
||||
const tick = () => gpuOnline().then(on => { if (alive) setGpu(on) })
|
||||
tick(); const t = setInterval(tick, 15000)
|
||||
return () => { alive = false; clearInterval(t) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { scroller.current?.scrollTo(0, scroller.current.scrollHeight) }, [messages, busy])
|
||||
|
||||
const ready = !!input.trim() && !busy
|
||||
|
||||
async function send() {
|
||||
const text = input.trim()
|
||||
if (!text || busy) return
|
||||
@@ -48,7 +51,7 @@ export function App() {
|
||||
const r = await chat(next, conversationId)
|
||||
setConversationId(r.conversationId)
|
||||
const note = r.actions.length
|
||||
? `\n\n${r.actions.map(a => a.error ? `⚠︎ ${a.name}: ${a.error}` : `▸ ${a.name} dispatched`).join('\n')}`
|
||||
? `\n\n${r.actions.map(a => a.error ? `⚠︎ ${a.name}: ${a.error}` : `▸ ${a.name} dispatched — see Up next →`).join('\n')}`
|
||||
: ''
|
||||
setMessages([...next, { role: 'assistant', content: (r.reply || '…') + note }])
|
||||
} catch (e) {
|
||||
@@ -58,13 +61,47 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
function insertRef(text: string) {
|
||||
setInput(v => (v.trim() ? `${v.trim()} ${text}` : text) + ' ')
|
||||
composer.current?.focus()
|
||||
}
|
||||
function useExample(ex: string) {
|
||||
setInput(ex); composer.current?.focus()
|
||||
}
|
||||
|
||||
function onVSplit(e: React.PointerEvent) {
|
||||
beginDrag()
|
||||
const startW = layout.railW
|
||||
startDrag(e, 'x', d => patch({ railW: clamp(startW - d, RAIL_MIN, RAIL_MAX) }), endDrag)
|
||||
}
|
||||
|
||||
const initials = (session.user || session.org || session.email || '?').replace(/[^a-zA-Z0-9]/g, '').slice(0, 2).toUpperCase() || '?'
|
||||
|
||||
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>
|
||||
<div className="acct-wrap">
|
||||
<button className="avatar" aria-label="account" aria-haspopup="menu"
|
||||
title={session.email || session.user || 'account'} onClick={() => setAcct(a => !a)}>
|
||||
{initials}
|
||||
</button>
|
||||
{acct && (
|
||||
<div className="rc-menu acct-menu" role="menu" onMouseLeave={() => setAcct(false)}>
|
||||
<div className="acct-id">
|
||||
<div className="acct-name">{session.user || (session.authenticated ? 'signed in' : 'guest')}</div>
|
||||
{session.email && <div className="acct-sub">{session.email}</div>}
|
||||
{session.org && <div className="acct-sub">org · {session.org}</div>}
|
||||
</div>
|
||||
<a role="menuitem" href="https://console.hanzo.ai" target="_blank" rel="noreferrer">Console</a>
|
||||
{session.authenticated
|
||||
? <a role="menuitem" href="/logout">Sign out</a>
|
||||
: <a role="menuitem" href={session.iam_url || '/login'}>Sign in with Hanzo</a>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="body">
|
||||
@@ -73,8 +110,11 @@ export function App() {
|
||||
{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>
|
||||
<p>Describe an edit or a new shot. Runs appear in <b>Up next</b> on the right as they render,
|
||||
and finished results land in <b>History</b> — rate them to teach the next pass.</p>
|
||||
<div className="examples">
|
||||
{EXAMPLES.map(ex => <button key={ex} className="ex-chip" onClick={() => useExample(ex)}>{ex}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((m, i) => (
|
||||
@@ -84,30 +124,20 @@ export function App() {
|
||||
</div>
|
||||
<div className="composer">
|
||||
<textarea
|
||||
value={input} placeholder="Describe what to create or change…"
|
||||
ref={composer} 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>
|
||||
<button className={`send${ready ? ' ready' : ''}`} onClick={send} disabled={busy}>Send</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className={`rail${assets.length || jobs.length ? ' active' : ''}`}>
|
||||
{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>
|
||||
<div className="vsplit" onPointerDown={onVSplit} onDoubleClick={() => patch({ railW: DEFAULT.railW })}
|
||||
title="drag to resize · double-click to reset" role="separator" aria-orientation="vertical" />
|
||||
|
||||
<aside className="rail" style={{ width: layout.railW }}>
|
||||
<Rail layout={layout} patch={patch} toggle={toggle} reset={reset}
|
||||
beginDrag={beginDrag} endDrag={endDrag} onUseRef={insertRef} />
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
Vendored
+116
-9
@@ -4,8 +4,8 @@
|
||||
// hanzo_token cookie is scoped to .hanzo.ai so credentials:'include' carries it
|
||||
// cross-origin, and the round replays it into the per-org-billed completion.
|
||||
// (Gateway CORS must allow the studio.hanzo.ai origin with credentials.)
|
||||
// - the studio engine API: /v1/session, /v1/library, /v1/render-queue, … —
|
||||
// same-origin on studio.hanzo.ai (same cookie).
|
||||
// - the studio engine API: /v1/session, /v1/worklog, /v1/queue/*, /v1/favorites,
|
||||
// /v1/stacks, … — same-origin on studio.hanzo.ai (same cookie).
|
||||
// No token handling in the browser: the cookie is the credential for both.
|
||||
// VITE_STUDIO_API / VITE_AGENT_API override the bases for local dev.
|
||||
|
||||
@@ -54,14 +54,121 @@ export interface Session {
|
||||
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).
|
||||
// A run in a flow: one dispatched generation (fix/compose/rerun/render/template) with
|
||||
// its instruction, references, parents (the assets it derived from), landed output and
|
||||
// real status. The queue/history backbone — GET /v1/worklog, newest first.
|
||||
export type RunStatus = 'queued' | 'running' | 'done' | 'failed' | 'cancelled'
|
||||
export interface Run {
|
||||
id: string; ts: number; kind: string; prompt: string
|
||||
refs: string[]; uploads: string[]; parents: string[]
|
||||
output_prefix: string; output: string | null
|
||||
lane: string; node: string; status: RunStatus
|
||||
started_at?: number; finished_at?: number; error?: string
|
||||
params?: Record<string, unknown>
|
||||
}
|
||||
export const getWorklog = (q?: string) =>
|
||||
j<{ org: string; items: Run[] }>(`${BASE}/v1/worklog${q ? `?q=${encodeURIComponent(q)}` : ''}`)
|
||||
.catch(() => ({ org: '', items: [] as Run[] }))
|
||||
|
||||
// Live position + ETA overlay for the ACTIVE runs, per lane, from the org's own
|
||||
// measured medians. Joined onto the worklog rows by id.
|
||||
export interface QueueItem {
|
||||
id: string; kind: string; prompt: string; status: string; node: string
|
||||
lane?: string; position?: number; of?: number
|
||||
eta_seconds?: number; wait_seconds?: number; elapsed_seconds?: number; remaining_seconds?: number | null
|
||||
}
|
||||
export interface QueueStatus { items: Record<string, QueueItem>; medians: Record<string, number>; lanes: Record<string, number>; now: number }
|
||||
export const getQueueStatus = () =>
|
||||
j<QueueStatus>(`${BASE}/v1/queue/status`).catch(() => ({ items: {}, medians: {}, lanes: {}, now: Math.floor(Date.now() / 1000) }))
|
||||
|
||||
// The version chain of one output — ancestors (what it derived from) + descendants
|
||||
// (later iterations). The iteration history of a design flow.
|
||||
export interface LineageStep { path: string; prompt: string; kind: string; ts: number }
|
||||
export interface Lineage { ancestors: LineageStep[]; node: string; descendants: LineageStep[] }
|
||||
export const getLineage = (path: string) =>
|
||||
j<Lineage>(`${BASE}/v1/worklog/lineage?path=${encodeURIComponent(path)}`)
|
||||
.catch(() => ({ ancestors: [], node: path, descendants: [] }))
|
||||
|
||||
// Delete a queued run (tenant-scoped cancel; a foreign id 404s).
|
||||
export const cancelRun = (id: string) =>
|
||||
j<{ ok: boolean; canceled: string }>(`${BASE}/v1/queue/cancel`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt_id: id }),
|
||||
})
|
||||
|
||||
// Amend a still-queued run — the ONE way to change it (atomic cancel + re-dispatch
|
||||
// with the same instruction plus added references). Adding references IS an amend.
|
||||
export const amendRun = (id: string, instruction: string, uploads: string[], paths: string[] = []) =>
|
||||
j<{ ok: boolean; prompt_id: string; amended_from: string; refs: number }>(`${BASE}/v1/queue/amend`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt_id: id, instruction, uploads, paths }),
|
||||
})
|
||||
|
||||
// Stage a reference image in the org input dir (the engine's upload route); returns
|
||||
// the flat name amend/compose reference by.
|
||||
export const uploadRef = (file: File) => {
|
||||
const fd = new FormData()
|
||||
fd.append('image', file, file.name)
|
||||
return j<{ name: string; subfolder: string; type: string }>(`${BASE}/upload/image`, { method: 'POST', body: fd })
|
||||
}
|
||||
|
||||
// Ratings feed the AI flywheel: 0-5 stars + a note, keyed by run id, carrying the
|
||||
// output path. Persisted per-org; visible on reopen.
|
||||
export interface Rating { stars?: number; notes?: string; path?: string; updatedAt?: number }
|
||||
export const getRatings = () => j<Record<string, Rating>>(`${BASE}/v1/library/ratings`).catch(() => ({}))
|
||||
export const rate = (key: string, patch: { stars?: number; notes?: string; path?: string }) =>
|
||||
j<{ ok: boolean; key: string; entry: Rating }>(`${BASE}/v1/library/rate`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, ...patch }),
|
||||
})
|
||||
|
||||
// Soft-delete a finished result — the library status lifecycle ('deleted'), the same
|
||||
// mechanism the assets view uses. Keyed by the run's output path.
|
||||
export const softDelete = (path: string) =>
|
||||
j<{ ok: boolean; path: string; status: string }>(`${BASE}/v1/library/status`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path, status: 'deleted' }),
|
||||
})
|
||||
// The org's soft-deleted asset paths — so History hides results deleted here or in the
|
||||
// assets view, and the delete persists across reloads.
|
||||
export const getDeletedPaths = () =>
|
||||
j<{ assets: { path: string; status?: string }[] }>(`${BASE}/v1/library`)
|
||||
.then(r => (r.assets || []).filter(a => a.status === 'deleted').map(a => a.path))
|
||||
.catch(() => [] as string[])
|
||||
|
||||
// Favorites: the heart toggle, a per-org set keyed by run id. Separate concern from
|
||||
// ratings — a bookmark, not quality feedback.
|
||||
export const getFavorites = () =>
|
||||
j<{ org: string; favorites: Record<string, { ts: number }> }>(`${BASE}/v1/favorites`)
|
||||
.then(r => r.favorites).catch(() => ({} as Record<string, { ts: number }>))
|
||||
export const setFavorite = (key: string, favorite: boolean) =>
|
||||
j<{ ok: boolean; key: string; favorite: boolean }>(`${BASE}/v1/favorites`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, favorite }),
|
||||
})
|
||||
|
||||
// Stacks: iOS-folder groupings of runs. The client owns the arrangement; the whole
|
||||
// normalized set is POSTed back and echoed.
|
||||
export interface Stack { id: string; name: string; items: string[]; ts: number }
|
||||
export const getStacks = () =>
|
||||
j<{ org: string; stacks: Stack[] }>(`${BASE}/v1/stacks`).then(r => r.stacks).catch(() => [] as Stack[])
|
||||
export const saveStacks = (stacks: Stack[]) =>
|
||||
j<{ org: string; stacks: Stack[] }>(`${BASE}/v1/stacks`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ stacks }),
|
||||
}).then(r => r.stacks)
|
||||
|
||||
// Favorite → template: capture a reusable FLOW (kind + instruction + refs + parents).
|
||||
export interface Flow { kind: string; prompt: string; refs: string[]; parents: string[] }
|
||||
export const saveTemplate = (name: string, flow: Flow) =>
|
||||
j<{ ok: boolean; name: string; slug: string }>(`${BASE}/v1/templates`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, flow }),
|
||||
})
|
||||
|
||||
// Thumbnails: a landed library output, a staged input reference, and the full-res file.
|
||||
export const thumbURL = (path: string) => `${BASE}/v1/library/file?thumb=1&path=${encodeURIComponent(path)}`
|
||||
export const inputThumbURL = (name: string) => `${BASE}/v1/library/file?input=1&thumb=1&path=${encodeURIComponent(name)}`
|
||||
export const fileURL = (path: string) => `${BASE}/v1/library/file?path=${encodeURIComponent(path)}`
|
||||
|
||||
Vendored
+219
@@ -0,0 +1,219 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
type Run, type QueueItem, type Rating,
|
||||
thumbURL, inputThumbURL, fileURL,
|
||||
} from './api'
|
||||
|
||||
// ── Small shared bits ────────────────────────────────────────────────────────────
|
||||
export function relTime(ts: number, now: number): string {
|
||||
const s = Math.max(0, now - ts)
|
||||
if (s < 45) return 'just now'
|
||||
if (s < 3600) return `${Math.round(s / 60)}m ago`
|
||||
if (s < 86400) return `${Math.round(s / 3600)}h ago`
|
||||
return `${Math.round(s / 86400)}d ago`
|
||||
}
|
||||
export function fmtDur(sec: number): string {
|
||||
if (sec < 60) return `${Math.round(sec)}s`
|
||||
if (sec < 3600) return `${Math.round(sec / 60)}m`
|
||||
return `${(sec / 3600).toFixed(1)}h`
|
||||
}
|
||||
// A friendly identifier for a run, from its output/prefix (the finding: cards must
|
||||
// carry names like the "design 13" the prompts teach). Strips the _NNNNN_ counter.
|
||||
export function idLabel(run: Run): string {
|
||||
const raw = (run.output || run.output_prefix || run.kind || '').split('/').pop() || run.kind
|
||||
return raw.replace(/_\d+_?\.\w+$/, '').replace(/_\d+_$/, '').replace(/\.\w+$/, '') || run.kind
|
||||
}
|
||||
export function runThumb(run: Run): string | null {
|
||||
if (run.output) return thumbURL(run.output)
|
||||
if (run.parents[0]) return thumbURL(run.parents[0])
|
||||
if (run.refs[0]) return inputThumbURL(run.refs[0])
|
||||
return null
|
||||
}
|
||||
const isActive = (run: Run) => run.status === 'queued' || run.status === 'running'
|
||||
|
||||
export function Stars({ value, onChange }: { value: number; onChange?: (n: number) => void }) {
|
||||
const stars = [1, 2, 3, 4, 5]
|
||||
return (
|
||||
<span className={`stars${onChange ? ' interactive' : ''}`} role={onChange ? 'radiogroup' : undefined} aria-label="rating">
|
||||
{stars.map(n => (
|
||||
<button
|
||||
key={n} type="button" className={`star${n <= value ? ' on' : ''}`}
|
||||
disabled={!onChange} aria-label={`${n} star${n > 1 ? 's' : ''}`}
|
||||
onClick={onChange ? () => onChange(n === value ? 0 : n) : undefined}
|
||||
>★</button>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── One run card ─────────────────────────────────────────────────────────────────
|
||||
export interface CardProps {
|
||||
run: Run
|
||||
qitem?: QueueItem
|
||||
rating?: Rating
|
||||
favorite: boolean
|
||||
now: number
|
||||
version?: { pos: number; len: number }
|
||||
onRemove: (run: Run) => void
|
||||
onAmend: (run: Run, files: File[]) => Promise<void>
|
||||
onRate: (run: Run, patch: { stars?: number; notes?: string; path?: string }) => void
|
||||
onFavorite: (run: Run) => void
|
||||
onAddToStack: (run: Run) => void
|
||||
onOpenVersions: (run: Run) => void
|
||||
onUseRef: (run: Run) => void
|
||||
dnd: {
|
||||
onDragStart: (e: React.DragEvent) => void
|
||||
onDragEnter: (e: React.DragEvent) => void
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDragLeave: (e: React.DragEvent) => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
intent: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function RunCard(p: CardProps) {
|
||||
const { run, qitem, rating, favorite, now, version } = p
|
||||
const [menu, setMenu] = useState(false)
|
||||
const [panel, setPanel] = useState<'none' | 'rate' | 'refs'>('none')
|
||||
const [stars, setStars] = useState(rating?.stars ?? 0)
|
||||
const [notes, setNotes] = useState(rating?.notes ?? '')
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [fileOver, setFileOver] = useState(false)
|
||||
const [confirmDel, setConfirmDel] = useState(false)
|
||||
|
||||
const active = isActive(run)
|
||||
const src = runThumb(run)
|
||||
const fresh = run.status === 'done' && now - (run.finished_at || run.ts) < 120
|
||||
|
||||
// Corner badge: ETA while queued, elapsed while running, stars-or-node when done.
|
||||
let badge = ''
|
||||
if (run.status === 'running') badge = qitem?.elapsed_seconds != null ? fmtDur(qitem.elapsed_seconds) : 'running'
|
||||
else if (run.status === 'queued') badge = qitem?.eta_seconds != null ? `~${fmtDur(qitem.eta_seconds)}` : 'queued'
|
||||
else if (run.status === 'failed') badge = 'failed'
|
||||
else if (run.status === 'cancelled') badge = 'cancelled'
|
||||
else badge = rating?.stars ? `★${rating.stars}` : run.node
|
||||
|
||||
const pos = qitem?.position && qitem?.of ? `${qitem.position}/${qitem.of}` : ''
|
||||
const meta = [run.node, relTime(run.ts, now), run.status + (pos ? ` · ${pos}` : '')].filter(Boolean).join(' · ')
|
||||
|
||||
const collectFiles = (list: FileList | null) =>
|
||||
setFiles(f => [...f, ...[...(list || [])].filter(x => x.type.startsWith('image/'))])
|
||||
|
||||
function onDrop(e: React.DragEvent) {
|
||||
const dropped = [...(e.dataTransfer?.files || [])].filter(x => x.type.startsWith('image/'))
|
||||
if (dropped.length && active) { // files onto a queued card → add references
|
||||
e.preventDefault(); e.stopPropagation(); setFileOver(false)
|
||||
setPanel('refs'); setFiles(f => [...f, ...dropped]); return
|
||||
}
|
||||
setFileOver(false); p.dnd.onDrop(e) // otherwise an internal run drag → stacking
|
||||
}
|
||||
function onDragOver(e: React.DragEvent) {
|
||||
if (active && e.dataTransfer?.types?.includes('Files')) { e.preventDefault(); setFileOver(true); return }
|
||||
p.dnd.onDragOver(e) // internal run drag → let the stack drop fire
|
||||
}
|
||||
|
||||
async function applyRefs() {
|
||||
if (!files.length || busy) return
|
||||
setBusy(true)
|
||||
try { await p.onAmend(run, files); setFiles([]); setPanel('none') } finally { setBusy(false) }
|
||||
}
|
||||
function saveRate() {
|
||||
p.onRate(run, { stars, notes, path: run.output || undefined })
|
||||
setPanel('none')
|
||||
}
|
||||
// The × (and the menu's Delete) share ONE handler: a queued run cancels straight
|
||||
// away; a finished result is a destructive soft-delete, so it asks first.
|
||||
function requestDelete() {
|
||||
if (active) p.onRemove(run)
|
||||
else setConfirmDel(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`rc rc-${run.status}${p.dnd.intent ? ' stack-intent' : ''}${fileOver ? ' file-over' : ''}`}
|
||||
draggable onDragStart={p.dnd.onDragStart}
|
||||
onDragEnter={p.dnd.onDragEnter} onDragLeave={e => { setFileOver(false); p.dnd.onDragLeave(e) }}
|
||||
onDragOver={onDragOver} onDrop={onDrop}
|
||||
>
|
||||
<a className="rc-thumb" draggable={false} href={run.output ? fileURL(run.output) : undefined}
|
||||
target="_blank" rel="noreferrer" onClick={e => { if (!run.output) e.preventDefault() }}>
|
||||
{src ? <img loading="lazy" draggable={false} src={src} alt="" /> : <span className="rc-ph">{run.kind || '—'}</span>}
|
||||
<span className={`rc-badge s-${run.status}`}>{badge}</span>
|
||||
{fresh && <span className="rc-new">New</span>}
|
||||
</a>
|
||||
|
||||
<button className="rc-x" aria-label={active ? 'cancel' : 'delete'}
|
||||
title={active ? 'Cancel' : 'Delete'} onClick={requestDelete}>×</button>
|
||||
{confirmDel && (
|
||||
<div className="rc-confirm" role="alertdialog" aria-label="confirm delete">
|
||||
<span>Delete result?</span>
|
||||
<button className="danger" onClick={() => { setConfirmDel(false); p.onRemove(run) }}>Delete</button>
|
||||
<button onClick={() => setConfirmDel(false)}>Keep</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rc-body">
|
||||
<div className="rc-title" title={run.prompt || run.kind}>{run.prompt || `(${run.kind})`}</div>
|
||||
<div className="rc-meta">{meta}</div>
|
||||
<div className="rc-tags">
|
||||
<span className="rc-id">{idLabel(run)}</span>
|
||||
{version && version.len > 1 && (
|
||||
<button className="rc-vers" title="versions of this design" onClick={() => p.onOpenVersions(run)}>
|
||||
{Array.from({ length: Math.min(version.len, 6) }).map((_, i) =>
|
||||
<span key={i} className={`pip${i === version.pos - 1 ? ' on' : ''}`} />)}
|
||||
<span className="rc-vn">v{version.pos}</span>
|
||||
</button>
|
||||
)}
|
||||
{favorite && <span className="rc-fav" title="favorite">♥</span>}
|
||||
{rating?.stars ? <span className="rc-rated"><Stars value={rating.stars} /></span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="rc-more" aria-label="more actions" aria-haspopup="menu"
|
||||
onClick={() => setMenu(m => !m)}>⋯</button>
|
||||
|
||||
{menu && (
|
||||
<div className="rc-menu" role="menu" onMouseLeave={() => setMenu(false)}>
|
||||
<button role="menuitem" onClick={() => { setMenu(false); requestDelete() }}>Delete</button>
|
||||
{active && <button role="menuitem" onClick={() => { setMenu(false); setPanel('refs') }}>Add references</button>}
|
||||
<button role="menuitem" onClick={() => { setMenu(false); setStars(rating?.stars ?? 0); setNotes(rating?.notes ?? ''); setPanel('rate') }}>Rate & note</button>
|
||||
<button role="menuitem" onClick={() => { setMenu(false); p.onFavorite(run) }}>{favorite ? 'Unfavorite' : 'Favorite'}</button>
|
||||
<button role="menuitem" onClick={() => { setMenu(false); p.onAddToStack(run) }}>Add to stack</button>
|
||||
<button role="menuitem" onClick={() => { setMenu(false); p.onUseRef(run) }}>Use as reference</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{panel === 'rate' && (
|
||||
<div className="rc-panel">
|
||||
<Stars value={stars} onChange={setStars} />
|
||||
<textarea className="rc-notes" value={notes} onChange={e => setNotes(e.target.value)}
|
||||
placeholder="What should the AI do differently next time?" />
|
||||
<div className="rc-panel-row">
|
||||
<button className="pri" onClick={saveRate}>Save</button>
|
||||
<button onClick={() => setPanel('none')}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{panel === 'refs' && (
|
||||
<div className={`rc-panel rc-drop${fileOver ? ' over' : ''}`}
|
||||
onDragOver={e => { e.preventDefault(); setFileOver(true) }}
|
||||
onDragLeave={() => setFileOver(false)}
|
||||
onDrop={e => { e.preventDefault(); e.stopPropagation(); setFileOver(false); collectFiles(e.dataTransfer.files) }}>
|
||||
<div className="rc-drop-hint">Drop images here, or</div>
|
||||
<label className="rc-pick">Choose files
|
||||
<input type="file" multiple accept="image/*" onChange={e => collectFiles(e.target.files)} />
|
||||
</label>
|
||||
{files.length > 0 && <div className="rc-files">{files.map((f, i) => <span key={i} className="chip">{f.name}</span>)}</div>}
|
||||
<div className="rc-panel-row">
|
||||
<button className="pri" disabled={!files.length || busy} onClick={applyRefs}>
|
||||
{busy ? 'Requeuing…' : `Add ${files.length || ''} & requeue`}
|
||||
</button>
|
||||
<button onClick={() => { setFiles([]); setPanel('none') }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
Vendored
+82
@@ -0,0 +1,82 @@
|
||||
// Layout state for the queue/history rail: divider positions, per-section collapse,
|
||||
// and the active filter chip — ONE mechanism, persisted in localStorage keyed by
|
||||
// org+user (from /v1/session) and restored on load. Dividers are native pointer
|
||||
// drags; nothing here depends on a DnD library.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type React from 'react'
|
||||
|
||||
export type Filter = 'all' | 'queued' | 'done' | 'favorites' | 'stacks'
|
||||
|
||||
export interface Layout {
|
||||
railW: number // px — the main | rail divider (horizontal resize)
|
||||
upFrac: number // 0..1 — the Up next | History divider (vertical resize)
|
||||
collapsed: { next: boolean; up: boolean; hist: boolean }
|
||||
filter: Filter
|
||||
}
|
||||
|
||||
export const DEFAULT: Layout = {
|
||||
railW: 360, upFrac: 0.5,
|
||||
collapsed: { next: false, up: false, hist: false }, filter: 'all',
|
||||
}
|
||||
export const RAIL_MIN = 300
|
||||
export const RAIL_MAX = 640
|
||||
|
||||
const KEY = (who: string) => `studio.layout:${who}`
|
||||
export const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
|
||||
|
||||
export function useLayout(who: string) {
|
||||
const [layout, setLayout] = useState<Layout>(DEFAULT)
|
||||
const loaded = useRef(false)
|
||||
const dragging = useRef(false)
|
||||
|
||||
// Restore once we know who the user is (org:user); merge over defaults so a stored
|
||||
// layout from an older shape never loses a newly-added field.
|
||||
useEffect(() => {
|
||||
if (!who || loaded.current) return
|
||||
loaded.current = true
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY(who))
|
||||
if (raw) {
|
||||
const s = JSON.parse(raw)
|
||||
setLayout({ ...DEFAULT, ...s, collapsed: { ...DEFAULT.collapsed, ...(s.collapsed || {}) } })
|
||||
}
|
||||
} catch { /* corrupt entry — fall back to defaults */ }
|
||||
}, [who])
|
||||
|
||||
// Persist on change — but not on every drag frame; the drag commits once on release.
|
||||
useEffect(() => {
|
||||
if (!who || !loaded.current || dragging.current) return
|
||||
try { localStorage.setItem(KEY(who), JSON.stringify(layout)) } catch { /* quota — ignore */ }
|
||||
}, [who, layout])
|
||||
|
||||
const patch = useCallback((p: Partial<Layout>) => setLayout(l => ({ ...l, ...p })), [])
|
||||
const toggle = useCallback((k: keyof Layout['collapsed']) =>
|
||||
setLayout(l => ({ ...l, collapsed: { ...l.collapsed, [k]: !l.collapsed[k] } })), [])
|
||||
const reset = useCallback(() => { setLayout(DEFAULT); dragging.current = false }, [])
|
||||
const beginDrag = useCallback(() => { dragging.current = true }, [])
|
||||
const endDrag = useCallback(() => { dragging.current = false; setLayout(l => ({ ...l })) }, [])
|
||||
|
||||
return { layout, patch, toggle, reset, beginDrag, endDrag }
|
||||
}
|
||||
|
||||
// Native divider drag. Tracks pointer delta from the grab point on the given axis and
|
||||
// hands it to `onMove` (the caller clamps + applies); `onEnd` commits/persists.
|
||||
export function startDrag(
|
||||
e: React.PointerEvent, axis: 'x' | 'y',
|
||||
onMove: (delta: number) => void, onEnd: () => void,
|
||||
) {
|
||||
e.preventDefault()
|
||||
const origin = axis === 'x' ? e.clientX : e.clientY
|
||||
const move = (ev: PointerEvent) => onMove((axis === 'x' ? ev.clientX : ev.clientY) - origin)
|
||||
const up = () => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
onEnd()
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', up)
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = axis === 'x' ? 'col-resize' : 'row-resize'
|
||||
}
|
||||
Vendored
+442
@@ -0,0 +1,442 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
getWorklog, getQueueStatus, getRatings, getFavorites, getStacks, saveStacks,
|
||||
cancelRun, amendRun, uploadRef, rate, setFavorite, saveTemplate, getLineage,
|
||||
softDelete, getDeletedPaths, thumbURL, fileURL,
|
||||
type Run, type QueueStatus, type QueueItem, type Rating, type Stack, type Lineage,
|
||||
} from './api'
|
||||
import { RunCard, runThumb, idLabel, relTime, fmtDur } from './card'
|
||||
import { type Filter, type Layout, startDrag, clamp } from './layout'
|
||||
|
||||
// The queue/history rail — the whole YouTube-watch-page surface adapted to studio's
|
||||
// generative flows. App owns the shell + composer + layout persistence; this owns all
|
||||
// queue/history/favorites/stacks data, polling and mutations, and calls back to insert
|
||||
// a reference into the composer.
|
||||
|
||||
// Version chain (pos/len) for each run's output, derived from the loaded rows — the
|
||||
// same parent→child edges the server's lineage walk uses, counted for the "vN" pips.
|
||||
function chains(rows: Run[]): Map<string, { pos: number; len: number }> {
|
||||
const producer = new Map<string, Run>()
|
||||
for (const r of rows) if (r.output && !producer.has(r.output)) producer.set(r.output, r)
|
||||
const childrenOf = new Map<string, Run[]>()
|
||||
for (const r of rows) for (const par of r.parents || []) {
|
||||
const arr = childrenOf.get(par)
|
||||
if (arr) arr.push(r); else childrenOf.set(par, [r])
|
||||
}
|
||||
const out = new Map<string, { pos: number; len: number }>()
|
||||
for (const r of rows) {
|
||||
if (!r.output) continue
|
||||
let anc = 0, cur: string | undefined = r.output
|
||||
const seen = new Set([r.output])
|
||||
for (;;) {
|
||||
const par: string | undefined = producer.get(cur!)?.parents?.[0]
|
||||
if (!par || seen.has(par)) break
|
||||
seen.add(par); anc++; cur = par
|
||||
}
|
||||
let desc = 0
|
||||
const q = [r.output], seenD = new Set([r.output])
|
||||
while (q.length) {
|
||||
for (const kid of childrenOf.get(q.shift()!) || []) {
|
||||
const key = kid.output || kid.id
|
||||
if (seenD.has(key)) continue
|
||||
seenD.add(key); desc++
|
||||
if (kid.output) q.push(kid.output)
|
||||
}
|
||||
}
|
||||
out.set(r.id, { pos: anc + 1, len: anc + 1 + desc })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export interface RailProps {
|
||||
layout: Layout
|
||||
patch: (p: Partial<Layout>) => void
|
||||
toggle: (k: 'next' | 'up' | 'hist') => void
|
||||
reset: () => void
|
||||
beginDrag: () => void
|
||||
endDrag: () => void
|
||||
onUseRef: (text: string) => void
|
||||
}
|
||||
|
||||
export function Rail(props: RailProps) {
|
||||
const { layout, patch, toggle, reset, beginDrag, endDrag } = props
|
||||
const filter = layout.filter
|
||||
|
||||
const [rows, setRows] = useState<Run[]>([])
|
||||
const [q, setQ] = useState<QueueStatus>({ items: {}, medians: {}, lanes: {}, now: Math.floor(Date.now() / 1000) })
|
||||
const [now, setNow] = useState(Math.floor(Date.now() / 1000))
|
||||
const [ratings, setRatings] = useState<Record<string, Rating>>({})
|
||||
const [favorites, setFavorites] = useState<Record<string, { ts: number }>>({})
|
||||
const [stacks, setStacks] = useState<Stack[]>([])
|
||||
const [deletedPaths, setDeletedPaths] = useState<Set<string>>(new Set())
|
||||
const [hidden, setHidden] = useState<Set<string>>(new Set())
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const [openStack, setOpenStack] = useState<string | null>(null)
|
||||
const [stackPick, setStackPick] = useState<Run | null>(null)
|
||||
const [versionsFor, setVersionsFor] = useState<Run | null>(null)
|
||||
const [lineage, setLineage] = useState<Lineage | null>(null)
|
||||
const [offer, setOffer] = useState<Run | null>(null)
|
||||
const [railMenu, setRailMenu] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
const pollLive = useCallback(async () => {
|
||||
const [w, qs] = await Promise.all([getWorklog(query), getQueueStatus()])
|
||||
setRows(w.items); setQ(qs); setNow(qs.now)
|
||||
}, [query])
|
||||
const loadRatings = useCallback(() => getRatings().then(setRatings), [])
|
||||
const loadFavorites = useCallback(() => getFavorites().then(setFavorites), [])
|
||||
const loadStacks = useCallback(() => getStacks().then(setStacks), [])
|
||||
const loadDeleted = useCallback(() => getDeletedPaths().then(ps => setDeletedPaths(new Set(ps))), [])
|
||||
|
||||
useEffect(() => { pollLive(); const t = setInterval(pollLive, 4000); return () => clearInterval(t) }, [pollLive])
|
||||
useEffect(() => { loadRatings(); loadFavorites(); loadStacks(); loadDeleted() }, [loadRatings, loadFavorites, loadStacks, loadDeleted])
|
||||
useEffect(() => { if (!err) return; const t = setTimeout(() => setErr(''), 4000); return () => clearTimeout(t) }, [err])
|
||||
|
||||
// Active = the runs the server still counts as in-flight (present in queue/status),
|
||||
// plus just-dispatched rows not yet in the next poll — so a stalled row doesn't
|
||||
// linger in Up next forever (matches the queue's own 30-min active window).
|
||||
const active = rows.filter(r => (r.status === 'queued' || r.status === 'running') && (q.items[r.id] || now - r.ts <= 1800))
|
||||
.sort((a, b) => ((q.items[a.id]?.position ?? 999) - (q.items[b.id]?.position ?? 999)) || (a.ts - b.ts))
|
||||
const history = rows.filter(r => (r.status === 'done' || r.status === 'failed' || r.status === 'cancelled')
|
||||
&& !hidden.has(r.id) && !(r.output && deletedPaths.has(r.output)))
|
||||
const favRuns = rows.filter(r => favorites[r.id])
|
||||
const nextRun = active[0]
|
||||
const chainMap = chains(rows)
|
||||
|
||||
// ── Mutations (each refreshes the affected store) ────────────────────────────────
|
||||
// A dispatch/mutation can fail because the run or asset changed since this list was
|
||||
// fetched (deduped, finished, cancelled). The trailing refresh already re-fetches;
|
||||
// swap the raw error for a plain "pick it again" — one self-heal for every mutation.
|
||||
const STALE = /unknown asset|not found|no such job|already finished/i
|
||||
const heal = (e: Error): boolean => {
|
||||
if (!STALE.test(e.message)) return false
|
||||
setErr('That run changed — refreshed, pick it again.')
|
||||
return true
|
||||
}
|
||||
// The ONE remove handler behind both the card's × and its menu Delete: a queued run
|
||||
// cancels; a finished result soft-deletes its output (the library lifecycle) and is
|
||||
// hidden from History optimistically (reconciled from the server's deleted set).
|
||||
async function onRemove(run: Run) {
|
||||
if (run.status === 'queued' || run.status === 'running') {
|
||||
try { await cancelRun(run.id) } catch (e) { if (!heal(e as Error)) setErr((e as Error).message) }
|
||||
pollLive(); return
|
||||
}
|
||||
setHidden(h => new Set(h).add(run.id))
|
||||
const out = run.output
|
||||
if (out) {
|
||||
try { await softDelete(out); setDeletedPaths(d => new Set(d).add(out)) }
|
||||
catch (e) { if (!heal(e as Error)) setErr((e as Error).message) }
|
||||
}
|
||||
pollLive()
|
||||
}
|
||||
async function onAmend(run: Run, files: File[]) {
|
||||
const names: string[] = []
|
||||
for (const f of files) { try { const u = await uploadRef(f); if (u?.name) names.push(u.name) } catch (e) { setErr((e as Error).message) } }
|
||||
if (!names.length) return
|
||||
const instr = (run.prompt || '').trim()
|
||||
try { await amendRun(run.id, instr.length >= 3 ? instr : `${run.kind || 'edit'} update`, names) }
|
||||
catch (e) { if (!heal(e as Error)) setErr((e as Error).message) }
|
||||
pollLive()
|
||||
}
|
||||
async function onRate(run: Run, p: { stars?: number; notes?: string; path?: string }) {
|
||||
try { await rate(run.id, p) } catch (e) { setErr((e as Error).message) }
|
||||
loadRatings()
|
||||
}
|
||||
async function onFavorite(run: Run) {
|
||||
const nowFav = !favorites[run.id]
|
||||
try { await setFavorite(run.id, nowFav) } catch (e) { setErr((e as Error).message) }
|
||||
await loadFavorites()
|
||||
if (nowFav) setOffer(run) // offer to turn the flow into a template
|
||||
}
|
||||
async function onOpenVersions(run: Run) {
|
||||
setVersionsFor(run); setLineage(null)
|
||||
setLineage(await getLineage(run.output || ''))
|
||||
}
|
||||
async function persistStacks(next: Stack[]) {
|
||||
const clean = next.filter(s => s.items.length >= 2) // a folder of one dissolves
|
||||
setStacks(clean)
|
||||
try { setStacks(await saveStacks(clean)) } catch (e) { setErr((e as Error).message) }
|
||||
}
|
||||
function stackRuns(from: string, target: string) {
|
||||
const next = stacks.map(s => ({ ...s, items: s.items.filter(i => i !== from) }))
|
||||
const host = next.find(s => s.items.includes(target))
|
||||
if (host) host.items = [...host.items, from]
|
||||
else next.push({ id: `st_${Date.now().toString(36)}`, name: 'Stack', items: [target, from], ts: Math.floor(Date.now() / 1000) })
|
||||
persistStacks(next)
|
||||
}
|
||||
function addToStack(run: Run, stackId: string) {
|
||||
const next = stacks.map(s => ({ ...s, items: s.items.filter(i => i !== run.id) }))
|
||||
const host = next.find(s => s.id === stackId)
|
||||
if (host) host.items = [...host.items, run.id]
|
||||
persistStacks(next); setStackPick(null)
|
||||
}
|
||||
function unstack(run: Run) {
|
||||
persistStacks(stacks.map(s => ({ ...s, items: s.items.filter(i => i !== run.id) })))
|
||||
}
|
||||
function renameStack(id: string, name: string) {
|
||||
setStacks(s => s.map(x => x.id === id ? { ...x, name } : x))
|
||||
}
|
||||
function commitRename() { persistStacks(stacks) }
|
||||
|
||||
// ── Drag-hold stacking (native HTML5 drag + a 600ms hover timer) ─────────────────
|
||||
const dragId = useRef<string | null>(null)
|
||||
const holdTimer = useRef<number | undefined>(undefined)
|
||||
const [intentId, setIntentId] = useState<string | null>(null)
|
||||
function dndFor(run: Run) {
|
||||
return {
|
||||
onDragStart: (e: React.DragEvent) => { dragId.current = run.id; e.dataTransfer.setData('text/plain', run.id); e.dataTransfer.effectAllowed = 'move' },
|
||||
onDragOver: (e: React.DragEvent) => { if (dragId.current && dragId.current !== run.id) { e.preventDefault(); e.dataTransfer.dropEffect = 'move' } },
|
||||
onDragEnter: (e: React.DragEvent) => {
|
||||
if (!dragId.current || dragId.current === run.id) return
|
||||
e.preventDefault()
|
||||
window.clearTimeout(holdTimer.current)
|
||||
holdTimer.current = window.setTimeout(() => setIntentId(run.id), 600) // hold to form a stack
|
||||
},
|
||||
onDragLeave: () => { window.clearTimeout(holdTimer.current); setIntentId(cur => cur === run.id ? null : cur) },
|
||||
onDrop: () => {
|
||||
window.clearTimeout(holdTimer.current)
|
||||
const from = dragId.current, held = intentId === run.id
|
||||
setIntentId(null); dragId.current = null
|
||||
if (from && from !== run.id && held) stackRuns(from, run.id)
|
||||
},
|
||||
intent: intentId === run.id,
|
||||
}
|
||||
}
|
||||
|
||||
const card = (run: Run) => (
|
||||
<RunCard
|
||||
key={run.id} run={run} qitem={q.items[run.id]} rating={ratings[run.id]}
|
||||
favorite={!!favorites[run.id]} now={now} version={chainMap.get(run.id)}
|
||||
onRemove={onRemove} onAmend={onAmend} onRate={onRate} onFavorite={onFavorite}
|
||||
onAddToStack={r => setStackPick(r)} onOpenVersions={onOpenVersions}
|
||||
onUseRef={r => props.onUseRef(idLabel(r))} dnd={dndFor(run)}
|
||||
/>
|
||||
)
|
||||
|
||||
// ── Section layout (content-driven flex + the vertical resize divider) ───────────
|
||||
const sectionsRef = useRef<HTMLDivElement>(null)
|
||||
const bothOpen = filter === 'all' && !layout.collapsed.up && !layout.collapsed.hist
|
||||
const upStyle: React.CSSProperties = filter === 'queued' ? { flex: '1 1 auto' }
|
||||
: bothOpen ? { flex: `0 0 ${Math.round(layout.upFrac * 100)}%` }
|
||||
: layout.collapsed.up ? { flex: '0 0 auto' } : { flex: '1 1 auto' }
|
||||
const histStyle: React.CSSProperties = layout.collapsed.hist ? { flex: '0 0 auto' } : { flex: '1 1 0' }
|
||||
function onHSplit(e: React.PointerEvent) {
|
||||
beginDrag()
|
||||
const h = sectionsRef.current?.getBoundingClientRect().height || 1
|
||||
const start = layout.upFrac
|
||||
startDrag(e, 'y', d => patch({ upFrac: clamp(start + d / h, 0.15, 0.85) }), endDrag)
|
||||
}
|
||||
|
||||
const chips: { k: Filter; label: string; n?: number }[] = [
|
||||
{ k: 'all', label: 'All' },
|
||||
{ k: 'queued', label: 'Queued', n: active.length },
|
||||
{ k: 'done', label: 'Done' },
|
||||
{ k: 'favorites', label: 'Favorites', n: favRuns.length },
|
||||
{ k: 'stacks', label: 'Stacks', n: stacks.length },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
{(filter === 'all' || filter === 'queued') && nextRun && (
|
||||
<div className={`nextpin${layout.collapsed.next ? ' col' : ''}`}>
|
||||
<button className="pin-h" onClick={() => toggle('next')} aria-expanded={!layout.collapsed.next}>
|
||||
<span className="pin-lab">Next</span>
|
||||
<span className="pin-count">{active.length} in queue</span>
|
||||
<span className="chev">{layout.collapsed.next ? '▸' : '▾'}</span>
|
||||
</button>
|
||||
{!layout.collapsed.next && <NextBody run={nextRun} q={q.items[nextRun.id]} now={now} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="chips">
|
||||
<div className="chips-row">
|
||||
{chips.map(c => (
|
||||
<button key={c.k} className={`chip-f${filter === c.k ? ' on' : ''}`} onClick={() => patch({ filter: c.k })}>
|
||||
{c.label}{c.n ? <span className="chip-n">{c.n}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="rail-more-wrap">
|
||||
<button className="rail-more" aria-label="rail options" onClick={() => setRailMenu(m => !m)}>⋯</button>
|
||||
{railMenu && (
|
||||
<div className="rc-menu" role="menu" onMouseLeave={() => setRailMenu(false)}>
|
||||
<button role="menuitem" onClick={() => { setRailMenu(false); reset() }}>Reset layout</button>
|
||||
<button role="menuitem" onClick={() => { setRailMenu(false); pollLive(); loadRatings(); loadFavorites(); loadStacks() }}>Refresh</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rail-main">
|
||||
{filter === 'stacks' ? (
|
||||
<div className="stacks-view">
|
||||
{stacks.length === 0 && <div className="empty">No stacks yet. Drag one card onto another and hold (~½s) to make a stack.</div>}
|
||||
{stacks.map(s => {
|
||||
const members = s.items.map(id => rows.find(r => r.id === id)).filter((r): r is Run => !!r)
|
||||
const open = openStack === s.id
|
||||
return (
|
||||
<div key={s.id} className={`stack${open ? ' open' : ''}`}>
|
||||
<button className="stack-tile" onClick={() => setOpenStack(open ? null : s.id)}>
|
||||
<span className="stack-cards">
|
||||
{members.slice(0, 3).map((m, i) => {
|
||||
const src = runThumb(m)
|
||||
return <span key={i} className="sc" style={src ? { backgroundImage: `url(${src})` } : undefined} />
|
||||
})}
|
||||
</span>
|
||||
<span className="stack-meta">
|
||||
<span className="stack-name">{s.name}</span>
|
||||
<span className="stack-count">{members.length}</span>
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="stack-open">
|
||||
<input className="stack-rename" value={s.name} aria-label="stack name"
|
||||
onChange={e => renameStack(s.id, e.target.value)} onBlur={commitRename} />
|
||||
{members.map(m => (
|
||||
<div key={m.id} className="stack-member">
|
||||
{card(m)}
|
||||
<button className="unstack" title="remove from stack" onClick={() => unstack(m)}>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : filter === 'favorites' ? (
|
||||
<section className="sec">
|
||||
<div className="sec-body">
|
||||
{favRuns.length ? favRuns.map(card) : <div className="empty">No favorites yet. Tap ♥ on any card.</div>}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<div className="sections" ref={sectionsRef}>
|
||||
{(filter === 'all' || filter === 'queued') && (
|
||||
<section className={`sec${layout.collapsed.up ? ' col' : ''}`} style={upStyle}>
|
||||
<SecHeader label="Up next" n={active.length} collapsed={layout.collapsed.up} onToggle={() => toggle('up')} />
|
||||
{!layout.collapsed.up && (
|
||||
<div className="sec-body">{active.length ? active.map(card) : <div className="empty">Nothing queued. Describe a change to start a run.</div>}</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{bothOpen && <div className="hsplit" onPointerDown={onHSplit} onDoubleClick={() => patch({ upFrac: 0.5 })} title="drag to resize · double-click to reset" />}
|
||||
{(filter === 'all' || filter === 'done') && (
|
||||
<section className={`sec${layout.collapsed.hist ? ' col' : ''}`} style={histStyle}>
|
||||
<SecHeader label="History" n={history.length} collapsed={layout.collapsed.hist} onToggle={() => toggle('hist')}
|
||||
search={<input className="sec-search" placeholder="Search history…" value={query} onChange={e => setQuery(e.target.value)} />} />
|
||||
{!layout.collapsed.hist && (
|
||||
<div className="sec-body">{history.length ? history.map(card) : <div className="empty">No past generations{query ? ' match your search' : ' yet'}.</div>}</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stackPick && (
|
||||
<div className="ovl" onClick={() => setStackPick(null)}>
|
||||
<div className="sheet" onClick={e => e.stopPropagation()}>
|
||||
<div className="sheet-h">Add “{idLabel(stackPick)}” to a stack</div>
|
||||
{stacks.length === 0
|
||||
? <p className="muted">No stacks yet — drag one card onto another and hold to make the first one.</p>
|
||||
: stacks.map(s => <button key={s.id} className="sheet-item" onClick={() => addToStack(stackPick, s.id)}>{s.name} · {s.items.length}</button>)}
|
||||
<div className="sheet-row"><button onClick={() => setStackPick(null)}>Cancel</button></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{versionsFor && (
|
||||
<div className="ovl" onClick={() => setVersionsFor(null)}>
|
||||
<div className="sheet vers" onClick={e => e.stopPropagation()}>
|
||||
<div className="sheet-h">Versions of {idLabel(versionsFor)}</div>
|
||||
{!lineage ? <div className="empty">Loading…</div> : (
|
||||
<div className="vers-strip">
|
||||
{[...lineage.ancestors,
|
||||
{ path: lineage.node, prompt: versionsFor.prompt, kind: versionsFor.kind, ts: versionsFor.ts },
|
||||
...lineage.descendants].map((step, i) => (
|
||||
<a key={i} className={`vstep${step.path === lineage.node ? ' cur' : ''}`}
|
||||
href={step.path ? fileURL(step.path) : undefined} target="_blank" rel="noreferrer"
|
||||
onClick={e => { if (!step.path) e.preventDefault() }} title={step.prompt || step.kind}>
|
||||
{step.path ? <img src={thumbURL(step.path)} alt="" /> : <span className="rc-ph">?</span>}
|
||||
<span className="vlabel">v{i + 1}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="sheet-row"><button onClick={() => setVersionsFor(null)}>Close</button></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{offer && <TemplateOffer run={offer} onClose={() => setOffer(null)} onError={setErr} />}
|
||||
{err && <div className="rail-toast" role="alert">{err}</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NextBody({ run, q, now }: { run: Run; q?: QueueItem; now: number }) {
|
||||
const src = runThumb(run)
|
||||
const badge = run.status === 'running'
|
||||
? (q?.elapsed_seconds != null ? `running · ${fmtDur(q.elapsed_seconds)}` : 'running')
|
||||
: (q?.eta_seconds != null ? `starts in ~${fmtDur(q.eta_seconds)}` : 'queued')
|
||||
return (
|
||||
<div className="pin-body">
|
||||
<div className="pin-thumb">{src ? <img src={src} alt="" /> : <span className="rc-ph">{run.kind}</span>}</div>
|
||||
<div className="pin-txt">
|
||||
<div className="pin-prompt" title={run.prompt}>{run.prompt || `(${run.kind})`}</div>
|
||||
<div className="pin-meta">{badge} · {run.node} · {relTime(run.ts, now)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SecHeader({ label, n, collapsed, onToggle, search }:
|
||||
{ label: string; n: number; collapsed: boolean; onToggle: () => void; search?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="sec-h">
|
||||
<button className="sec-toggle" onClick={onToggle} aria-expanded={!collapsed}>
|
||||
<span className="chev">{collapsed ? '▸' : '▾'}</span>
|
||||
<span className="sec-lab">{label}</span>
|
||||
<span className="sec-n">{n}</span>
|
||||
</button>
|
||||
{search}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TemplateOffer({ run, onClose, onError }: { run: Run; onClose: () => void; onError: (m: string) => void }) {
|
||||
const [stage, setStage] = useState<'offer' | 'name'>('offer')
|
||||
const [name, setName] = useState(idLabel(run))
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
async function save() {
|
||||
setBusy(true)
|
||||
try {
|
||||
await saveTemplate(name.trim() || idLabel(run), { kind: run.kind, prompt: run.prompt, refs: run.refs, parents: run.parents })
|
||||
setDone(true); setTimeout(onClose, 900)
|
||||
} catch (e) { onError((e as Error).message); onClose() } finally { setBusy(false) }
|
||||
}
|
||||
return (
|
||||
<div className="ovl" onClick={onClose}>
|
||||
<div className="sheet" onClick={e => e.stopPropagation()}>
|
||||
{done ? <div className="sheet-h">Saved to templates ✓</div>
|
||||
: stage === 'offer' ? (
|
||||
<>
|
||||
<div className="sheet-h">Turn this into a template?</div>
|
||||
<p className="muted">Reuse this flow — {run.kind}, its instruction and {run.refs.length} reference{run.refs.length === 1 ? '' : 's'} — as a starting point next time.</p>
|
||||
<div className="sheet-row"><button className="pri" onClick={() => setStage('name')}>Name it…</button><button onClick={onClose}>Not now</button></div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="sheet-h">Name this template</div>
|
||||
<input className="sheet-in" value={name} autoFocus onChange={e => setName(e.target.value)} placeholder="Template name" />
|
||||
<p className="muted">Captures: {run.kind} · “{run.prompt || '(no instruction)'}” · {run.refs.length} ref{run.refs.length === 1 ? '' : 's'}</p>
|
||||
<div className="sheet-row"><button className="pri" disabled={busy || !name.trim()} onClick={save}>{busy ? 'Saving…' : 'Save template'}</button><button onClick={onClose}>Cancel</button></div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Vendored
+217
-35
@@ -1,54 +1,236 @@
|
||||
:root {
|
||||
--bg: #0d0d0f; --panel: #151517; --line: #26262b; --text: #f4f4f6;
|
||||
--dim: #9a9aa2; --accent: #f4f4f6; --accent-ink: #0d0d0f;
|
||||
--bg: #0d0d0f; --panel: #151517; --panel2: #1c1c20; --line: #26262b; --text: #f4f4f6;
|
||||
--dim: #a6a6ae; --accent: #f4f4f6; --accent-ink: #0d0d0f; --brand: #4f8cff;
|
||||
--good: #35c26b; --bad: #ff6b6b; --halo: #4f8cff;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root { --bg: #fafafa; --panel: #fff; --line: #e6e6ea; --text: #16161a; --dim: #6b6b73; --accent: #16161a; --accent-ink: #fff; }
|
||||
:root {
|
||||
--bg: #fafafa; --panel: #fff; --panel2: #f3f3f5; --line: #e6e6ea; --text: #16161a;
|
||||
--dim: #55565e; --accent: #16161a; --accent-ink: #fff; --brand: #2f6be0;
|
||||
--good: #1f9d57; --bad: #d33; --halo: #2f6be0;
|
||||
}
|
||||
}
|
||||
* { 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; }
|
||||
body {
|
||||
background: var(--bg); color: var(--text);
|
||||
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
button { font: inherit; color: inherit; }
|
||||
|
||||
.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); }
|
||||
.top { display: flex; align-items: center; gap: 12px; padding: 10px 16px; 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); }
|
||||
.gpu { font-size: 12px; padding: 3px 9px; border-radius: 999px; border: 1px solid var(--line); color: var(--dim); }
|
||||
.gpu.on { color: var(--text); } .gpu.on::before { content: "● "; color: var(--good); }
|
||||
|
||||
.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); }
|
||||
/* Account avatar + menu — a 44px target, not bare text */
|
||||
.acct-wrap { position: relative; }
|
||||
.avatar {
|
||||
width: 44px; height: 44px; border-radius: 999px; border: 1px solid var(--line);
|
||||
background: var(--panel2); color: var(--text); font-weight: 700; font-size: 13px;
|
||||
cursor: pointer; letter-spacing: .5px;
|
||||
}
|
||||
.avatar:hover { border-color: var(--dim); }
|
||||
.acct-menu { right: 0; top: 50px; min-width: 210px; }
|
||||
.acct-id { padding: 8px 12px; border-bottom: 1px solid var(--line); }
|
||||
.acct-name { font-weight: 600; }
|
||||
.acct-sub { font-size: 12px; color: var(--dim); }
|
||||
|
||||
.body { flex: 1; display: flex; min-height: 0; }
|
||||
.chat { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
|
||||
.scroll { flex: 1; overflow-y: auto; padding: 20px; }
|
||||
.hero { max-width: 580px; margin: 4vh auto 0; text-align: center; }
|
||||
.hero h1 { font-size: 27px; font-weight: 600; margin: 0 0 8px; }
|
||||
.hero p { color: var(--dim); margin: 0 0 16px; }
|
||||
.examples { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
|
||||
.ex-chip {
|
||||
border: 1px solid var(--line); background: var(--panel); color: var(--text);
|
||||
border-radius: 999px; padding: 7px 13px; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.ex-chip:hover { border-color: var(--brand); color: var(--brand); }
|
||||
.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; }
|
||||
.composer { display: flex; gap: 10px; padding: 12px 14px; border-top: 1px solid var(--line); background: var(--bg); }
|
||||
.composer textarea { flex: 1; resize: none; height: 46px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); color: var(--text); font: inherit; }
|
||||
.composer textarea:focus { outline: none; border-color: var(--brand); }
|
||||
.send {
|
||||
padding: 0 20px; border-radius: 12px; cursor: pointer; font-weight: 600;
|
||||
border: 1px solid var(--line); background: transparent; color: var(--dim); /* empty: muted outline */
|
||||
}
|
||||
.send.ready { border-color: transparent; background: var(--brand); color: #fff; } /* text present: brand fill */
|
||||
.send:disabled { cursor: default; opacity: .6; }
|
||||
|
||||
/* ── The main | rail divider ─────────────────────────────────────────────── */
|
||||
.vsplit { flex: 0 0 6px; cursor: col-resize; background: var(--line); opacity: .5; }
|
||||
.vsplit:hover { opacity: 1; background: var(--brand); }
|
||||
|
||||
/* ── The rail ────────────────────────────────────────────────────────────── */
|
||||
.rail { border-left: 1px solid var(--line); display: flex; flex-direction: column; min-height: 0; overflow: hidden; background: var(--bg); }
|
||||
.chev { color: var(--dim); font-size: 11px; }
|
||||
|
||||
.nextpin { border-bottom: 1px solid var(--line); }
|
||||
.pin-h { width: 100%; display: flex; align-items: center; gap: 8px; padding: 9px 14px; background: var(--panel2); border: 0; cursor: pointer; text-align: left; }
|
||||
.pin-lab { font-weight: 700; font-size: 12px; text-transform: uppercase; letter-spacing: .6px; }
|
||||
.pin-count { flex: 1; color: var(--dim); font-size: 12px; }
|
||||
.pin-body { display: flex; gap: 10px; padding: 10px 14px; }
|
||||
.pin-thumb { flex: 0 0 64px; height: 64px; border-radius: 8px; overflow: hidden; border: 1px solid var(--line); background: var(--panel); display: grid; place-items: center; }
|
||||
.pin-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.pin-txt { min-width: 0; }
|
||||
.pin-prompt { font-weight: 500; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.pin-meta { font-size: 12px; color: var(--dim); margin-top: 3px; }
|
||||
|
||||
.chips { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--line); }
|
||||
.chips-row { flex: 1; display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; }
|
||||
.chips-row::-webkit-scrollbar { display: none; }
|
||||
.chip-f { flex: 0 0 auto; border: 1px solid var(--line); background: var(--panel); color: var(--dim); border-radius: 999px; padding: 5px 11px; font-size: 13px; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; }
|
||||
.chip-f.on { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
|
||||
.chip-n { font-size: 11px; background: color-mix(in srgb, var(--dim) 22%, transparent); border-radius: 999px; padding: 0 6px; }
|
||||
.chip-f.on .chip-n { background: color-mix(in srgb, var(--accent-ink) 25%, transparent); }
|
||||
.rail-more-wrap { position: relative; }
|
||||
.rail-more, .rc-more { border: 0; background: transparent; cursor: pointer; color: var(--dim); font-size: 18px; line-height: 1; padding: 4px 6px; border-radius: 6px; }
|
||||
.rail-more:hover { background: var(--panel2); color: var(--text); }
|
||||
|
||||
.rail-main { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.sections { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.sec { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
||||
.sec.col { flex: 0 0 auto !important; }
|
||||
.sec-h { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--line); }
|
||||
.sec-toggle { flex: 0 0 auto; display: flex; align-items: center; gap: 7px; background: transparent; border: 0; cursor: pointer; padding: 2px 4px; }
|
||||
.sec-lab { font-size: 12px; text-transform: uppercase; letter-spacing: .6px; font-weight: 700; }
|
||||
.sec-n { font-size: 11px; color: var(--dim); background: var(--panel2); border-radius: 999px; padding: 0 7px; }
|
||||
.sec-search { flex: 1; min-width: 0; border: 1px solid var(--line); background: var(--panel); color: var(--text); border-radius: 8px; padding: 5px 9px; font-size: 13px; }
|
||||
.sec-search:focus { outline: none; border-color: var(--brand); }
|
||||
.sec-body { flex: 1; min-height: 0; overflow-y: auto; padding: 8px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.hsplit { flex: 0 0 6px; cursor: row-resize; background: var(--line); opacity: .5; }
|
||||
.hsplit:hover { opacity: 1; background: var(--brand); }
|
||||
.empty { color: var(--dim); font-size: 13px; padding: 14px; text-align: center; }
|
||||
|
||||
/* ── Run card ────────────────────────────────────────────────────────────── */
|
||||
.rc { position: relative; display: flex; flex-wrap: wrap; gap: 10px; padding: 8px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); }
|
||||
.rc:hover { border-color: var(--dim); }
|
||||
.rc.stack-intent { border-color: var(--halo); box-shadow: 0 0 0 3px color-mix(in srgb, var(--halo) 35%, transparent); }
|
||||
.rc.file-over { border-color: var(--brand); border-style: dashed; }
|
||||
.rc-thumb { position: relative; flex: 0 0 48%; max-width: 48%; border-radius: 8px; overflow: hidden; background: var(--panel2); border: 1px solid var(--line); display: block; aspect-ratio: 4 / 5; text-decoration: none; }
|
||||
.rc-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.rc-ph { display: grid; place-items: center; width: 100%; height: 100%; color: var(--dim); font-size: 12px; }
|
||||
.rc-badge { position: absolute; left: 5px; bottom: 5px; font-size: 11px; font-weight: 600; padding: 1px 6px; border-radius: 6px; background: color-mix(in srgb, #000 62%, transparent); color: #fff; }
|
||||
.rc-badge.s-running { background: var(--good); }
|
||||
.rc-badge.s-failed { background: var(--bad); }
|
||||
.rc-new { position: absolute; right: 5px; top: 5px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .4px; padding: 1px 6px; border-radius: 6px; background: var(--brand); color: #fff; }
|
||||
.rc-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||
.rc-title { font-weight: 500; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.35; }
|
||||
.rc-meta { font-size: 12px; color: var(--dim); }
|
||||
.rc-tags { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: auto; }
|
||||
.rc-id { font-size: 11px; color: var(--dim); background: var(--panel2); border-radius: 6px; padding: 1px 6px; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rc-vers { display: inline-flex; align-items: center; gap: 3px; border: 1px solid var(--line); background: transparent; border-radius: 999px; padding: 1px 7px 1px 5px; cursor: pointer; }
|
||||
.rc-vers .pip { width: 5px; height: 5px; border-radius: 999px; background: var(--dim); opacity: .5; }
|
||||
.rc-vers .pip.on { background: var(--brand); opacity: 1; }
|
||||
.rc-vn { font-size: 11px; color: var(--dim); }
|
||||
.rc-fav { color: var(--bad); }
|
||||
.rc-more { position: absolute; right: 4px; top: 4px; }
|
||||
.rc-more:hover { background: var(--panel2); color: var(--text); }
|
||||
/* Always-visible fast-path delete/cancel (× top-left of the thumb) */
|
||||
.rc-x { position: absolute; left: 6px; top: 6px; z-index: 4; width: 22px; height: 22px; border-radius: 999px; border: 0; background: color-mix(in srgb, #000 55%, transparent); color: #fff; font-size: 16px; line-height: 1; cursor: pointer; display: grid; place-items: center; opacity: .9; }
|
||||
.rc-x:hover { background: var(--bad); opacity: 1; }
|
||||
.rc-confirm { position: absolute; left: 6px; top: 6px; z-index: 7; display: flex; align-items: center; gap: 6px; background: var(--panel); border: 1px solid var(--line); border-radius: 9px; padding: 5px 7px; box-shadow: 0 6px 20px rgba(0,0,0,.28); font-size: 12px; }
|
||||
.rc-confirm button { border: 1px solid var(--line); background: var(--panel2); color: var(--text); border-radius: 6px; padding: 3px 9px; font-size: 12px; cursor: pointer; }
|
||||
.rc-confirm .danger { background: var(--bad); color: #fff; border-color: transparent; }
|
||||
|
||||
.rc-menu { position: absolute; z-index: 20; right: 6px; top: 30px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,.22); padding: 5px; display: flex; flex-direction: column; min-width: 168px; }
|
||||
.rc-menu > button, .rc-menu > a { text-align: left; background: transparent; border: 0; border-radius: 7px; padding: 8px 10px; cursor: pointer; color: var(--text); text-decoration: none; font-size: 14px; }
|
||||
.rc-menu > button:hover, .rc-menu > a:hover { background: var(--panel2); }
|
||||
|
||||
.rc-panel { flex-basis: 100%; order: 9; border-top: 1px solid var(--line); margin-top: 6px; padding-top: 8px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.rc-notes { width: 100%; min-height: 56px; resize: vertical; border: 1px solid var(--line); background: var(--panel2); color: var(--text); border-radius: 8px; padding: 8px 10px; font: inherit; font-size: 13px; }
|
||||
.rc-notes:focus { outline: none; border-color: var(--brand); }
|
||||
.rc-panel-row { display: flex; gap: 8px; }
|
||||
.rc-panel-row .pri, .sheet .pri, .send.ready { }
|
||||
.rc-panel-row button, .sheet-row button, .sheet-item { border: 1px solid var(--line); background: var(--panel); color: var(--text); border-radius: 8px; padding: 7px 13px; cursor: pointer; font-size: 13px; }
|
||||
.rc-panel-row .pri, .sheet-row .pri { background: var(--brand); color: #fff; border-color: transparent; }
|
||||
.rc-panel-row button:disabled, .sheet-row button:disabled { opacity: .5; cursor: default; }
|
||||
.rc-drop { align-items: stretch; text-align: center; }
|
||||
.rc-drop.over { outline: 2px dashed var(--brand); outline-offset: 2px; border-radius: 8px; }
|
||||
.rc-drop-hint { font-size: 12px; color: var(--dim); }
|
||||
.rc-pick { display: inline-block; border: 1px solid var(--line); border-radius: 8px; padding: 6px 12px; cursor: pointer; font-size: 13px; align-self: center; }
|
||||
.rc-pick input { display: none; }
|
||||
.rc-files { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.chip { font-size: 11px; background: var(--panel2); border: 1px solid var(--line); border-radius: 6px; padding: 2px 7px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* stars */
|
||||
.stars { display: inline-flex; gap: 1px; }
|
||||
.stars .star { border: 0; background: transparent; padding: 0 1px; cursor: default; color: var(--dim); font-size: 15px; line-height: 1; }
|
||||
.stars.interactive .star { cursor: pointer; font-size: 20px; }
|
||||
.stars .star.on { color: #f5b301; }
|
||||
.rc-rated .stars .star { font-size: 12px; }
|
||||
|
||||
/* ── Stacks ──────────────────────────────────────────────────────────────── */
|
||||
.stacks-view { flex: 1; min-height: 0; overflow-y: auto; padding: 10px; display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; align-content: start; }
|
||||
.stack { grid-column: 1 / -1; }
|
||||
.stack:not(.open) { grid-column: auto; }
|
||||
.stack-tile { width: 100%; border: 1px solid var(--line); background: var(--panel); border-radius: 12px; padding: 10px; cursor: pointer; display: flex; flex-direction: column; gap: 8px; }
|
||||
.stack-tile:hover { border-color: var(--dim); }
|
||||
.stack-cards { position: relative; height: 92px; }
|
||||
.stack-cards .sc { position: absolute; width: 66%; height: 84px; border-radius: 8px; border: 1px solid var(--line); background: var(--panel2) center/cover no-repeat; box-shadow: 0 2px 8px rgba(0,0,0,.15); }
|
||||
.stack-cards .sc:nth-child(1) { left: 0; top: 8px; transform: rotate(-5deg); }
|
||||
.stack-cards .sc:nth-child(2) { left: 17%; top: 4px; transform: rotate(1deg); z-index: 1; }
|
||||
.stack-cards .sc:nth-child(3) { left: 34%; top: 0; transform: rotate(6deg); z-index: 2; }
|
||||
.stack-meta { display: flex; align-items: center; justify-content: space-between; }
|
||||
.stack-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.stack-count { font-size: 12px; color: var(--dim); background: var(--panel2); border-radius: 999px; padding: 1px 8px; }
|
||||
.stack.open .stack-open { animation: folder-open .22s ease; overflow: hidden; margin-top: 10px; display: flex; flex-direction: column; gap: 8px; }
|
||||
@keyframes folder-open { from { opacity: 0; transform: translateY(-6px) scale(.98); } to { opacity: 1; transform: none; } }
|
||||
.stack-rename { border: 1px solid var(--line); background: var(--panel2); color: var(--text); border-radius: 8px; padding: 6px 9px; font: inherit; font-weight: 600; }
|
||||
.stack-rename:focus { outline: none; border-color: var(--brand); }
|
||||
.stack-member { position: relative; }
|
||||
.unstack { position: absolute; right: -6px; top: -6px; z-index: 5; width: 22px; height: 22px; border-radius: 999px; border: 1px solid var(--line); background: var(--panel); color: var(--text); cursor: pointer; line-height: 1; }
|
||||
.unstack:hover { background: var(--bad); color: #fff; border-color: transparent; }
|
||||
|
||||
/* ── Overlays / sheets ───────────────────────────────────────────────────── */
|
||||
.ovl { position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,.4); display: grid; place-items: center; padding: 16px; }
|
||||
.sheet { width: min(460px, 100%); max-height: 80vh; overflow-y: auto; background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 16px; display: flex; flex-direction: column; gap: 10px; box-shadow: 0 20px 60px rgba(0,0,0,.35); }
|
||||
.sheet-h { font-weight: 600; font-size: 16px; }
|
||||
.muted { color: var(--dim); font-size: 13px; margin: 0; }
|
||||
.sheet-in { border: 1px solid var(--line); background: var(--panel2); color: var(--text); border-radius: 10px; padding: 10px 12px; font: inherit; }
|
||||
.sheet-in:focus { outline: none; border-color: var(--brand); }
|
||||
.sheet-item { text-align: left; }
|
||||
.sheet-item:hover { border-color: var(--dim); }
|
||||
.sheet-row { display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px; }
|
||||
.vers-strip { display: flex; gap: 10px; overflow-x: auto; padding: 4px 0 8px; }
|
||||
.vstep { flex: 0 0 auto; width: 92px; text-align: center; text-decoration: none; color: var(--dim); }
|
||||
.vstep img, .vstep .rc-ph { width: 92px; height: 92px; object-fit: cover; border-radius: 10px; border: 1px solid var(--line); }
|
||||
.vstep.cur img, .vstep.cur .rc-ph { border-color: var(--brand); box-shadow: 0 0 0 2px color-mix(in srgb, var(--brand) 40%, transparent); }
|
||||
.vlabel { display: block; font-size: 12px; margin-top: 4px; }
|
||||
.vstep.cur .vlabel { color: var(--text); font-weight: 600; }
|
||||
|
||||
.rail-toast { position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); z-index: 80; background: var(--bad); color: #fff; padding: 9px 16px; border-radius: 10px; font-size: 13px; box-shadow: 0 8px 24px rgba(0,0,0,.3); max-width: 90vw; }
|
||||
|
||||
/* ── Mobile / tablet: ONE vertical scroll — chat transcript, then the rail as the
|
||||
bottom section — with the composer FIXED to the viewport bottom + safe-area inset
|
||||
(the #1 thumb-reach fix). Dividers are a desktop affordance (hidden); collapse
|
||||
chevrons + filter chips + persistence all still apply. */
|
||||
@media (max-width: 820px) {
|
||||
/* Chat fills; the library drops UNDER it as a compact strip so renders stay
|
||||
reachable on a phone. Empty (no jobs, no assets) it hides entirely — the
|
||||
`active` class is set only when there is something to show — so the empty
|
||||
chat state stays clean. */
|
||||
.body { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; }
|
||||
.chat { border-right: 0; }
|
||||
.rail { display: none; }
|
||||
.rail.active { display: block; border-top: 1px solid var(--line); max-height: 42vh; overflow-y: auto; }
|
||||
.rail.active .grid { grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); }
|
||||
.body { flex-direction: column; overflow-y: auto; -webkit-overflow-scrolling: touch;
|
||||
padding-bottom: calc(84px + env(safe-area-inset-bottom)); }
|
||||
.chat { flex: 0 0 auto; min-height: 52vh; }
|
||||
.scroll { flex: 0 0 auto; overflow: visible; }
|
||||
.composer { position: fixed; left: 0; right: 0; bottom: 0; z-index: 40;
|
||||
border-top: 1px solid var(--line); box-shadow: 0 -6px 18px rgba(0,0,0,.12);
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom)); }
|
||||
.vsplit, .hsplit { display: none; }
|
||||
.rail { width: auto !important; flex: 0 0 auto; border-left: 0; border-top: 1px solid var(--line); overflow: visible; }
|
||||
.rail-main, .sections, .sec, .sec-body, .stacks-view { overflow: visible; min-height: 0; }
|
||||
.sec, .sec.col { flex: 0 0 auto !important; }
|
||||
.chips { position: sticky; top: 0; z-index: 5; background: var(--bg); }
|
||||
.rc-thumb { flex-basis: 44%; max-width: 44%; }
|
||||
.stacks-view { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 420px) {
|
||||
.stacks-view { grid-template-columns: 1fr 1fr; }
|
||||
.sheet { border-radius: 14px; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user