Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38557c11fd | ||
|
|
bb3711eca1 | ||
|
|
86e301345f | ||
|
|
3f48ca15e0 | ||
|
|
fdc2da4c57 | ||
|
|
9993da806e |
-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 \
|
||||
@@ -34,9 +25,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.
|
||||
|
||||
+25
-35
@@ -51,11 +51,6 @@ log = logging.getLogger("studio.home")
|
||||
|
||||
_HTML = Path(__file__).with_name("studio_home.html")
|
||||
_ASSET_DIR = Path(__file__).parent
|
||||
# The studio-chat SPA (web/, a Vite bundle on @hanzo/ai talking to /v1/agent) built
|
||||
# into the image at /app/web/dist and served AT /studio. When the build is absent
|
||||
# (dev checkout, an image that skipped the web stage) /studio falls back to the
|
||||
# legacy studio_home.html so the route is never a dark page.
|
||||
_WEB_DIST = Path(__file__).resolve().parents[1] / "web" / "dist"
|
||||
_STATUSES = ("draft", "approved", "queued", "published", "deleted")
|
||||
_PROV_CACHE: dict[str, tuple[float, dict]] = {} # abspath -> (mtime, provenance)
|
||||
|
||||
@@ -148,11 +143,11 @@ def _save_library(root: Path, lib: dict) -> None:
|
||||
|
||||
|
||||
def _safe_asset(root: Path, rel: str) -> Path | None:
|
||||
p = (root / rel).resolve()
|
||||
if p.name.startswith("."):
|
||||
# Dotfiles are never assets (AppleDouble forks carry image extensions
|
||||
# but serve resource-fork bytes) — refuse them everywhere at once.
|
||||
if any(part.startswith(".") for part in Path(rel).parts):
|
||||
# Hidden segments are never assets — AppleDouble forks AND cache
|
||||
# trees (.thumbs/) in one rule, refused everywhere at once.
|
||||
return None
|
||||
p = (root / rel).resolve()
|
||||
return p if p.is_file() and str(p).startswith(str(root.resolve())) else None
|
||||
|
||||
|
||||
@@ -688,7 +683,7 @@ async def _wallet(request: web.Request, org: str) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
_MAX_UPLOAD = 30 * 1024 * 1024 # 30 MB — a sane ceiling for one render
|
||||
_MAX_UPLOAD = 100 * 1024 * 1024 # 100 MB — 4k masters run 30-60MB; the cap must hold the largest real render
|
||||
_UPLOAD_EXT = (".png", ".jpg", ".jpeg", ".webp")
|
||||
_SUBPATH_RE = re.compile(r"[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*")
|
||||
|
||||
@@ -728,41 +723,28 @@ def _index_upload(org: str, root: Path, rel: str, *, design, kind, role, node) -
|
||||
else:
|
||||
ex.update(design=design, kind=kind, role=role, updatedAt=now_iso)
|
||||
_save_library(root, lib)
|
||||
worklog.record(org, kind="render", prompt="", refs=[], uploads=[], parents=[],
|
||||
output_prefix=rel, node=node, status="done",
|
||||
lane=("gpu" if node not in ("upload", "studio pod") else "local"),
|
||||
pid="up-" + uuid.uuid4().hex[:12])
|
||||
# The worklog upserts by output too: re-ingesting identical bytes (an
|
||||
# interrupted mirror, a restored store) must repair a missing row, never
|
||||
# stack a second one.
|
||||
if not worklog.has_output(org, rel):
|
||||
worklog.record(org, kind="render", prompt="", refs=[], uploads=[], parents=[],
|
||||
output_prefix=rel, node=node, status="done",
|
||||
lane=("gpu" if node not in ("upload", "studio pod") else "local"),
|
||||
pid="up-" + uuid.uuid4().hex[:12])
|
||||
|
||||
|
||||
def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
|
||||
|
||||
@routes.get("/studio")
|
||||
async def studio_home(request: web.Request):
|
||||
# no-store: the SPA HTML changes every release; without this, browsers
|
||||
# no-store: the home HTML changes every release; without this, browsers
|
||||
# (and any proxy) heuristically cache it and serve a STALE page after a
|
||||
# deploy — which is why UI fixes appeared "not working" to a user still
|
||||
# running yesterday's cached page. The page is tiny; always serve fresh.
|
||||
# Hashed assets keep their own immutable cache. Serve the studio-chat SPA
|
||||
# when built into the image; fall back to the legacy home otherwise.
|
||||
index = _WEB_DIST / "index.html"
|
||||
html = index.read_text() if index.is_file() else _HTML.read_text()
|
||||
return web.Response(text=html, content_type="text/html",
|
||||
# running yesterday's cached page. Always serve fresh; the page is small
|
||||
# and its shell.js/shell.css keep their own cache.
|
||||
return web.Response(text=_HTML.read_text(), content_type="text/html",
|
||||
headers={"Cache-Control": "no-store, must-revalidate"})
|
||||
|
||||
@routes.get("/studio/assets/{name}")
|
||||
async def studio_asset(request: web.Request):
|
||||
# The studio-chat SPA's hashed bundle (the hash IS the version → immutable).
|
||||
name = request.match_info["name"]
|
||||
assets = (_WEB_DIST / "assets").resolve()
|
||||
f = (assets / name).resolve()
|
||||
if f.parent != assets or not f.is_file():
|
||||
return web.Response(status=404)
|
||||
ctype = ("text/javascript" if name.endswith(".js")
|
||||
else "text/css" if name.endswith(".css")
|
||||
else "application/octet-stream")
|
||||
return web.Response(body=f.read_bytes(), content_type=ctype,
|
||||
headers={"Cache-Control": "public, max-age=31536000, immutable"})
|
||||
|
||||
@routes.get("/studio/shell.js")
|
||||
async def shell_js(request: web.Request):
|
||||
return _asset("shell.js", "text/javascript")
|
||||
@@ -1023,6 +1005,14 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
|
||||
return web.json_response({"error": "invalid path"}, status=400)
|
||||
rel = f"{sub}/{name}"
|
||||
if dest.is_file() and dest.stat().st_size == len(data):
|
||||
# Identical bytes: skip the write but STILL index — files can exist
|
||||
# without rows (an interrupted mirror, a restored store), and the
|
||||
# index upsert is idempotent by path. Recovery depends on this.
|
||||
_index_upload(org, out_root, rel,
|
||||
design=request.query.get("design") or None,
|
||||
kind=request.query.get("kind") or "render",
|
||||
role=request.query.get("role") or None,
|
||||
node=request.query.get("node") or "upload")
|
||||
return web.json_response({"ok": True, "existed": True, "path": rel})
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = dest.with_name(dest.name + ".tmp")
|
||||
|
||||
@@ -59,6 +59,13 @@ def _save(org: str, rows: list[dict]) -> None:
|
||||
tmp.replace(p)
|
||||
|
||||
|
||||
def has_output(org: str, output_prefix: str) -> bool:
|
||||
"""Whether any row already carries this landed output — the ingest upsert
|
||||
key (re-ingesting identical bytes must repair, never duplicate)."""
|
||||
return any(r.get("output_prefix") == output_prefix or r.get("output") == output_prefix
|
||||
for r in load(org))
|
||||
|
||||
|
||||
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,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "hanzo-studio"
|
||||
version = "0.17.3"
|
||||
version = "0.17.4"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -91,12 +91,13 @@ 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)
|
||||
@@ -138,9 +139,13 @@ 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: an image outside the naming
|
||||
# scheme is still an asset. Dropping it here erased every
|
||||
# ingest-indexed render on each sweep cycle.
|
||||
info = {"design": prev.get("design"), "kind": prev.get("kind") or "render",
|
||||
"role": prev.get("role")}
|
||||
entry = {
|
||||
"path": norm,
|
||||
"design": info["design"],
|
||||
|
||||
Vendored
+11
-1
@@ -18,7 +18,17 @@ const AGENT_API = (import.meta.env.VITE_AGENT_API ?? '').replace(/\/$/, '')
|
||||
|
||||
async function j<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const r = await fetch(url, { credentials: 'include', ...init })
|
||||
if (!r.ok) throw new Error(`${url} -> ${r.status}`)
|
||||
if (!r.ok) {
|
||||
// Surface the server's own message — a 402 says "add credits at
|
||||
// pay.hanzo.ai", which beats a bare status code.
|
||||
let msg = `${url} -> ${r.status}`
|
||||
try {
|
||||
const b = await r.json()
|
||||
const m = b?.error?.message ?? b?.error ?? b?.message
|
||||
if (typeof m === 'string' && m) msg = m
|
||||
} catch { /* keep the status line */ }
|
||||
throw new Error(msg)
|
||||
}
|
||||
return r.json() as Promise<T>
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user