Spaces:
Running on Zero
Running on Zero
feat: restore General plus 19 LoRA editor
Browse files- MODEL_BOM.md +18 -14
- README.md +19 -14
- app.py +233 -31
- assets/app-ui.js +455 -109
- i2i_contract.py +111 -5
- index.html +67 -8
- provenance.py +8 -7
- tests/test_i2i_contract.py +259 -4
- tests/test_space_static.py +63 -23
MODEL_BOM.md
CHANGED
|
@@ -18,12 +18,14 @@ The Rapid AIO source statement is why this repository explicitly records
|
|
| 18 |
`content_moderation_enabled: false`. No input-image, Prompt, or output-image
|
| 19 |
NSFW classifier, safety checker, or semantic blocklist is added in H2.
|
| 20 |
|
| 21 |
-
##
|
| 22 |
|
| 23 |
-
The source Space exposed all 19
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
| 27 |
|
| 28 |
| API ID | Repository | Revision | Recorded status |
|
| 29 |
|---|---|---|---|
|
|
@@ -47,16 +49,18 @@ selecting, or applying a LoRA adapter.
|
|
| 47 |
| `Studio-DeLight` | `prithivMLmods/QIE-2511-Studio-DeLight` | `a5c2440989ba6a025ac99acac095369486e32774` | Metadata observed as Apache-2.0 |
|
| 48 |
| `Cinematic-FlatLog` | `prithivMLmods/QIE-2511-Cinematic-FlatLog-Control` | `f30505226920906802c9677634219bc53edfd915` | Metadata observed as Apache-2.0 |
|
| 49 |
|
| 50 |
-
## Open release gates
|
| 51 |
|
| 52 |
- Resolve license text and attribution requirements for the full runtime chain,
|
| 53 |
not only top-level metadata.
|
| 54 |
-
-
|
| 55 |
-
|
| 56 |
-
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
| 59 |
- Produce an exact Linux transitive dependency lock after a controlled build.
|
| 60 |
-
- A previous `Anime-V2` smoke
|
| 61 |
-
|
| 62 |
-
|
|
|
|
| 18 |
`content_moderation_enabled: false`. No input-image, Prompt, or output-image
|
| 19 |
NSFW classifier, safety checker, or semantic blocklist is added in H2.
|
| 20 |
|
| 21 |
+
## Enabled LoRA allowlist
|
| 22 |
|
| 23 |
+
The source Space exposed all 19 adapters below. This source revision enables
|
| 24 |
+
their exact API IDs in addition to the default `__base__` general mode. Weights
|
| 25 |
+
are loaded lazily at their pinned revisions, and the runtime unloads the prior
|
| 26 |
+
adapter before loading another so at most one LoRA is resident. Enablement is a
|
| 27 |
+
technical runtime fact, not a conclusion that every license, attribution, or
|
| 28 |
+
brand-use question has been resolved.
|
| 29 |
|
| 30 |
| API ID | Repository | Revision | Recorded status |
|
| 31 |
|---|---|---|---|
|
|
|
|
| 49 |
| `Studio-DeLight` | `prithivMLmods/QIE-2511-Studio-DeLight` | `a5c2440989ba6a025ac99acac095369486e32774` | Metadata observed as Apache-2.0 |
|
| 50 |
| `Cinematic-FlatLog` | `prithivMLmods/QIE-2511-Cinematic-FlatLog-Control` | `f30505226920906802c9677634219bc53edfd915` | Metadata observed as Apache-2.0 |
|
| 51 |
|
| 52 |
+
## Open release facts and gates
|
| 53 |
|
| 54 |
- Resolve license text and attribution requirements for the full runtime chain,
|
| 55 |
not only top-level metadata.
|
| 56 |
+
- `Style-Transfer` is technically enabled, but its metadata/card license conflict
|
| 57 |
+
remains unresolved and must not be described as license-cleared.
|
| 58 |
+
- Brand-named adapter IDs remain exposed in the selector; brand-use review is
|
| 59 |
+
still open.
|
| 60 |
+
- The 19 fixed upstream examples are exposed through generated thumbnails and
|
| 61 |
+
the non-GPU `/load_example` endpoint. Their redistribution status has not been
|
| 62 |
+
independently cleared. Direct Gradio file serving of `examples/` stays blocked.
|
| 63 |
- Produce an exact Linux transitive dependency lock after a controlled build.
|
| 64 |
+
- A previous `Anime-V2` smoke predates this single-resident adapter state machine
|
| 65 |
+
and does not validate General mode, multi-image mode, or the other 18 adapters.
|
| 66 |
+
No new GPU smoke was performed as part of this source-only restoration.
|
README.md
CHANGED
|
@@ -9,7 +9,7 @@ python_version: 3.10.13
|
|
| 9 |
app_file: app.py
|
| 10 |
pinned: true
|
| 11 |
license: apache-2.0
|
| 12 |
-
short_description: Revision-pinned general AI image editor
|
| 13 |
---
|
| 14 |
|
| 15 |
# hy3dlab Image-to-Image
|
|
@@ -18,27 +18,32 @@ Public backend duplicated from
|
|
| 18 |
[`prithivMLmods/Qwen-Image-Edit-2511-LoRAs-Fast`](https://huggingface.co/spaces/prithivMLmods/Qwen-Image-Edit-2511-LoRAs-Fast)
|
| 19 |
at commit `e1c9d2e8421d7934841d67f809d87ac7a6cdf720`.
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
| 24 |
|
| 25 |
-
The Space App includes a first-party
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
|
| 30 |
## API boundary
|
| 31 |
|
| 32 |
- Named endpoint: `/edit_image`
|
|
|
|
| 33 |
- Input shape: the upstream seven fields
|
| 34 |
- Output shape: `{ "image": "data:image/png;base64,...", "seed": number }`
|
| 35 |
-
- Technical validation: one PNG/JPEG/WebP data
|
| 36 |
-
|
| 37 |
-
concurrent pipeline operation
|
| 38 |
- Error output: stable low-cardinality codes; raw provider exceptions and user
|
| 39 |
content are not returned or logged
|
| 40 |
- Input and output are processed in memory and are not deliberately written to
|
| 41 |
application storage
|
|
|
|
|
|
|
| 42 |
|
| 43 |
## Content-moderation decision
|
| 44 |
|
|
@@ -50,10 +55,10 @@ applicable law.
|
|
| 50 |
|
| 51 |
## Reproducibility
|
| 52 |
|
| 53 |
-
Remote model, transformer, kernel, and
|
| 54 |
`provenance.py`. See `MODEL_BOM.md` for the recorded supply-chain facts and open
|
| 55 |
-
release gates
|
| 56 |
-
|
| 57 |
`pre-requirements.txt`. The browser client is vendored at the installed
|
| 58 |
`@gradio/client` 2.3.1 build and hash-checked by the static test suite; a Linux
|
| 59 |
transitive lock remains a post-build task.
|
|
|
|
| 9 |
app_file: app.py
|
| 10 |
pinned: true
|
| 11 |
license: apache-2.0
|
| 12 |
+
short_description: Revision-pinned general and LoRA AI image editor
|
| 13 |
---
|
| 14 |
|
| 15 |
# hy3dlab Image-to-Image
|
|
|
|
| 18 |
[`prithivMLmods/Qwen-Image-Edit-2511-LoRAs-Fast`](https://huggingface.co/spaces/prithivMLmods/Qwen-Image-Edit-2511-LoRAs-Fast)
|
| 19 |
at commit `e1c9d2e8421d7934841d67f809d87ac7a6cdf720`.
|
| 20 |
|
| 21 |
+
General editing through the pinned replacement transformer is the default mode
|
| 22 |
+
(`lora_adapter="__base__"`). The same seven-field endpoint also accepts the 19
|
| 23 |
+
revision-pinned LoRA IDs recorded in `provenance.py`. Adapter changes use a
|
| 24 |
+
single-resident state machine: the prior adapter is unloaded before another is
|
| 25 |
+
loaded, and activation plus inference are serialized by one pipeline lock.
|
| 26 |
|
| 27 |
+
The Space App includes a first-party one-or-two-image editor with Prompt,
|
| 28 |
+
General/Style-LoRA selection, Quick Prompts, 19 fixed examples, queue status,
|
| 29 |
+
before/after comparison, session history, and PNG download. Browser code is
|
| 30 |
+
self-hosted and uses the same strict seven-field API contract.
|
| 31 |
|
| 32 |
## API boundary
|
| 33 |
|
| 34 |
- Named endpoint: `/edit_image`
|
| 35 |
+
- Non-GPU example endpoint: `/load_example`
|
| 36 |
- Input shape: the upstream seven fields
|
| 37 |
- Output shape: `{ "image": "data:image/png;base64,...", "seed": number }`
|
| 38 |
+
- Technical validation: one or two PNG/JPEG/WebP data URLs, at most 10 MiB and
|
| 39 |
+
16 MP each (20 MiB and 32 MP total), Prompt length, numeric ranges, an exact
|
| 40 |
+
allowlisted edit mode, and one concurrent pipeline operation
|
| 41 |
- Error output: stable low-cardinality codes; raw provider exceptions and user
|
| 42 |
content are not returned or logged
|
| 43 |
- Input and output are processed in memory and are not deliberately written to
|
| 44 |
application storage
|
| 45 |
+
- Generated output remains a single PNG with a maximum 1024px long side. The
|
| 46 |
+
`Upscaler` adapter does not make this API a 4K-output service.
|
| 47 |
|
| 48 |
## Content-moderation decision
|
| 49 |
|
|
|
|
| 55 |
|
| 56 |
## Reproducibility
|
| 57 |
|
| 58 |
+
Remote model, transformer, kernel, and all enabled LoRA revisions are pinned in
|
| 59 |
`provenance.py`. See `MODEL_BOM.md` for the recorded supply-chain facts and open
|
| 60 |
+
release gates; enablement is not a claim that every license/brand question has
|
| 61 |
+
been cleared. Python direct dependencies are pinned in `requirements.txt` and
|
| 62 |
`pre-requirements.txt`. The browser client is vendored at the installed
|
| 63 |
`@gradio/client` 2.3.1 build and hash-checked by the static test suite; a Linux
|
| 64 |
transitive lock remains a post-build task.
|
app.py
CHANGED
|
@@ -1,19 +1,23 @@
|
|
| 1 |
-
"""Public, revision-pinned general Image-to-Image backend for hy3d.dev."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 5 |
import gc
|
| 6 |
import logging
|
|
|
|
| 7 |
import os
|
| 8 |
import random
|
| 9 |
import threading
|
|
|
|
| 10 |
|
| 11 |
import gradio as gr
|
| 12 |
import spaces
|
| 13 |
import torch
|
| 14 |
from fastapi import HTTPException
|
| 15 |
-
from fastapi.responses import FileResponse, HTMLResponse
|
| 16 |
from gradio import Server
|
|
|
|
| 17 |
|
| 18 |
from i2i_contract import (
|
| 19 |
ALLOWED_MIME_FORMATS,
|
|
@@ -24,24 +28,31 @@ from i2i_contract import (
|
|
| 24 |
MAX_GUIDANCE,
|
| 25 |
MAX_IMAGE_BYTES,
|
| 26 |
MAX_IMAGE_PIXELS,
|
|
|
|
| 27 |
MAX_OUTPUT_SIDE,
|
| 28 |
MAX_PROMPT_CHARS,
|
| 29 |
MAX_REQUEST_JSON_CHARS,
|
| 30 |
MAX_SEED,
|
| 31 |
MAX_STEPS,
|
|
|
|
|
|
|
| 32 |
MIN_GUIDANCE,
|
|
|
|
| 33 |
MIN_IMAGE_SIDE,
|
| 34 |
MIN_STEPS,
|
|
|
|
| 35 |
ValidatedEditRequest,
|
| 36 |
decode_validated_images,
|
| 37 |
execute_validated_edit,
|
| 38 |
output_dimensions,
|
|
|
|
| 39 |
public_error_text,
|
| 40 |
serialize_png_candidate,
|
| 41 |
)
|
| 42 |
from provenance import (
|
| 43 |
BASE_MODEL_ID,
|
| 44 |
BASE_MODEL_REVISION,
|
|
|
|
| 45 |
TRANSFORMER_MODEL_ID,
|
| 46 |
TRANSFORMER_MODEL_REVISION,
|
| 47 |
UPSTREAM_SPACE_ID,
|
|
@@ -55,12 +66,111 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s %(message
|
|
| 55 |
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 56 |
DTYPE = torch.bfloat16
|
| 57 |
PIPELINE_LOCK = threading.Lock()
|
|
|
|
| 58 |
|
| 59 |
NEGATIVE_PROMPT = (
|
| 60 |
"worst quality, low quality, bad anatomy, bad hands, text, error, missing fingers, "
|
| 61 |
"extra digit, fewer digits, cropped, jpeg artifacts, signature, watermark, username, blurry"
|
| 62 |
)
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
# Importing spaces before torch and moving the model at module scope follows the
|
| 66 |
# Hugging Face ZeroGPU model-placement contract. All Hub assets are pinned to
|
|
@@ -89,25 +199,57 @@ except Exception as exc: # The pipeline can run with its default processor.
|
|
| 89 |
LOGGER.warning("event=attention_processor_fallback error_class=%s", type(exc).__name__)
|
| 90 |
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
def _public_gradio_error(code: str) -> gr.Error:
|
| 93 |
return gr.Error(public_error_text(code), print_exception=False)
|
| 94 |
|
| 95 |
|
| 96 |
def _is_public_error(error: gr.Error) -> bool:
|
| 97 |
message = str(getattr(error, "message", error))
|
| 98 |
-
return
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
|
| 113 |
def _provider_error_code(error: gr.Error) -> str:
|
|
@@ -126,11 +268,23 @@ def _clear_cuda_cache() -> None:
|
|
| 126 |
|
| 127 |
@spaces.GPU(size="xlarge")
|
| 128 |
def _run_gpu_edit(request: ValidatedEditRequest) -> tuple[bytes, int]:
|
| 129 |
-
"""
|
| 130 |
|
| 131 |
_clear_cuda_cache()
|
| 132 |
try:
|
| 133 |
with PIPELINE_LOCK:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
used_seed = random.randint(0, MAX_SEED) if request.randomize_seed else request.seed
|
| 135 |
generator = torch.Generator(device=DEVICE).manual_seed(used_seed)
|
| 136 |
width, height = output_dimensions(request.images[0])
|
|
@@ -177,9 +331,9 @@ def edit_image(
|
|
| 177 |
) -> dict:
|
| 178 |
"""Validate on CPU, then call the internal ZeroGPU edit function.
|
| 179 |
|
| 180 |
-
The public seven-field contract is retained for compatibility.
|
| 181 |
-
|
| 182 |
-
|
| 183 |
"""
|
| 184 |
|
| 185 |
try:
|
|
@@ -191,7 +345,7 @@ def edit_image(
|
|
| 191 |
randomize_seed=randomize_seed,
|
| 192 |
guidance_scale=guidance_scale,
|
| 193 |
steps=steps,
|
| 194 |
-
allowed_edit_modes={BASE_EDIT_MODE_ID},
|
| 195 |
runner=_run_gpu_edit,
|
| 196 |
)
|
| 197 |
except ContractError as exc:
|
|
@@ -210,6 +364,7 @@ def edit_image(
|
|
| 210 |
|
| 211 |
def _service_metadata() -> dict:
|
| 212 |
deploy_sha = os.environ.get("APP_DEPLOY_SHA", "unknown")
|
|
|
|
| 213 |
return {
|
| 214 |
"service": "hy3dlab-image-to-image",
|
| 215 |
"api_version": API_VERSION,
|
|
@@ -219,17 +374,28 @@ def _service_metadata() -> dict:
|
|
| 219 |
"base_model_revision": BASE_MODEL_REVISION,
|
| 220 |
"transformer_revision": TRANSFORMER_MODEL_REVISION,
|
| 221 |
"content_moderation_enabled": CONTENT_MODERATION_ENABLED,
|
| 222 |
-
"edit_mode": "general",
|
| 223 |
-
"uses_lora":
|
| 224 |
-
"
|
| 225 |
-
"
|
| 226 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
"limits": {
|
| 228 |
-
"input_images":
|
|
|
|
|
|
|
| 229 |
"mime_types": sorted(ALLOWED_MIME_FORMATS),
|
| 230 |
"max_image_bytes": MAX_IMAGE_BYTES,
|
|
|
|
| 231 |
"max_request_json_chars": MAX_REQUEST_JSON_CHARS,
|
| 232 |
"max_image_pixels": MAX_IMAGE_PIXELS,
|
|
|
|
| 233 |
"min_image_side": MIN_IMAGE_SIDE,
|
| 234 |
"max_prompt_chars": MAX_PROMPT_CHARS,
|
| 235 |
"seed": [0, MAX_SEED],
|
|
@@ -241,27 +407,63 @@ def _service_metadata() -> dict:
|
|
| 241 |
|
| 242 |
|
| 243 |
@app.get("/healthz")
|
| 244 |
-
def healthz() ->
|
| 245 |
"""Non-inference configuration and revision check."""
|
| 246 |
|
| 247 |
-
return _service_metadata()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
|
| 249 |
|
| 250 |
@app.get("/api/config")
|
| 251 |
-
def client_config() ->
|
| 252 |
"""Public, non-secret configuration for the first-party frontend."""
|
| 253 |
|
| 254 |
metadata = _service_metadata()
|
| 255 |
-
|
| 256 |
"api_version": metadata["api_version"],
|
| 257 |
"content_moderation_enabled": metadata["content_moderation_enabled"],
|
| 258 |
"edit_mode": metadata["edit_mode"],
|
| 259 |
"uses_lora": metadata["uses_lora"],
|
|
|
|
|
|
|
| 260 |
"required_lora_adapter_value": metadata["required_lora_adapter_value"],
|
| 261 |
-
"
|
|
|
|
|
|
|
| 262 |
"default_lora": None,
|
|
|
|
| 263 |
"limits": metadata["limits"],
|
| 264 |
}
|
|
|
|
| 265 |
|
| 266 |
|
| 267 |
NO_STORE_HEADERS = {
|
|
|
|
| 1 |
+
"""Public, revision-pinned general + LoRA Image-to-Image backend for hy3d.dev."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import base64
|
| 6 |
import gc
|
| 7 |
import logging
|
| 8 |
+
import math
|
| 9 |
import os
|
| 10 |
import random
|
| 11 |
import threading
|
| 12 |
+
from io import BytesIO
|
| 13 |
|
| 14 |
import gradio as gr
|
| 15 |
import spaces
|
| 16 |
import torch
|
| 17 |
from fastapi import HTTPException
|
| 18 |
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
| 19 |
from gradio import Server
|
| 20 |
+
from PIL import Image, ImageOps
|
| 21 |
|
| 22 |
from i2i_contract import (
|
| 23 |
ALLOWED_MIME_FORMATS,
|
|
|
|
| 28 |
MAX_GUIDANCE,
|
| 29 |
MAX_IMAGE_BYTES,
|
| 30 |
MAX_IMAGE_PIXELS,
|
| 31 |
+
MAX_INPUT_IMAGES,
|
| 32 |
MAX_OUTPUT_SIDE,
|
| 33 |
MAX_PROMPT_CHARS,
|
| 34 |
MAX_REQUEST_JSON_CHARS,
|
| 35 |
MAX_SEED,
|
| 36 |
MAX_STEPS,
|
| 37 |
+
MAX_TOTAL_IMAGE_BYTES,
|
| 38 |
+
MAX_TOTAL_IMAGE_PIXELS,
|
| 39 |
MIN_GUIDANCE,
|
| 40 |
+
MIN_INPUT_IMAGES,
|
| 41 |
MIN_IMAGE_SIDE,
|
| 42 |
MIN_STEPS,
|
| 43 |
+
SingleResidentAdapterManager,
|
| 44 |
ValidatedEditRequest,
|
| 45 |
decode_validated_images,
|
| 46 |
execute_validated_edit,
|
| 47 |
output_dimensions,
|
| 48 |
+
parse_example_index,
|
| 49 |
public_error_text,
|
| 50 |
serialize_png_candidate,
|
| 51 |
)
|
| 52 |
from provenance import (
|
| 53 |
BASE_MODEL_ID,
|
| 54 |
BASE_MODEL_REVISION,
|
| 55 |
+
ENABLED_ADAPTER_SPECS,
|
| 56 |
TRANSFORMER_MODEL_ID,
|
| 57 |
TRANSFORMER_MODEL_REVISION,
|
| 58 |
UPSTREAM_SPACE_ID,
|
|
|
|
| 66 |
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 67 |
DTYPE = torch.bfloat16
|
| 68 |
PIPELINE_LOCK = threading.Lock()
|
| 69 |
+
LANCZOS = getattr(Image, "Resampling", Image).LANCZOS
|
| 70 |
|
| 71 |
NEGATIVE_PROMPT = (
|
| 72 |
"worst quality, low quality, bad anatomy, bad hands, text, error, missing fingers, "
|
| 73 |
"extra digit, fewer digits, cropped, jpeg artifacts, signature, watermark, username, blurry"
|
| 74 |
)
|
| 75 |
|
| 76 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 77 |
+
EXAMPLES_DIR = os.path.realpath(os.path.join(BASE_DIR, "examples"))
|
| 78 |
+
|
| 79 |
+
# Fixed, index-addressed manifest copied from the pinned upstream Space. The
|
| 80 |
+
# Upscaler text is deliberately accurate for this API's 1024px output limit.
|
| 81 |
+
EXAMPLES_CONFIG = (
|
| 82 |
+
{"images": ("examples/B.jpg",), "prompt": "Transform into anime.", "lora": "Photo-to-Anime"},
|
| 83 |
+
{"images": ("examples/HRP.jpg",), "prompt": "Transform into a hyper-realistic face portrait.", "lora": "Hyper-Realistic-Portrait"},
|
| 84 |
+
{"images": ("examples/A.jpeg",), "prompt": "Rotate the camera 45 degrees to the right.", "lora": "Multiple-Angles"},
|
| 85 |
+
{"images": ("examples/U.jpg",), "prompt": "Upscale and enhance image detail.", "lora": "Upscaler"},
|
| 86 |
+
{"images": ("examples/L1.jpg", "examples/L2.jpg"), "prompt": "Apply the lighting from image 2 to image 1.", "lora": "Any-light"},
|
| 87 |
+
{"images": ("examples/PP1.jpg",), "prompt": "cinematic polaroid with soft grain subtle vignette gentle lighting white frame handwritten photographed preserving realistic texture and details.", "lora": "Polaroid-Photo"},
|
| 88 |
+
{"images": ("examples/Z1.jpg",), "prompt": "Front-right quarter view.", "lora": "Fal-Multiple-Angles"},
|
| 89 |
+
{"images": ("examples/URP.jpg",), "prompt": "Transform into a cinematic flat log.", "lora": "Cinematic-FlatLog"},
|
| 90 |
+
{"images": ("examples/SL.jpg",), "prompt": "Neutral uniform lighting. Preserve identity and composition.", "lora": "Studio-DeLight"},
|
| 91 |
+
{"images": ("examples/PI.jpg",), "prompt": "Transform it into Pixar-inspired 3D.", "lora": "Pixar-Inspired-3D"},
|
| 92 |
+
{"images": ("examples/MT.jpg",), "prompt": "Paint with manga tone.", "lora": "Manga-Tone"},
|
| 93 |
+
{"images": ("examples/NCB.jpg",), "prompt": "Transform into a noir comic book style.", "lora": "Noir-Comic-Book"},
|
| 94 |
+
{"images": ("examples/URP.jpg",), "prompt": "Ultra-realistic portrait.", "lora": "Ultra-Realistic-Portrait"},
|
| 95 |
+
{"images": ("examples/MN.jpg",), "prompt": "Transform into Midnight Noir Eyes Spotlight.", "lora": "Midnight-Noir-Eyes-Spotlight"},
|
| 96 |
+
{"images": ("examples/ST1.jpg", "examples/ST2.jpg"), "prompt": "Convert Image 1 to the style of Image 2.", "lora": "Style-Transfer"},
|
| 97 |
+
{"images": ("examples/R1.jpg",), "prompt": "Change the picture to realistic photograph.", "lora": "Anything2Real"},
|
| 98 |
+
{"images": ("examples/UA.jpeg",), "prompt": "Unblur and upscale.", "lora": "Unblur-Anything"},
|
| 99 |
+
{"images": ("examples/L1.jpg", "examples/L2.jpg"), "prompt": "Refer to the color tone, remove the original lighting from Image 1, and relight Image 1 based on the lighting and color tone of Image 2.", "lora": "Light-Migration"},
|
| 100 |
+
{"images": ("examples/P1.jpg",), "prompt": "Transform into anime (while preserving the background and remaining elements maintaining realism and original details.)", "lora": "Anime-V2"},
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
if len(EXAMPLES_CONFIG) != 19 or any(example["lora"] not in ENABLED_ADAPTER_SPECS for example in EXAMPLES_CONFIG):
|
| 104 |
+
raise RuntimeError("The fixed example manifest must reference all approved runtime assets")
|
| 105 |
+
|
| 106 |
+
EXAMPLE_ASSET_PATHS = frozenset(
|
| 107 |
+
relative_path
|
| 108 |
+
for example in EXAMPLES_CONFIG
|
| 109 |
+
for relative_path in example["images"]
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _fixed_example_path(relative_path: str) -> str:
|
| 114 |
+
"""Resolve only paths present in the static example manifest."""
|
| 115 |
+
|
| 116 |
+
if relative_path not in EXAMPLE_ASSET_PATHS:
|
| 117 |
+
raise ValueError("Unknown example asset")
|
| 118 |
+
resolved = os.path.realpath(os.path.join(BASE_DIR, relative_path))
|
| 119 |
+
if os.path.commonpath((EXAMPLES_DIR, resolved)) != EXAMPLES_DIR:
|
| 120 |
+
raise ValueError("Invalid example asset path")
|
| 121 |
+
return resolved
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _make_example_thumbnail(relative_path: str, max_side: int = 220) -> str:
|
| 125 |
+
try:
|
| 126 |
+
with Image.open(_fixed_example_path(relative_path)) as source:
|
| 127 |
+
source.load()
|
| 128 |
+
image = ImageOps.exif_transpose(source).convert("RGB")
|
| 129 |
+
image.thumbnail((max_side, max_side), LANCZOS)
|
| 130 |
+
buffer = BytesIO()
|
| 131 |
+
image.save(buffer, format="JPEG", quality=65, optimize=True)
|
| 132 |
+
return f"data:image/jpeg;base64,{base64.b64encode(buffer.getvalue()).decode('ascii')}"
|
| 133 |
+
except (OSError, SyntaxError, ValueError) as exc:
|
| 134 |
+
LOGGER.warning("event=example_thumbnail_failed error_class=%s", type(exc).__name__)
|
| 135 |
+
return ""
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _encode_example_image(relative_path: str) -> str:
|
| 139 |
+
"""Encode one fixed example, normalizing it to the public input limits."""
|
| 140 |
+
|
| 141 |
+
with Image.open(_fixed_example_path(relative_path)) as source:
|
| 142 |
+
source.load()
|
| 143 |
+
image = ImageOps.exif_transpose(source).convert("RGB")
|
| 144 |
+
if image.width * image.height > MAX_IMAGE_PIXELS:
|
| 145 |
+
scale = math.sqrt(MAX_IMAGE_PIXELS / (image.width * image.height))
|
| 146 |
+
target = (max(MIN_IMAGE_SIDE, int(image.width * scale)), max(MIN_IMAGE_SIDE, int(image.height * scale)))
|
| 147 |
+
image = image.resize(target, LANCZOS)
|
| 148 |
+
buffer = BytesIO()
|
| 149 |
+
image.save(buffer, format="JPEG", quality=90, optimize=True)
|
| 150 |
+
encoded = buffer.getvalue()
|
| 151 |
+
if len(encoded) > MAX_IMAGE_BYTES:
|
| 152 |
+
raise ValueError("Normalized example exceeds the public image limit")
|
| 153 |
+
return f"data:image/jpeg;base64,{base64.b64encode(encoded).decode('ascii')}"
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _build_example_cards() -> list[dict]:
|
| 157 |
+
cards = []
|
| 158 |
+
for index, example in enumerate(EXAMPLES_CONFIG):
|
| 159 |
+
cards.append(
|
| 160 |
+
{
|
| 161 |
+
"idx": index,
|
| 162 |
+
"thumbs": [_make_example_thumbnail(path) for path in example["images"]],
|
| 163 |
+
"n_images": len(example["images"]),
|
| 164 |
+
"lora": example["lora"],
|
| 165 |
+
"prompt": example["prompt"],
|
| 166 |
+
}
|
| 167 |
+
)
|
| 168 |
+
return cards
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
EXAMPLE_CARDS = _build_example_cards()
|
| 172 |
+
EXAMPLE_ASSETS_READY = all(card["thumbs"] and all(card["thumbs"]) for card in EXAMPLE_CARDS)
|
| 173 |
+
|
| 174 |
|
| 175 |
# Importing spaces before torch and moving the model at module scope follows the
|
| 176 |
# Hugging Face ZeroGPU model-placement contract. All Hub assets are pinned to
|
|
|
|
| 199 |
LOGGER.warning("event=attention_processor_fallback error_class=%s", type(exc).__name__)
|
| 200 |
|
| 201 |
|
| 202 |
+
def _load_adapter(edit_mode: str) -> None:
|
| 203 |
+
spec = ENABLED_ADAPTER_SPECS[edit_mode]
|
| 204 |
+
pipe.load_lora_weights(
|
| 205 |
+
spec.repo,
|
| 206 |
+
revision=spec.revision,
|
| 207 |
+
weight_name=spec.weights,
|
| 208 |
+
adapter_name=spec.adapter_name,
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def _activate_adapter(edit_mode: str) -> None:
|
| 213 |
+
pipe.set_adapters(
|
| 214 |
+
[ENABLED_ADAPTER_SPECS[edit_mode].adapter_name],
|
| 215 |
+
adapter_weights=[1.0],
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def _unload_adapters() -> None:
|
| 220 |
+
pipe.unload_lora_weights()
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
ADAPTER_MANAGER = SingleResidentAdapterManager(
|
| 224 |
+
adapter_modes=ENABLED_ADAPTER_SPECS,
|
| 225 |
+
load_adapter=_load_adapter,
|
| 226 |
+
activate_adapter=_activate_adapter,
|
| 227 |
+
unload_adapters=_unload_adapters,
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
def _public_gradio_error(code: str) -> gr.Error:
|
| 232 |
return gr.Error(public_error_text(code), print_exception=False)
|
| 233 |
|
| 234 |
|
| 235 |
def _is_public_error(error: gr.Error) -> bool:
|
| 236 |
message = str(getattr(error, "message", error))
|
| 237 |
+
return message in {
|
| 238 |
+
public_error_text(code)
|
| 239 |
+
for code in (
|
| 240 |
+
"I2I_BAD_REQUEST",
|
| 241 |
+
"I2I_UNSUPPORTED_TYPE",
|
| 242 |
+
"I2I_FILE_TOO_LARGE",
|
| 243 |
+
"I2I_IMAGE_DECODE_FAILED",
|
| 244 |
+
"I2I_IMAGE_DIMENSIONS_INVALID",
|
| 245 |
+
"I2I_PROMPT_REQUIRED",
|
| 246 |
+
"I2I_PROMPT_TOO_LONG",
|
| 247 |
+
"I2I_LORA_NOT_ALLOWED",
|
| 248 |
+
"I2I_PROVIDER_BUSY",
|
| 249 |
+
"I2I_INFERENCE_FAILED",
|
| 250 |
+
"I2I_INVALID_OUTPUT",
|
| 251 |
+
)
|
| 252 |
+
}
|
| 253 |
|
| 254 |
|
| 255 |
def _provider_error_code(error: gr.Error) -> str:
|
|
|
|
| 268 |
|
| 269 |
@spaces.GPU(size="xlarge")
|
| 270 |
def _run_gpu_edit(request: ValidatedEditRequest) -> tuple[bytes, int]:
|
| 271 |
+
"""Activate one allowed mode and run inference under one pipeline lock."""
|
| 272 |
|
| 273 |
_clear_cuda_cache()
|
| 274 |
try:
|
| 275 |
with PIPELINE_LOCK:
|
| 276 |
+
try:
|
| 277 |
+
ADAPTER_MANAGER.activate_mode(request.edit_mode)
|
| 278 |
+
except ContractError:
|
| 279 |
+
raise
|
| 280 |
+
except Exception as exc:
|
| 281 |
+
LOGGER.warning(
|
| 282 |
+
"event=adapter_transition_failed edit_mode=%s error_class=%s",
|
| 283 |
+
request.edit_mode,
|
| 284 |
+
type(exc).__name__,
|
| 285 |
+
)
|
| 286 |
+
raise _public_gradio_error("I2I_INFERENCE_FAILED") from None
|
| 287 |
+
|
| 288 |
used_seed = random.randint(0, MAX_SEED) if request.randomize_seed else request.seed
|
| 289 |
generator = torch.Generator(device=DEVICE).manual_seed(used_seed)
|
| 290 |
width, height = output_dimensions(request.images[0])
|
|
|
|
| 331 |
) -> dict:
|
| 332 |
"""Validate on CPU, then call the internal ZeroGPU edit function.
|
| 333 |
|
| 334 |
+
The public seven-field contract is retained for compatibility. General mode
|
| 335 |
+
uses the reserved ``__base__`` value; 19 provenance-pinned LoRA IDs are also
|
| 336 |
+
accepted. Validation is technical only, with no semantic moderation.
|
| 337 |
"""
|
| 338 |
|
| 339 |
try:
|
|
|
|
| 345 |
randomize_seed=randomize_seed,
|
| 346 |
guidance_scale=guidance_scale,
|
| 347 |
steps=steps,
|
| 348 |
+
allowed_edit_modes={BASE_EDIT_MODE_ID, *ENABLED_ADAPTER_SPECS},
|
| 349 |
runner=_run_gpu_edit,
|
| 350 |
)
|
| 351 |
except ContractError as exc:
|
|
|
|
| 364 |
|
| 365 |
def _service_metadata() -> dict:
|
| 366 |
deploy_sha = os.environ.get("APP_DEPLOY_SHA", "unknown")
|
| 367 |
+
lora_ids = list(ENABLED_ADAPTER_SPECS)
|
| 368 |
return {
|
| 369 |
"service": "hy3dlab-image-to-image",
|
| 370 |
"api_version": API_VERSION,
|
|
|
|
| 374 |
"base_model_revision": BASE_MODEL_REVISION,
|
| 375 |
"transformer_revision": TRANSFORMER_MODEL_REVISION,
|
| 376 |
"content_moderation_enabled": CONTENT_MODERATION_ENABLED,
|
| 377 |
+
"edit_mode": "general-and-lora",
|
| 378 |
+
"uses_lora": True,
|
| 379 |
+
"supports_general_mode": True,
|
| 380 |
+
"general_mode_value": BASE_EDIT_MODE_ID,
|
| 381 |
+
"required_lora_adapter_value": None,
|
| 382 |
+
"default_edit_mode": BASE_EDIT_MODE_ID,
|
| 383 |
+
"enabled_loras": lora_ids,
|
| 384 |
+
"enabled_edit_modes": [BASE_EDIT_MODE_ID, *lora_ids],
|
| 385 |
+
"adapter_residency": "single",
|
| 386 |
+
"examples_count": len(EXAMPLE_CARDS),
|
| 387 |
+
"example_assets_ready": EXAMPLE_ASSETS_READY,
|
| 388 |
+
"ready": deploy_sha != "unknown" and EXAMPLE_ASSETS_READY,
|
| 389 |
"limits": {
|
| 390 |
+
"input_images": MAX_INPUT_IMAGES,
|
| 391 |
+
"min_input_images": MIN_INPUT_IMAGES,
|
| 392 |
+
"max_input_images": MAX_INPUT_IMAGES,
|
| 393 |
"mime_types": sorted(ALLOWED_MIME_FORMATS),
|
| 394 |
"max_image_bytes": MAX_IMAGE_BYTES,
|
| 395 |
+
"max_total_image_bytes": MAX_TOTAL_IMAGE_BYTES,
|
| 396 |
"max_request_json_chars": MAX_REQUEST_JSON_CHARS,
|
| 397 |
"max_image_pixels": MAX_IMAGE_PIXELS,
|
| 398 |
+
"max_total_image_pixels": MAX_TOTAL_IMAGE_PIXELS,
|
| 399 |
"min_image_side": MIN_IMAGE_SIDE,
|
| 400 |
"max_prompt_chars": MAX_PROMPT_CHARS,
|
| 401 |
"seed": [0, MAX_SEED],
|
|
|
|
| 407 |
|
| 408 |
|
| 409 |
@app.get("/healthz")
|
| 410 |
+
def healthz() -> JSONResponse:
|
| 411 |
"""Non-inference configuration and revision check."""
|
| 412 |
|
| 413 |
+
return JSONResponse(content=_service_metadata(), headers=NO_STORE_HEADERS)
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def _example_error_response() -> dict:
|
| 417 |
+
return {"images": [], "prompt": "", "lora": "", "names": [], "status": "error"}
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
@app.api(name="load_example", queue=False)
|
| 421 |
+
def load_example(idx: float) -> dict:
|
| 422 |
+
"""Return one fixed example payload without allocating a GPU."""
|
| 423 |
+
|
| 424 |
+
index = parse_example_index(idx, len(EXAMPLES_CONFIG))
|
| 425 |
+
if index is None:
|
| 426 |
+
return _example_error_response()
|
| 427 |
+
|
| 428 |
+
example = EXAMPLES_CONFIG[index]
|
| 429 |
+
try:
|
| 430 |
+
images = [_encode_example_image(path) for path in example["images"]]
|
| 431 |
+
except (OSError, SyntaxError, ValueError) as exc:
|
| 432 |
+
LOGGER.warning("event=example_load_failed error_class=%s", type(exc).__name__)
|
| 433 |
+
return _example_error_response()
|
| 434 |
+
|
| 435 |
+
if len(images) != len(example["images"]) or not all(images):
|
| 436 |
+
return _example_error_response()
|
| 437 |
+
return {
|
| 438 |
+
"images": images,
|
| 439 |
+
"prompt": example["prompt"],
|
| 440 |
+
"lora": example["lora"],
|
| 441 |
+
"names": [os.path.basename(path) for path in example["images"]],
|
| 442 |
+
"status": "ok",
|
| 443 |
+
}
|
| 444 |
|
| 445 |
|
| 446 |
@app.get("/api/config")
|
| 447 |
+
def client_config() -> JSONResponse:
|
| 448 |
"""Public, non-secret configuration for the first-party frontend."""
|
| 449 |
|
| 450 |
metadata = _service_metadata()
|
| 451 |
+
config = {
|
| 452 |
"api_version": metadata["api_version"],
|
| 453 |
"content_moderation_enabled": metadata["content_moderation_enabled"],
|
| 454 |
"edit_mode": metadata["edit_mode"],
|
| 455 |
"uses_lora": metadata["uses_lora"],
|
| 456 |
+
"supports_general_mode": metadata["supports_general_mode"],
|
| 457 |
+
"general_mode_value": metadata["general_mode_value"],
|
| 458 |
"required_lora_adapter_value": metadata["required_lora_adapter_value"],
|
| 459 |
+
"default_edit_mode": metadata["default_edit_mode"],
|
| 460 |
+
"enabled_edit_modes": metadata["enabled_edit_modes"],
|
| 461 |
+
"loras": metadata["enabled_loras"],
|
| 462 |
"default_lora": None,
|
| 463 |
+
"examples": EXAMPLE_CARDS,
|
| 464 |
"limits": metadata["limits"],
|
| 465 |
}
|
| 466 |
+
return JSONResponse(content=config, headers=NO_STORE_HEADERS)
|
| 467 |
|
| 468 |
|
| 469 |
NO_STORE_HEADERS = {
|
assets/app-ui.js
CHANGED
|
@@ -1,12 +1,38 @@
|
|
| 1 |
import { Client } from "/hy3d-assets/gradio-client-2.3.1.js";
|
| 2 |
|
| 3 |
const GENERAL_MODE_ID = "__base__";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
const MAX_HISTORY_ITEMS = 6;
|
| 5 |
const FALLBACK_LIMITS = Object.freeze({
|
| 6 |
-
input_images:
|
|
|
|
|
|
|
| 7 |
mime_types: ["image/jpeg", "image/png", "image/webp"],
|
| 8 |
max_image_bytes: 10 * 1024 * 1024,
|
|
|
|
| 9 |
max_image_pixels: 16_000_000,
|
|
|
|
| 10 |
min_image_side: 64,
|
| 11 |
max_prompt_chars: 1000,
|
| 12 |
seed: [0, 2147483647],
|
|
@@ -30,14 +56,22 @@ const PUBLIC_ERROR_MESSAGES = Object.freeze({
|
|
| 30 |
});
|
| 31 |
|
| 32 |
const SUGGESTIONS = Object.freeze([
|
| 33 |
-
["
|
| 34 |
-
["
|
| 35 |
-
["
|
| 36 |
-
["
|
| 37 |
-
["
|
| 38 |
-
["
|
| 39 |
-
["
|
| 40 |
-
["
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
]);
|
| 42 |
|
| 43 |
const galleryGrid = document.getElementById("image-gallery-grid");
|
|
@@ -48,6 +82,7 @@ const btnRemove = document.getElementById("tb-remove");
|
|
| 48 |
const btnClear = document.getElementById("tb-clear");
|
| 49 |
const promptInput = document.getElementById("custom-prompt-input");
|
| 50 |
const charCount = document.getElementById("char-count");
|
|
|
|
| 51 |
const runBtn = document.getElementById("custom-run-btn");
|
| 52 |
const runBtnLabel = document.getElementById("run-btn-label");
|
| 53 |
const cancelBtn = document.getElementById("custom-cancel-btn");
|
|
@@ -87,11 +122,19 @@ const segInput = document.getElementById("seg-input");
|
|
| 87 |
const connDot = document.getElementById("conn-dot");
|
| 88 |
const connText = document.getElementById("conn-text");
|
| 89 |
const suggestionsWrap = document.getElementById("suggestions-wrap");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
let client = null;
|
| 92 |
let limits = FALLBACK_LIMITS;
|
| 93 |
let contractReady = false;
|
| 94 |
-
let
|
|
|
|
|
|
|
| 95 |
let currentResult = null;
|
| 96 |
let history = [];
|
| 97 |
let historyIndex = -1;
|
|
@@ -104,6 +147,9 @@ let currentStage = "idle";
|
|
| 104 |
let cancellationRequested = false;
|
| 105 |
let activeRunId = 0;
|
| 106 |
let selectionId = 0;
|
|
|
|
|
|
|
|
|
|
| 107 |
let toastTimer = null;
|
| 108 |
|
| 109 |
function errorClass(error) {
|
|
@@ -155,7 +201,8 @@ function setStatus(text, style = "") {
|
|
| 155 |
}
|
| 156 |
|
| 157 |
function updateRunButton() {
|
| 158 |
-
|
|
|
|
| 159 |
}
|
| 160 |
|
| 161 |
function flashPromptError() {
|
|
@@ -175,24 +222,26 @@ function exitCompareMode() {
|
|
| 175 |
compareBtn.setAttribute("aria-pressed", "false");
|
| 176 |
compareWrap.classList.remove("visible");
|
| 177 |
const canvasImage = getCanvasImage();
|
| 178 |
-
if (canvasImage && (currentResult ||
|
| 179 |
}
|
| 180 |
|
| 181 |
function updateRailState() {
|
| 182 |
const hasResult = Boolean(currentResult);
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
|
|
|
| 190 |
}
|
| 191 |
|
| 192 |
function renderCanvas() {
|
| 193 |
exitCompareMode();
|
| 194 |
let canvasImage = getCanvasImage();
|
| 195 |
-
const
|
|
|
|
| 196 |
if (shown) {
|
| 197 |
outPh.style.display = "none";
|
| 198 |
if (!canvasImage) {
|
|
@@ -206,14 +255,16 @@ function renderCanvas() {
|
|
| 206 |
outBody.appendChild(canvasImage);
|
| 207 |
}
|
| 208 |
canvasImage.style.display = "";
|
| 209 |
-
canvasImage.src = viewMode === "result" ? currentResult.b64 :
|
| 210 |
canvasMeta.textContent = viewMode === "result"
|
| 211 |
-
? `result · seed ${currentResult.seed}`
|
| 212 |
-
: `input · ${
|
| 213 |
} else {
|
| 214 |
if (canvasImage) canvasImage.style.display = "none";
|
| 215 |
outPh.style.display = "";
|
| 216 |
-
canvasMeta.textContent =
|
|
|
|
|
|
|
| 217 |
}
|
| 218 |
updateRailState();
|
| 219 |
}
|
|
@@ -226,47 +277,63 @@ function setViewMode(mode) {
|
|
| 226 |
}
|
| 227 |
|
| 228 |
function updateCounts() {
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
? `1 input · ${history.length} result${history.length === 1 ? "" : "s"}`
|
| 233 |
: "No inputs";
|
| 234 |
}
|
| 235 |
|
| 236 |
function renderInputStrip() {
|
| 237 |
galleryGrid.replaceChildren();
|
| 238 |
-
if (!
|
| 239 |
const empty = document.createElement("div");
|
| 240 |
empty.className = "fs-empty";
|
| 241 |
-
empty.textContent = "Drop or paste
|
| 242 |
galleryGrid.appendChild(empty);
|
| 243 |
-
}
|
|
|
|
| 244 |
const thumb = document.createElement("div");
|
| 245 |
-
thumb.className =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
const image = document.createElement("img");
|
| 247 |
-
image.src =
|
| 248 |
-
image.alt =
|
| 249 |
const badge = document.createElement("span");
|
| 250 |
badge.className = "fs-badge";
|
| 251 |
-
badge.textContent =
|
| 252 |
const remove = document.createElement("button");
|
| 253 |
remove.className = "fs-remove";
|
| 254 |
remove.type = "button";
|
| 255 |
remove.textContent = "✕";
|
| 256 |
-
remove.setAttribute("aria-label",
|
| 257 |
remove.addEventListener("click", (event) => {
|
| 258 |
event.stopPropagation();
|
| 259 |
-
|
| 260 |
});
|
| 261 |
-
|
| 262 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
galleryGrid.appendChild(thumb);
|
| 264 |
-
}
|
| 265 |
const add = document.createElement("button");
|
| 266 |
add.type = "button";
|
| 267 |
add.className = "fs-add";
|
| 268 |
-
|
| 269 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
galleryGrid.appendChild(add);
|
| 271 |
updateRailState();
|
| 272 |
}
|
|
@@ -302,29 +369,42 @@ function renderHistory() {
|
|
| 302 |
});
|
| 303 |
}
|
| 304 |
|
| 305 |
-
function
|
| 306 |
selectionId += 1;
|
| 307 |
-
|
| 308 |
-
|
| 309 |
renderInputStrip();
|
| 310 |
updateCounts();
|
| 311 |
-
|
|
|
|
| 312 |
updateRunButton();
|
| 313 |
}
|
| 314 |
|
| 315 |
-
function
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
}
|
| 322 |
|
| 323 |
function readFileAsDataUrl(file) {
|
| 324 |
return new Promise((resolve, reject) => {
|
| 325 |
const reader = new FileReader();
|
| 326 |
-
reader.onload = () => typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("
|
| 327 |
-
reader.onerror = () => reject(new Error("
|
| 328 |
reader.readAsDataURL(file);
|
| 329 |
});
|
| 330 |
}
|
|
@@ -339,58 +419,129 @@ function decodeImageFile(file) {
|
|
| 339 |
};
|
| 340 |
image.onerror = () => {
|
| 341 |
URL.revokeObjectURL(url);
|
| 342 |
-
reject(new Error("
|
| 343 |
};
|
| 344 |
image.src = url;
|
| 345 |
});
|
| 346 |
}
|
| 347 |
|
| 348 |
-
|
| 349 |
-
if (!
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
if (!contractReady) {
|
| 351 |
showToast("The editor is still connecting. Try again shortly.", "warning");
|
| 352 |
return;
|
| 353 |
}
|
| 354 |
-
if (running) {
|
| 355 |
-
showToast("Wait for the current edit to finish.", "warning");
|
| 356 |
return;
|
| 357 |
}
|
| 358 |
const localSelectionId = ++selectionId;
|
| 359 |
-
if (!limits.mime_types.includes(file.type)) {
|
| 360 |
-
showToast(PUBLIC_ERROR_MESSAGES.I2I_UNSUPPORTED_TYPE);
|
| 361 |
-
return;
|
| 362 |
-
}
|
| 363 |
-
if (file.size <= 0 || file.size > limits.max_image_bytes) {
|
| 364 |
-
showToast(PUBLIC_ERROR_MESSAGES.I2I_FILE_TOO_LARGE);
|
| 365 |
-
return;
|
| 366 |
-
}
|
| 367 |
try {
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
}
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
showToast("Input image ready", "info");
|
| 380 |
} catch (error) {
|
| 381 |
console.warn("[image-editor] input validation failed", { error_class: errorClass(error) });
|
| 382 |
-
|
|
|
|
| 383 |
}
|
| 384 |
}
|
| 385 |
|
| 386 |
fileInput.addEventListener("change", () => {
|
| 387 |
-
const
|
| 388 |
-
void
|
| 389 |
fileInput.value = "";
|
|
|
|
| 390 |
});
|
| 391 |
-
btnUpload.addEventListener("click",
|
| 392 |
-
btnRemove.addEventListener("click",
|
| 393 |
-
btnClear.addEventListener("click",
|
| 394 |
|
| 395 |
let dragDepth = 0;
|
| 396 |
window.addEventListener("dragenter", (event) => {
|
|
@@ -413,8 +564,7 @@ window.addEventListener("drop", (event) => {
|
|
| 413 |
dropOverlay.classList.remove("visible");
|
| 414 |
const files = event.dataTransfer && event.dataTransfer.files;
|
| 415 |
if (!files || !files.length) return;
|
| 416 |
-
|
| 417 |
-
void processFile(files[0]);
|
| 418 |
});
|
| 419 |
|
| 420 |
document.addEventListener("paste", (event) => {
|
|
@@ -422,13 +572,28 @@ document.addEventListener("paste", (event) => {
|
|
| 422 |
if (event.target === promptInput) return;
|
| 423 |
const items = event.clipboardData && event.clipboardData.items;
|
| 424 |
if (!items) return;
|
| 425 |
-
const
|
| 426 |
-
|
| 427 |
-
|
|
|
|
|
|
|
| 428 |
event.preventDefault();
|
| 429 |
-
void
|
| 430 |
});
|
| 431 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
SUGGESTIONS.forEach(([label, prompt]) => {
|
| 433 |
const chip = document.createElement("button");
|
| 434 |
chip.type = "button";
|
|
@@ -451,6 +616,132 @@ promptInput.addEventListener("input", () => {
|
|
| 451 |
updateRunButton();
|
| 452 |
});
|
| 453 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
function bindSlider(slider) {
|
| 455 |
const value = document.getElementById(`${slider.id}-val`);
|
| 456 |
slider.addEventListener("input", () => { value.textContent = slider.value; });
|
|
@@ -515,7 +806,7 @@ function decodeOutput(dataUrl) {
|
|
| 515 |
reject(new Error("I2I_INVALID_OUTPUT"));
|
| 516 |
return;
|
| 517 |
}
|
| 518 |
-
resolve();
|
| 519 |
};
|
| 520 |
image.onerror = () => reject(new Error("I2I_INVALID_OUTPUT"));
|
| 521 |
image.src = dataUrl;
|
|
@@ -618,7 +909,8 @@ document.addEventListener("keydown", (event) => {
|
|
| 618 |
if (event.key === "Escape") closeLightbox();
|
| 619 |
});
|
| 620 |
zoomBtn.addEventListener("click", () => {
|
| 621 |
-
const
|
|
|
|
| 622 |
if (!source) return;
|
| 623 |
openLightbox(source, zoomBtn);
|
| 624 |
});
|
|
@@ -631,10 +923,16 @@ dlBtn.addEventListener("click", () => {
|
|
| 631 |
link.click();
|
| 632 |
link.remove();
|
| 633 |
});
|
| 634 |
-
useAsInputBtn.addEventListener("click", () => {
|
| 635 |
if (!currentResult) return;
|
| 636 |
-
|
| 637 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 638 |
});
|
| 639 |
seedChip.addEventListener("click", () => {
|
| 640 |
if (!currentResult || !navigator.clipboard) return;
|
|
@@ -658,8 +956,8 @@ function validateBeforeRun() {
|
|
| 658 |
showToast("The editor is not connected. Refresh the page.");
|
| 659 |
return false;
|
| 660 |
}
|
| 661 |
-
if (
|
| 662 |
-
showToast(
|
| 663 |
return false;
|
| 664 |
}
|
| 665 |
const prompt = promptInput.value.trim();
|
|
@@ -672,15 +970,21 @@ function validateBeforeRun() {
|
|
| 672 |
showToast(PUBLIC_ERROR_MESSAGES.I2I_PROMPT_TOO_LONG);
|
| 673 |
return false;
|
| 674 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 675 |
return true;
|
| 676 |
}
|
| 677 |
|
| 678 |
async function runEdit() {
|
| 679 |
-
if (running || !validateBeforeRun()) return;
|
| 680 |
const runId = ++activeRunId;
|
| 681 |
cancellationRequested = false;
|
| 682 |
-
const
|
|
|
|
| 683 |
const prompt = promptInput.value.trim();
|
|
|
|
| 684 |
let parsedResult = null;
|
| 685 |
setRunningUi(true, "preparing");
|
| 686 |
setViewMode("result");
|
|
@@ -688,9 +992,9 @@ async function runEdit() {
|
|
| 688 |
setStatus("Queued");
|
| 689 |
|
| 690 |
const payload = {
|
| 691 |
-
images_b64_json: JSON.stringify(
|
| 692 |
prompt,
|
| 693 |
-
lora_adapter:
|
| 694 |
seed: Number.parseInt(seedSlider.value, 10),
|
| 695 |
randomize_seed: randomizeInput.checked,
|
| 696 |
guidance_scale: Number.parseFloat(guidanceSlider.value),
|
|
@@ -736,6 +1040,7 @@ async function runEdit() {
|
|
| 736 |
b64: output.image,
|
| 737 |
seed: output.seed,
|
| 738 |
prompt,
|
|
|
|
| 739 |
beforeB64,
|
| 740 |
};
|
| 741 |
}
|
|
@@ -798,12 +1103,24 @@ document.addEventListener("keydown", (event) => {
|
|
| 798 |
function validateConfig(config) {
|
| 799 |
if (
|
| 800 |
!config ||
|
| 801 |
-
config.
|
| 802 |
-
config.
|
| 803 |
-
config.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 804 |
config.content_moderation_enabled !== false ||
|
| 805 |
!Array.isArray(config.loras) ||
|
| 806 |
-
config.loras.length !==
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 807 |
!config.limits
|
| 808 |
) {
|
| 809 |
throw new Error("contract_mismatch");
|
|
@@ -814,9 +1131,14 @@ function validateConfig(config) {
|
|
| 814 |
!Array.isArray(nextLimits.mime_types) ||
|
| 815 |
nextLimits.mime_types.length !== expectedMimes.length ||
|
| 816 |
!expectedMimes.every((mime) => nextLimits.mime_types.includes(mime)) ||
|
| 817 |
-
nextLimits.input_images !==
|
|
|
|
|
|
|
| 818 |
!Number.isInteger(nextLimits.max_image_bytes) || nextLimits.max_image_bytes <= 0 ||
|
|
|
|
|
|
|
| 819 |
!Number.isInteger(nextLimits.max_image_pixels) || nextLimits.max_image_pixels <= 0 ||
|
|
|
|
| 820 |
!Number.isInteger(nextLimits.min_image_side) || nextLimits.min_image_side <= 0 ||
|
| 821 |
!Number.isInteger(nextLimits.max_prompt_chars) || nextLimits.max_prompt_chars <= 0 ||
|
| 822 |
!Array.isArray(nextLimits.seed) ||
|
|
@@ -825,11 +1147,29 @@ function validateConfig(config) {
|
|
| 825 |
nextLimits.seed.length !== 2 || !nextLimits.seed.every(Number.isInteger) || nextLimits.seed[0] < 0 || nextLimits.seed[0] > nextLimits.seed[1] ||
|
| 826 |
nextLimits.guidance.length !== 2 || !nextLimits.guidance.every(Number.isFinite) || nextLimits.guidance[0] > nextLimits.guidance[1] ||
|
| 827 |
nextLimits.steps.length !== 2 || !nextLimits.steps.every(Number.isInteger) || nextLimits.steps[0] < 1 || nextLimits.steps[0] > nextLimits.steps[1] ||
|
| 828 |
-
|
| 829 |
) {
|
| 830 |
throw new Error("invalid_limits");
|
| 831 |
}
|
| 832 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 833 |
}
|
| 834 |
|
| 835 |
function applyLimits(nextLimits) {
|
|
@@ -851,7 +1191,11 @@ async function initialize() {
|
|
| 851 |
Client.connect(window.location.origin, { events: ["data", "status"] }),
|
| 852 |
]);
|
| 853 |
if (!configResponse.ok) throw new Error("config_unavailable");
|
| 854 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 855 |
client = connectedClient;
|
| 856 |
contractReady = true;
|
| 857 |
connDot.classList.remove("off", "pending");
|
|
@@ -860,6 +1204,8 @@ async function initialize() {
|
|
| 860 |
} catch (error) {
|
| 861 |
console.warn("[image-editor] initialization failed", { error_class: errorClass(error) });
|
| 862 |
contractReady = false;
|
|
|
|
|
|
|
| 863 |
connDot.classList.remove("pending");
|
| 864 |
connDot.classList.add("off");
|
| 865 |
connText.textContent = "offline";
|
|
|
|
| 1 |
import { Client } from "/hy3d-assets/gradio-client-2.3.1.js";
|
| 2 |
|
| 3 |
const GENERAL_MODE_ID = "__base__";
|
| 4 |
+
const EXPECTED_API_VERSION = "2026-08-12.3";
|
| 5 |
+
const EXPECTED_LORA_IDS = Object.freeze([
|
| 6 |
+
"Multiple-Angles",
|
| 7 |
+
"Photo-to-Anime",
|
| 8 |
+
"Anime-V2",
|
| 9 |
+
"Light-Migration",
|
| 10 |
+
"Upscaler",
|
| 11 |
+
"Style-Transfer",
|
| 12 |
+
"Manga-Tone",
|
| 13 |
+
"Anything2Real",
|
| 14 |
+
"Fal-Multiple-Angles",
|
| 15 |
+
"Polaroid-Photo",
|
| 16 |
+
"Unblur-Anything",
|
| 17 |
+
"Midnight-Noir-Eyes-Spotlight",
|
| 18 |
+
"Hyper-Realistic-Portrait",
|
| 19 |
+
"Ultra-Realistic-Portrait",
|
| 20 |
+
"Pixar-Inspired-3D",
|
| 21 |
+
"Noir-Comic-Book",
|
| 22 |
+
"Any-light",
|
| 23 |
+
"Studio-DeLight",
|
| 24 |
+
"Cinematic-FlatLog",
|
| 25 |
+
]);
|
| 26 |
const MAX_HISTORY_ITEMS = 6;
|
| 27 |
const FALLBACK_LIMITS = Object.freeze({
|
| 28 |
+
input_images: 2,
|
| 29 |
+
min_input_images: 1,
|
| 30 |
+
max_input_images: 2,
|
| 31 |
mime_types: ["image/jpeg", "image/png", "image/webp"],
|
| 32 |
max_image_bytes: 10 * 1024 * 1024,
|
| 33 |
+
max_total_image_bytes: 20 * 1024 * 1024,
|
| 34 |
max_image_pixels: 16_000_000,
|
| 35 |
+
max_total_image_pixels: 32_000_000,
|
| 36 |
min_image_side: 64,
|
| 37 |
max_prompt_chars: 1000,
|
| 38 |
seed: [0, 2147483647],
|
|
|
|
| 56 |
});
|
| 57 |
|
| 58 |
const SUGGESTIONS = Object.freeze([
|
| 59 |
+
["Anime", "Transform into anime."],
|
| 60 |
+
["B&W", "Convert it to black and white."],
|
| 61 |
+
["Cinematic", "Add cinematic lighting with warm orange tones and film grain."],
|
| 62 |
+
["Oil Paint", "Apply oil painting effect with visible brush strokes."],
|
| 63 |
+
["Upscale / Enhance", "Enhance clarity and fine detail while preserving the original composition."],
|
| 64 |
+
["Watercolor", "Make it look like a watercolor painting with soft edges."],
|
| 65 |
+
["Pencil Sketch", "Convert to detailed pencil sketch with cross-hatching and shading."],
|
| 66 |
+
["Pop Art", "Apply pop art style with bold colors and halftone patterns."],
|
| 67 |
+
["Vintage Retro", "Apply a vintage retro film look with faded colors and light leaks."],
|
| 68 |
+
["Neon Glow", "Add neon glow effects with vibrant colors against a dark background."],
|
| 69 |
+
["Pixel Art", "Convert to pixel art style with a retro 16-bit aesthetic."],
|
| 70 |
+
["Noir Comic", "Transform into a noir comic book style."],
|
| 71 |
+
["HyperReal", "Transform into a hyper-realistic face portrait."],
|
| 72 |
+
["Unblur", "Unblur and enhance image clarity."],
|
| 73 |
+
["Pixar 3D", "Transform into Pixar-inspired 3D."],
|
| 74 |
+
["Manga Tone", "Paint with manga tone."],
|
| 75 |
]);
|
| 76 |
|
| 77 |
const galleryGrid = document.getElementById("image-gallery-grid");
|
|
|
|
| 82 |
const btnClear = document.getElementById("tb-clear");
|
| 83 |
const promptInput = document.getElementById("custom-prompt-input");
|
| 84 |
const charCount = document.getElementById("char-count");
|
| 85 |
+
const loraSelect = document.getElementById("custom-lora-select");
|
| 86 |
const runBtn = document.getElementById("custom-run-btn");
|
| 87 |
const runBtnLabel = document.getElementById("run-btn-label");
|
| 88 |
const cancelBtn = document.getElementById("custom-cancel-btn");
|
|
|
|
| 122 |
const connDot = document.getElementById("conn-dot");
|
| 123 |
const connText = document.getElementById("conn-text");
|
| 124 |
const suggestionsWrap = document.getElementById("suggestions-wrap");
|
| 125 |
+
const tabEdit = document.getElementById("tab-edit");
|
| 126 |
+
const tabExamples = document.getElementById("tab-examples");
|
| 127 |
+
const pageEdit = document.getElementById("page-edit");
|
| 128 |
+
const pageExamples = document.getElementById("page-examples");
|
| 129 |
+
const examplesScroll = document.getElementById("examples-scroll");
|
| 130 |
+
const sbEditMode = document.getElementById("sb-edit-mode");
|
| 131 |
|
| 132 |
let client = null;
|
| 133 |
let limits = FALLBACK_LIMITS;
|
| 134 |
let contractReady = false;
|
| 135 |
+
let enabledEditModes = new Set();
|
| 136 |
+
let images = [];
|
| 137 |
+
let selectedImageIndex = -1;
|
| 138 |
let currentResult = null;
|
| 139 |
let history = [];
|
| 140 |
let historyIndex = -1;
|
|
|
|
| 147 |
let cancellationRequested = false;
|
| 148 |
let activeRunId = 0;
|
| 149 |
let selectionId = 0;
|
| 150 |
+
let exampleLoadId = 0;
|
| 151 |
+
let loadingExample = false;
|
| 152 |
+
let pendingUpload = { mode: "add", index: -1 };
|
| 153 |
let toastTimer = null;
|
| 154 |
|
| 155 |
function errorClass(error) {
|
|
|
|
| 201 |
}
|
| 202 |
|
| 203 |
function updateRunButton() {
|
| 204 |
+
const validInputCount = images.length >= limits.min_input_images && images.length <= limits.max_input_images;
|
| 205 |
+
runBtn.disabled = !contractReady || running || loadingExample || !validInputCount || !enabledEditModes.has(loraSelect.value);
|
| 206 |
}
|
| 207 |
|
| 208 |
function flashPromptError() {
|
|
|
|
| 222 |
compareBtn.setAttribute("aria-pressed", "false");
|
| 223 |
compareWrap.classList.remove("visible");
|
| 224 |
const canvasImage = getCanvasImage();
|
| 225 |
+
if (canvasImage && (currentResult || images.length)) canvasImage.style.display = "";
|
| 226 |
}
|
| 227 |
|
| 228 |
function updateRailState() {
|
| 229 |
const hasResult = Boolean(currentResult);
|
| 230 |
+
const locked = running || loadingExample;
|
| 231 |
+
btnUpload.disabled = locked || !contractReady;
|
| 232 |
+
dlBtn.disabled = locked || !hasResult;
|
| 233 |
+
useAsInputBtn.disabled = locked || !hasResult;
|
| 234 |
+
compareBtn.disabled = locked || !(hasResult && currentResult.beforeB64);
|
| 235 |
+
zoomBtn.disabled = locked || !(hasResult || images.length);
|
| 236 |
+
btnRemove.disabled = locked || selectedImageIndex < 0;
|
| 237 |
+
btnClear.disabled = locked || !images.length;
|
| 238 |
}
|
| 239 |
|
| 240 |
function renderCanvas() {
|
| 241 |
exitCompareMode();
|
| 242 |
let canvasImage = getCanvasImage();
|
| 243 |
+
const selectedInput = selectedImageIndex >= 0 ? images[selectedImageIndex] : images[0];
|
| 244 |
+
const shown = viewMode === "result" ? currentResult : selectedInput;
|
| 245 |
if (shown) {
|
| 246 |
outPh.style.display = "none";
|
| 247 |
if (!canvasImage) {
|
|
|
|
| 255 |
outBody.appendChild(canvasImage);
|
| 256 |
}
|
| 257 |
canvasImage.style.display = "";
|
| 258 |
+
canvasImage.src = viewMode === "result" ? currentResult.b64 : selectedInput.b64;
|
| 259 |
canvasMeta.textContent = viewMode === "result"
|
| 260 |
+
? `${currentResult.editMode || "result"} · seed ${currentResult.seed}`
|
| 261 |
+
: `input #${Math.max(0, selectedImageIndex) + 1} · ${selectedInput.name || "image"}`;
|
| 262 |
} else {
|
| 263 |
if (canvasImage) canvasImage.style.display = "none";
|
| 264 |
outPh.style.display = "";
|
| 265 |
+
canvasMeta.textContent = images.length
|
| 266 |
+
? `${images.length} input${images.length === 1 ? "" : "s"} ready — run an edit`
|
| 267 |
+
: "No image loaded";
|
| 268 |
}
|
| 269 |
updateRailState();
|
| 270 |
}
|
|
|
|
| 277 |
}
|
| 278 |
|
| 279 |
function updateCounts() {
|
| 280 |
+
imgCountTb.textContent = String(images.length);
|
| 281 |
+
imgCountSb.textContent = images.length
|
| 282 |
+
? `${images.length} input${images.length === 1 ? "" : "s"} · ${history.length} result${history.length === 1 ? "" : "s"}`
|
|
|
|
| 283 |
: "No inputs";
|
| 284 |
}
|
| 285 |
|
| 286 |
function renderInputStrip() {
|
| 287 |
galleryGrid.replaceChildren();
|
| 288 |
+
if (!images.length) {
|
| 289 |
const empty = document.createElement("div");
|
| 290 |
empty.className = "fs-empty";
|
| 291 |
+
empty.textContent = "Drop or paste up to two images…";
|
| 292 |
galleryGrid.appendChild(empty);
|
| 293 |
+
}
|
| 294 |
+
images.forEach((entry, index) => {
|
| 295 |
const thumb = document.createElement("div");
|
| 296 |
+
thumb.className = `fs-thumb${index === selectedImageIndex ? " selected" : ""}`;
|
| 297 |
+
const select = document.createElement("button");
|
| 298 |
+
select.type = "button";
|
| 299 |
+
select.className = "fs-thumb-select";
|
| 300 |
+
select.setAttribute("aria-label", `Select input image ${index + 1}`);
|
| 301 |
+
select.setAttribute("aria-pressed", String(index === selectedImageIndex));
|
| 302 |
const image = document.createElement("img");
|
| 303 |
+
image.src = entry.b64;
|
| 304 |
+
image.alt = `Input image ${index + 1}`;
|
| 305 |
const badge = document.createElement("span");
|
| 306 |
badge.className = "fs-badge";
|
| 307 |
+
badge.textContent = `#${index + 1}`;
|
| 308 |
const remove = document.createElement("button");
|
| 309 |
remove.className = "fs-remove";
|
| 310 |
remove.type = "button";
|
| 311 |
remove.textContent = "✕";
|
| 312 |
+
remove.setAttribute("aria-label", `Remove input image ${index + 1}`);
|
| 313 |
remove.addEventListener("click", (event) => {
|
| 314 |
event.stopPropagation();
|
| 315 |
+
removeInput(index);
|
| 316 |
});
|
| 317 |
+
select.append(image, badge);
|
| 318 |
+
select.addEventListener("click", () => {
|
| 319 |
+
selectedImageIndex = index;
|
| 320 |
+
renderInputStrip();
|
| 321 |
+
setViewMode("input");
|
| 322 |
+
});
|
| 323 |
+
thumb.append(select, remove);
|
| 324 |
galleryGrid.appendChild(thumb);
|
| 325 |
+
});
|
| 326 |
const add = document.createElement("button");
|
| 327 |
add.type = "button";
|
| 328 |
add.className = "fs-add";
|
| 329 |
+
const addIcon = document.createElement("span");
|
| 330 |
+
addIcon.className = "add-icon";
|
| 331 |
+
addIcon.textContent = "+";
|
| 332 |
+
const addText = document.createElement("span");
|
| 333 |
+
addText.className = "add-text";
|
| 334 |
+
addText.textContent = images.length < limits.max_input_images ? "Add" : "Replace";
|
| 335 |
+
add.append(addIcon, addText);
|
| 336 |
+
add.addEventListener("click", () => openFilePicker());
|
| 337 |
galleryGrid.appendChild(add);
|
| 338 |
updateRailState();
|
| 339 |
}
|
|
|
|
| 369 |
});
|
| 370 |
}
|
| 371 |
|
| 372 |
+
function setInputs(nextImages, selectedIndex = 0) {
|
| 373 |
selectionId += 1;
|
| 374 |
+
images = nextImages;
|
| 375 |
+
selectedImageIndex = images.length ? Math.max(0, Math.min(selectedIndex, images.length - 1)) : -1;
|
| 376 |
renderInputStrip();
|
| 377 |
updateCounts();
|
| 378 |
+
if (images.length) setViewMode("input");
|
| 379 |
+
else renderCanvas();
|
| 380 |
updateRunButton();
|
| 381 |
}
|
| 382 |
|
| 383 |
+
function removeInput(index) {
|
| 384 |
+
if (!Number.isInteger(index) || index < 0 || index >= images.length) return;
|
| 385 |
+
const nextImages = images.filter((_, itemIndex) => itemIndex !== index);
|
| 386 |
+
const nextSelected = nextImages.length ? Math.min(index, nextImages.length - 1) : -1;
|
| 387 |
+
setInputs(nextImages, nextSelected);
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
function clearInputs() {
|
| 391 |
+
fileInput.value = "";
|
| 392 |
+
setInputs([], -1);
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
function openFilePicker() {
|
| 396 |
+
if (running || loadingExample || !contractReady) return;
|
| 397 |
+
pendingUpload = images.length >= limits.max_input_images
|
| 398 |
+
? { mode: "replace", index: selectedImageIndex >= 0 ? selectedImageIndex : 0 }
|
| 399 |
+
: { mode: "add", index: -1 };
|
| 400 |
+
fileInput.click();
|
| 401 |
}
|
| 402 |
|
| 403 |
function readFileAsDataUrl(file) {
|
| 404 |
return new Promise((resolve, reject) => {
|
| 405 |
const reader = new FileReader();
|
| 406 |
+
reader.onload = () => typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("I2I_IMAGE_DECODE_FAILED"));
|
| 407 |
+
reader.onerror = () => reject(new Error("I2I_IMAGE_DECODE_FAILED"));
|
| 408 |
reader.readAsDataURL(file);
|
| 409 |
});
|
| 410 |
}
|
|
|
|
| 419 |
};
|
| 420 |
image.onerror = () => {
|
| 421 |
URL.revokeObjectURL(url);
|
| 422 |
+
reject(new Error("I2I_IMAGE_DECODE_FAILED"));
|
| 423 |
};
|
| 424 |
image.src = url;
|
| 425 |
});
|
| 426 |
}
|
| 427 |
|
| 428 |
+
function dataUrlByteLength(dataUrl) {
|
| 429 |
+
if (typeof dataUrl !== "string") return -1;
|
| 430 |
+
const separator = dataUrl.indexOf(",");
|
| 431 |
+
if (separator < 0) return -1;
|
| 432 |
+
const encoded = dataUrl.slice(separator + 1);
|
| 433 |
+
if (!encoded || encoded.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) return -1;
|
| 434 |
+
const padding = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0;
|
| 435 |
+
return (encoded.length / 4) * 3 - padding;
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
function decodeImageDataUrl(dataUrl) {
|
| 439 |
+
return new Promise((resolve, reject) => {
|
| 440 |
+
const image = new Image();
|
| 441 |
+
image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight });
|
| 442 |
+
image.onerror = () => reject(new Error("I2I_IMAGE_DECODE_FAILED"));
|
| 443 |
+
image.src = dataUrl;
|
| 444 |
+
});
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
function validateDimensions(dimensions) {
|
| 448 |
+
const pixels = dimensions.width * dimensions.height;
|
| 449 |
+
if (
|
| 450 |
+
!Number.isInteger(dimensions.width) || !Number.isInteger(dimensions.height) ||
|
| 451 |
+
Math.min(dimensions.width, dimensions.height) < limits.min_image_side ||
|
| 452 |
+
pixels > limits.max_image_pixels
|
| 453 |
+
) {
|
| 454 |
+
throw new Error("I2I_IMAGE_DIMENSIONS_INVALID");
|
| 455 |
+
}
|
| 456 |
+
return pixels;
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
function validateInputTotals(nextImages) {
|
| 460 |
+
const totalBytes = nextImages.reduce((sum, item) => sum + item.bytes, 0);
|
| 461 |
+
const totalPixels = nextImages.reduce((sum, item) => sum + item.pixels, 0);
|
| 462 |
+
if (totalBytes > limits.max_total_image_bytes) throw new Error("I2I_FILE_TOO_LARGE");
|
| 463 |
+
if (totalPixels > limits.max_total_image_pixels) throw new Error("I2I_IMAGE_DIMENSIONS_INVALID");
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
async function inputFromFile(file) {
|
| 467 |
+
if (!limits.mime_types.includes(file.type)) throw new Error("I2I_UNSUPPORTED_TYPE");
|
| 468 |
+
if (file.size <= 0 || file.size > limits.max_image_bytes) throw new Error("I2I_FILE_TOO_LARGE");
|
| 469 |
+
const dimensions = await decodeImageFile(file);
|
| 470 |
+
const pixels = validateDimensions(dimensions);
|
| 471 |
+
const b64 = await readFileAsDataUrl(file);
|
| 472 |
+
return { b64, name: file.name || "image", bytes: file.size, pixels };
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
async function inputFromDataUrl(dataUrl, name) {
|
| 476 |
+
const header = typeof dataUrl === "string" ? dataUrl.slice(0, dataUrl.indexOf(",")) : "";
|
| 477 |
+
const mime = header.startsWith("data:") && header.endsWith(";base64") ? header.slice(5, -7) : "";
|
| 478 |
+
if (!limits.mime_types.includes(mime)) throw new Error("I2I_UNSUPPORTED_TYPE");
|
| 479 |
+
const bytes = dataUrlByteLength(dataUrl);
|
| 480 |
+
if (bytes <= 0 || bytes > limits.max_image_bytes) throw new Error("I2I_FILE_TOO_LARGE");
|
| 481 |
+
const dimensions = await decodeImageDataUrl(dataUrl);
|
| 482 |
+
const pixels = validateDimensions(dimensions);
|
| 483 |
+
return { b64: dataUrl, name: name || "image", bytes, pixels };
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
async function processFiles(fileList, action = { mode: "add", index: -1 }) {
|
| 487 |
+
const files = Array.from(fileList || []);
|
| 488 |
+
if (!files.length) return;
|
| 489 |
if (!contractReady) {
|
| 490 |
showToast("The editor is still connecting. Try again shortly.", "warning");
|
| 491 |
return;
|
| 492 |
}
|
| 493 |
+
if (running || loadingExample) {
|
| 494 |
+
showToast(running ? "Wait for the current edit to finish." : "Wait for the example to finish loading.", "warning");
|
| 495 |
return;
|
| 496 |
}
|
| 497 |
const localSelectionId = ++selectionId;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 498 |
try {
|
| 499 |
+
let nextImages;
|
| 500 |
+
let nextSelected;
|
| 501 |
+
if (action.mode === "replace") {
|
| 502 |
+
const replacement = await inputFromFile(files[0]);
|
| 503 |
+
if (localSelectionId !== selectionId) return;
|
| 504 |
+
const replaceIndex = Number.isInteger(action.index) && action.index >= 0 && action.index < images.length
|
| 505 |
+
? action.index
|
| 506 |
+
: 0;
|
| 507 |
+
nextImages = images.slice();
|
| 508 |
+
nextImages[replaceIndex] = replacement;
|
| 509 |
+
nextSelected = replaceIndex;
|
| 510 |
+
if (files.length > 1) showToast("One selected slot was replaced; extra files were not added.", "warning");
|
| 511 |
+
} else {
|
| 512 |
+
const remaining = limits.max_input_images - images.length;
|
| 513 |
+
if (remaining <= 0) {
|
| 514 |
+
showToast(`You can use up to ${limits.max_input_images} input images. Select a slot to replace it.`, "warning");
|
| 515 |
+
return;
|
| 516 |
+
}
|
| 517 |
+
const acceptedFiles = files.slice(0, remaining);
|
| 518 |
+
const additions = await Promise.all(acceptedFiles.map(inputFromFile));
|
| 519 |
+
if (localSelectionId !== selectionId) return;
|
| 520 |
+
nextSelected = images.length;
|
| 521 |
+
nextImages = images.concat(additions);
|
| 522 |
+
if (files.length > acceptedFiles.length) {
|
| 523 |
+
showToast(`Only ${limits.max_input_images} input images are supported.`, "warning");
|
| 524 |
+
}
|
| 525 |
}
|
| 526 |
+
validateInputTotals(nextImages);
|
| 527 |
+
setInputs(nextImages, nextSelected);
|
| 528 |
+
showToast(`${nextImages.length} input image${nextImages.length === 1 ? "" : "s"} ready`, "info");
|
|
|
|
| 529 |
} catch (error) {
|
| 530 |
console.warn("[image-editor] input validation failed", { error_class: errorClass(error) });
|
| 531 |
+
const code = extractPublicErrorCode(error);
|
| 532 |
+
showToast(PUBLIC_ERROR_MESSAGES[code]);
|
| 533 |
}
|
| 534 |
}
|
| 535 |
|
| 536 |
fileInput.addEventListener("change", () => {
|
| 537 |
+
const files = fileInput.files;
|
| 538 |
+
void processFiles(files, pendingUpload);
|
| 539 |
fileInput.value = "";
|
| 540 |
+
pendingUpload = { mode: "add", index: -1 };
|
| 541 |
});
|
| 542 |
+
btnUpload.addEventListener("click", openFilePicker);
|
| 543 |
+
btnRemove.addEventListener("click", () => removeInput(selectedImageIndex));
|
| 544 |
+
btnClear.addEventListener("click", clearInputs);
|
| 545 |
|
| 546 |
let dragDepth = 0;
|
| 547 |
window.addEventListener("dragenter", (event) => {
|
|
|
|
| 564 |
dropOverlay.classList.remove("visible");
|
| 565 |
const files = event.dataTransfer && event.dataTransfer.files;
|
| 566 |
if (!files || !files.length) return;
|
| 567 |
+
void processFiles(files);
|
|
|
|
| 568 |
});
|
| 569 |
|
| 570 |
document.addEventListener("paste", (event) => {
|
|
|
|
| 572 |
if (event.target === promptInput) return;
|
| 573 |
const items = event.clipboardData && event.clipboardData.items;
|
| 574 |
if (!items) return;
|
| 575 |
+
const files = Array.from(items)
|
| 576 |
+
.filter((candidate) => candidate.kind === "file")
|
| 577 |
+
.map((candidate) => candidate.getAsFile())
|
| 578 |
+
.filter(Boolean);
|
| 579 |
+
if (!files.length) return;
|
| 580 |
event.preventDefault();
|
| 581 |
+
void processFiles(files);
|
| 582 |
});
|
| 583 |
|
| 584 |
+
function showInspectorPage(page) {
|
| 585 |
+
const showEdit = page === "edit";
|
| 586 |
+
tabEdit.classList.toggle("active", showEdit);
|
| 587 |
+
tabExamples.classList.toggle("active", !showEdit);
|
| 588 |
+
tabEdit.setAttribute("aria-selected", String(showEdit));
|
| 589 |
+
tabExamples.setAttribute("aria-selected", String(!showEdit));
|
| 590 |
+
pageEdit.classList.toggle("active", showEdit);
|
| 591 |
+
pageExamples.classList.toggle("active", !showEdit);
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
tabEdit.addEventListener("click", () => showInspectorPage("edit"));
|
| 595 |
+
tabExamples.addEventListener("click", () => showInspectorPage("examples"));
|
| 596 |
+
|
| 597 |
SUGGESTIONS.forEach(([label, prompt]) => {
|
| 598 |
const chip = document.createElement("button");
|
| 599 |
chip.type = "button";
|
|
|
|
| 616 |
updateRunButton();
|
| 617 |
});
|
| 618 |
|
| 619 |
+
loraSelect.addEventListener("change", () => {
|
| 620 |
+
sbEditMode.textContent = loraSelect.value === GENERAL_MODE_ID ? "General / No LoRA" : loraSelect.value;
|
| 621 |
+
updateRunButton();
|
| 622 |
+
});
|
| 623 |
+
|
| 624 |
+
function renderLoraOptions(loraIds) {
|
| 625 |
+
loraSelect.replaceChildren();
|
| 626 |
+
const generalOption = document.createElement("option");
|
| 627 |
+
generalOption.value = GENERAL_MODE_ID;
|
| 628 |
+
generalOption.textContent = "General / No LoRA";
|
| 629 |
+
loraSelect.appendChild(generalOption);
|
| 630 |
+
loraIds.forEach((loraId) => {
|
| 631 |
+
const option = document.createElement("option");
|
| 632 |
+
option.value = loraId;
|
| 633 |
+
option.textContent = loraId;
|
| 634 |
+
loraSelect.appendChild(option);
|
| 635 |
+
});
|
| 636 |
+
loraSelect.value = GENERAL_MODE_ID;
|
| 637 |
+
loraSelect.disabled = false;
|
| 638 |
+
sbEditMode.textContent = "General / No LoRA";
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
function renderExamples(examples) {
|
| 642 |
+
examplesScroll.replaceChildren();
|
| 643 |
+
examples.forEach((example) => {
|
| 644 |
+
const card = document.createElement("button");
|
| 645 |
+
card.type = "button";
|
| 646 |
+
card.className = "example-card";
|
| 647 |
+
card.setAttribute("aria-label", `Load ${example.lora} example with ${example.n_images} image${example.n_images === 1 ? "" : "s"}`);
|
| 648 |
+
|
| 649 |
+
const thumbnails = document.createElement("span");
|
| 650 |
+
thumbnails.className = "example-thumbs";
|
| 651 |
+
example.thumbs.forEach((source, imageIndex) => {
|
| 652 |
+
const thumbnail = document.createElement("img");
|
| 653 |
+
thumbnail.src = source;
|
| 654 |
+
thumbnail.alt = `${example.lora} example input ${imageIndex + 1}`;
|
| 655 |
+
thumbnail.loading = "lazy";
|
| 656 |
+
thumbnail.decoding = "async";
|
| 657 |
+
thumbnails.appendChild(thumbnail);
|
| 658 |
+
});
|
| 659 |
+
|
| 660 |
+
const meta = document.createElement("span");
|
| 661 |
+
meta.className = "example-meta";
|
| 662 |
+
const imageBadge = document.createElement("span");
|
| 663 |
+
imageBadge.className = "example-badge";
|
| 664 |
+
imageBadge.textContent = `${example.n_images} img${example.n_images === 1 ? "" : "s"}`;
|
| 665 |
+
const loraBadge = document.createElement("span");
|
| 666 |
+
loraBadge.className = "example-lora-badge";
|
| 667 |
+
loraBadge.textContent = example.lora;
|
| 668 |
+
meta.append(imageBadge, loraBadge);
|
| 669 |
+
|
| 670 |
+
const promptText = document.createElement("span");
|
| 671 |
+
promptText.className = "example-prompt-text";
|
| 672 |
+
promptText.textContent = example.prompt;
|
| 673 |
+
card.append(thumbnails, meta, promptText);
|
| 674 |
+
card.addEventListener("click", () => void loadExample(card, example.idx));
|
| 675 |
+
examplesScroll.appendChild(card);
|
| 676 |
+
});
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
function validateLoadedExample(data) {
|
| 680 |
+
if (
|
| 681 |
+
!data || data.status !== "ok" ||
|
| 682 |
+
!Array.isArray(data.images) || !Array.isArray(data.names) ||
|
| 683 |
+
data.images.length < limits.min_input_images || data.images.length > limits.max_input_images ||
|
| 684 |
+
data.names.length !== data.images.length ||
|
| 685 |
+
typeof data.prompt !== "string" || !data.prompt.trim() || Array.from(data.prompt.trim()).length > limits.max_prompt_chars ||
|
| 686 |
+
typeof data.lora !== "string" || !EXPECTED_LORA_IDS.includes(data.lora) ||
|
| 687 |
+
!data.names.every((name) => typeof name === "string" && /^[A-Za-z0-9._ -]{1,128}$/.test(name))
|
| 688 |
+
) {
|
| 689 |
+
throw new Error("invalid_example_payload");
|
| 690 |
+
}
|
| 691 |
+
return data;
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
async function loadExample(card, index) {
|
| 695 |
+
if (!contractReady || !client) {
|
| 696 |
+
showToast("The editor is not connected. Refresh the page.");
|
| 697 |
+
return;
|
| 698 |
+
}
|
| 699 |
+
if (running) {
|
| 700 |
+
showToast("Wait for the current edit to finish.", "warning");
|
| 701 |
+
return;
|
| 702 |
+
}
|
| 703 |
+
const loadId = ++exampleLoadId;
|
| 704 |
+
selectionId += 1;
|
| 705 |
+
loadingExample = true;
|
| 706 |
+
examplesScroll.querySelectorAll(".example-card").forEach((item) => { item.disabled = true; });
|
| 707 |
+
card.classList.add("loading");
|
| 708 |
+
setStatus("Loading example…");
|
| 709 |
+
updateRailState();
|
| 710 |
+
updateRunButton();
|
| 711 |
+
try {
|
| 712 |
+
const result = await client.predict("/load_example", { idx: index });
|
| 713 |
+
if (loadId !== exampleLoadId) return;
|
| 714 |
+
if (!result || !Array.isArray(result.data) || result.data.length !== 1) throw new Error("invalid_example_result");
|
| 715 |
+
const data = validateLoadedExample(result.data[0]);
|
| 716 |
+
const nextImages = await Promise.all(data.images.map((source, imageIndex) => inputFromDataUrl(source, data.names[imageIndex])));
|
| 717 |
+
if (loadId !== exampleLoadId) return;
|
| 718 |
+
validateInputTotals(nextImages);
|
| 719 |
+
|
| 720 |
+
promptInput.value = data.prompt.trim();
|
| 721 |
+
charCount.textContent = String(Array.from(promptInput.value).length);
|
| 722 |
+
suggestionsWrap.querySelectorAll(".suggestion-chip").forEach((item) => item.classList.remove("active"));
|
| 723 |
+
loraSelect.value = data.lora;
|
| 724 |
+
if (loraSelect.value !== data.lora) throw new Error("invalid_example_lora");
|
| 725 |
+
sbEditMode.textContent = data.lora;
|
| 726 |
+
setInputs(nextImages, 0);
|
| 727 |
+
showInspectorPage("edit");
|
| 728 |
+
setStatus("Ready");
|
| 729 |
+
showToast(`Example loaded — ${nextImages.length} image${nextImages.length === 1 ? "" : "s"}`, "info");
|
| 730 |
+
} catch (error) {
|
| 731 |
+
console.warn("[image-editor] example load failed", { error_class: errorClass(error) });
|
| 732 |
+
setStatus("Ready");
|
| 733 |
+
showToast("Could not load this example. Try again.");
|
| 734 |
+
} finally {
|
| 735 |
+
if (loadId === exampleLoadId) {
|
| 736 |
+
loadingExample = false;
|
| 737 |
+
examplesScroll.querySelectorAll(".example-card").forEach((item) => { item.disabled = false; });
|
| 738 |
+
card.classList.remove("loading");
|
| 739 |
+
updateRailState();
|
| 740 |
+
updateRunButton();
|
| 741 |
+
}
|
| 742 |
+
}
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
function bindSlider(slider) {
|
| 746 |
const value = document.getElementById(`${slider.id}-val`);
|
| 747 |
slider.addEventListener("input", () => { value.textContent = slider.value; });
|
|
|
|
| 806 |
reject(new Error("I2I_INVALID_OUTPUT"));
|
| 807 |
return;
|
| 808 |
}
|
| 809 |
+
resolve({ width: image.naturalWidth, height: image.naturalHeight });
|
| 810 |
};
|
| 811 |
image.onerror = () => reject(new Error("I2I_INVALID_OUTPUT"));
|
| 812 |
image.src = dataUrl;
|
|
|
|
| 909 |
if (event.key === "Escape") closeLightbox();
|
| 910 |
});
|
| 911 |
zoomBtn.addEventListener("click", () => {
|
| 912 |
+
const selectedInput = selectedImageIndex >= 0 ? images[selectedImageIndex] : images[0];
|
| 913 |
+
const source = viewMode === "result" && currentResult ? currentResult.b64 : selectedInput && selectedInput.b64;
|
| 914 |
if (!source) return;
|
| 915 |
openLightbox(source, zoomBtn);
|
| 916 |
});
|
|
|
|
| 923 |
link.click();
|
| 924 |
link.remove();
|
| 925 |
});
|
| 926 |
+
useAsInputBtn.addEventListener("click", async () => {
|
| 927 |
if (!currentResult) return;
|
| 928 |
+
try {
|
| 929 |
+
const nextInput = await inputFromDataUrl(currentResult.b64, `hy3d-image-edit-${currentResult.seed}.png`);
|
| 930 |
+
setInputs([nextInput], 0);
|
| 931 |
+
showToast("Result is ready for another edit", "info");
|
| 932 |
+
} catch (error) {
|
| 933 |
+
console.warn("[image-editor] result chaining failed", { error_class: errorClass(error) });
|
| 934 |
+
showToast(PUBLIC_ERROR_MESSAGES.I2I_INVALID_OUTPUT);
|
| 935 |
+
}
|
| 936 |
});
|
| 937 |
seedChip.addEventListener("click", () => {
|
| 938 |
if (!currentResult || !navigator.clipboard) return;
|
|
|
|
| 956 |
showToast("The editor is not connected. Refresh the page.");
|
| 957 |
return false;
|
| 958 |
}
|
| 959 |
+
if (images.length < limits.min_input_images || images.length > limits.max_input_images) {
|
| 960 |
+
showToast(`Upload ${limits.min_input_images === limits.max_input_images ? limits.min_input_images : `${limits.min_input_images}–${limits.max_input_images}`} input images first.`);
|
| 961 |
return false;
|
| 962 |
}
|
| 963 |
const prompt = promptInput.value.trim();
|
|
|
|
| 970 |
showToast(PUBLIC_ERROR_MESSAGES.I2I_PROMPT_TOO_LONG);
|
| 971 |
return false;
|
| 972 |
}
|
| 973 |
+
if (!enabledEditModes.has(loraSelect.value)) {
|
| 974 |
+
showToast(PUBLIC_ERROR_MESSAGES.I2I_LORA_NOT_ALLOWED);
|
| 975 |
+
return false;
|
| 976 |
+
}
|
| 977 |
return true;
|
| 978 |
}
|
| 979 |
|
| 980 |
async function runEdit() {
|
| 981 |
+
if (running || loadingExample || !validateBeforeRun()) return;
|
| 982 |
const runId = ++activeRunId;
|
| 983 |
cancellationRequested = false;
|
| 984 |
+
const inputSnapshot = images.map((item) => item.b64);
|
| 985 |
+
const beforeB64 = inputSnapshot[0];
|
| 986 |
const prompt = promptInput.value.trim();
|
| 987 |
+
const editMode = loraSelect.value;
|
| 988 |
let parsedResult = null;
|
| 989 |
setRunningUi(true, "preparing");
|
| 990 |
setViewMode("result");
|
|
|
|
| 992 |
setStatus("Queued");
|
| 993 |
|
| 994 |
const payload = {
|
| 995 |
+
images_b64_json: JSON.stringify(inputSnapshot),
|
| 996 |
prompt,
|
| 997 |
+
lora_adapter: editMode,
|
| 998 |
seed: Number.parseInt(seedSlider.value, 10),
|
| 999 |
randomize_seed: randomizeInput.checked,
|
| 1000 |
guidance_scale: Number.parseFloat(guidanceSlider.value),
|
|
|
|
| 1040 |
b64: output.image,
|
| 1041 |
seed: output.seed,
|
| 1042 |
prompt,
|
| 1043 |
+
editMode,
|
| 1044 |
beforeB64,
|
| 1045 |
};
|
| 1046 |
}
|
|
|
|
| 1103 |
function validateConfig(config) {
|
| 1104 |
if (
|
| 1105 |
!config ||
|
| 1106 |
+
config.api_version !== EXPECTED_API_VERSION ||
|
| 1107 |
+
config.edit_mode !== "general-and-lora" ||
|
| 1108 |
+
config.uses_lora !== true ||
|
| 1109 |
+
config.supports_general_mode !== true ||
|
| 1110 |
+
config.general_mode_value !== GENERAL_MODE_ID ||
|
| 1111 |
+
config.required_lora_adapter_value !== null ||
|
| 1112 |
+
config.default_edit_mode !== GENERAL_MODE_ID ||
|
| 1113 |
+
config.default_lora !== null ||
|
| 1114 |
config.content_moderation_enabled !== false ||
|
| 1115 |
!Array.isArray(config.loras) ||
|
| 1116 |
+
config.loras.length !== EXPECTED_LORA_IDS.length ||
|
| 1117 |
+
!EXPECTED_LORA_IDS.every((loraId, index) => config.loras[index] === loraId) ||
|
| 1118 |
+
!Array.isArray(config.enabled_edit_modes) ||
|
| 1119 |
+
config.enabled_edit_modes.length !== EXPECTED_LORA_IDS.length + 1 ||
|
| 1120 |
+
config.enabled_edit_modes[0] !== GENERAL_MODE_ID ||
|
| 1121 |
+
!EXPECTED_LORA_IDS.every((loraId, index) => config.enabled_edit_modes[index + 1] === loraId) ||
|
| 1122 |
+
!Array.isArray(config.examples) ||
|
| 1123 |
+
config.examples.length !== EXPECTED_LORA_IDS.length ||
|
| 1124 |
!config.limits
|
| 1125 |
) {
|
| 1126 |
throw new Error("contract_mismatch");
|
|
|
|
| 1131 |
!Array.isArray(nextLimits.mime_types) ||
|
| 1132 |
nextLimits.mime_types.length !== expectedMimes.length ||
|
| 1133 |
!expectedMimes.every((mime) => nextLimits.mime_types.includes(mime)) ||
|
| 1134 |
+
nextLimits.input_images !== 2 ||
|
| 1135 |
+
nextLimits.min_input_images !== 1 ||
|
| 1136 |
+
nextLimits.max_input_images !== 2 ||
|
| 1137 |
!Number.isInteger(nextLimits.max_image_bytes) || nextLimits.max_image_bytes <= 0 ||
|
| 1138 |
+
!Number.isInteger(nextLimits.max_total_image_bytes) || nextLimits.max_total_image_bytes !== nextLimits.max_image_bytes * 2 ||
|
| 1139 |
+
!Number.isInteger(nextLimits.max_request_json_chars) || nextLimits.max_request_json_chars <= nextLimits.max_total_image_bytes ||
|
| 1140 |
!Number.isInteger(nextLimits.max_image_pixels) || nextLimits.max_image_pixels <= 0 ||
|
| 1141 |
+
!Number.isInteger(nextLimits.max_total_image_pixels) || nextLimits.max_total_image_pixels !== nextLimits.max_image_pixels * 2 ||
|
| 1142 |
!Number.isInteger(nextLimits.min_image_side) || nextLimits.min_image_side <= 0 ||
|
| 1143 |
!Number.isInteger(nextLimits.max_prompt_chars) || nextLimits.max_prompt_chars <= 0 ||
|
| 1144 |
!Array.isArray(nextLimits.seed) ||
|
|
|
|
| 1147 |
nextLimits.seed.length !== 2 || !nextLimits.seed.every(Number.isInteger) || nextLimits.seed[0] < 0 || nextLimits.seed[0] > nextLimits.seed[1] ||
|
| 1148 |
nextLimits.guidance.length !== 2 || !nextLimits.guidance.every(Number.isFinite) || nextLimits.guidance[0] > nextLimits.guidance[1] ||
|
| 1149 |
nextLimits.steps.length !== 2 || !nextLimits.steps.every(Number.isInteger) || nextLimits.steps[0] < 1 || nextLimits.steps[0] > nextLimits.steps[1] ||
|
| 1150 |
+
nextLimits.max_output_side !== 1024
|
| 1151 |
) {
|
| 1152 |
throw new Error("invalid_limits");
|
| 1153 |
}
|
| 1154 |
+
|
| 1155 |
+
const seenExampleLoras = new Set();
|
| 1156 |
+
config.examples.forEach((example, index) => {
|
| 1157 |
+
if (
|
| 1158 |
+
!example || example.idx !== index ||
|
| 1159 |
+
!Number.isInteger(example.n_images) || example.n_images < nextLimits.min_input_images || example.n_images > nextLimits.max_input_images ||
|
| 1160 |
+
!Array.isArray(example.thumbs) || example.thumbs.length !== example.n_images ||
|
| 1161 |
+
!example.thumbs.every((source) => typeof source === "string" && /^data:image\/jpeg;base64,[A-Za-z0-9+/]+={0,2}$/.test(source)) ||
|
| 1162 |
+
typeof example.prompt !== "string" || !example.prompt.trim() || Array.from(example.prompt.trim()).length > nextLimits.max_prompt_chars ||
|
| 1163 |
+
typeof example.lora !== "string" || !EXPECTED_LORA_IDS.includes(example.lora)
|
| 1164 |
+
) {
|
| 1165 |
+
throw new Error("invalid_examples");
|
| 1166 |
+
}
|
| 1167 |
+
seenExampleLoras.add(example.lora);
|
| 1168 |
+
});
|
| 1169 |
+
if (seenExampleLoras.size !== EXPECTED_LORA_IDS.length || !EXPECTED_LORA_IDS.every((loraId) => seenExampleLoras.has(loraId))) {
|
| 1170 |
+
throw new Error("invalid_examples");
|
| 1171 |
+
}
|
| 1172 |
+
return config;
|
| 1173 |
}
|
| 1174 |
|
| 1175 |
function applyLimits(nextLimits) {
|
|
|
|
| 1191 |
Client.connect(window.location.origin, { events: ["data", "status"] }),
|
| 1192 |
]);
|
| 1193 |
if (!configResponse.ok) throw new Error("config_unavailable");
|
| 1194 |
+
const config = validateConfig(await configResponse.json());
|
| 1195 |
+
applyLimits(config.limits);
|
| 1196 |
+
enabledEditModes = new Set(config.enabled_edit_modes);
|
| 1197 |
+
renderLoraOptions(config.loras);
|
| 1198 |
+
renderExamples(config.examples);
|
| 1199 |
client = connectedClient;
|
| 1200 |
contractReady = true;
|
| 1201 |
connDot.classList.remove("off", "pending");
|
|
|
|
| 1204 |
} catch (error) {
|
| 1205 |
console.warn("[image-editor] initialization failed", { error_class: errorClass(error) });
|
| 1206 |
contractReady = false;
|
| 1207 |
+
enabledEditModes = new Set();
|
| 1208 |
+
loraSelect.disabled = true;
|
| 1209 |
connDot.classList.remove("pending");
|
| 1210 |
connDot.classList.add("off");
|
| 1211 |
connText.textContent = "offline";
|
i2i_contract.py
CHANGED
|
@@ -19,14 +19,21 @@ from typing import Callable, Collection, Sequence
|
|
| 19 |
from PIL import Image, ImageOps, UnidentifiedImageError
|
| 20 |
|
| 21 |
|
| 22 |
-
API_VERSION = "2026-08-12.
|
| 23 |
CONTENT_MODERATION_ENABLED = False
|
| 24 |
BASE_EDIT_MODE_ID = "__base__"
|
| 25 |
|
| 26 |
-
|
|
|
|
| 27 |
MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
MAX_IMAGE_PIXELS = 16_000_000
|
|
|
|
| 30 |
MIN_IMAGE_SIDE = 64
|
| 31 |
MAX_OUTPUT_SIDE = 1024
|
| 32 |
MAX_PROMPT_CHARS = 1000
|
|
@@ -86,6 +93,79 @@ class ValidatedEditRequest:
|
|
| 86 |
steps: int
|
| 87 |
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
def public_error_text(code: str) -> str:
|
| 90 |
"""Return a stable client-safe code and message."""
|
| 91 |
|
|
@@ -191,9 +271,35 @@ def _decode_images(images_b64_json: str) -> tuple[ValidatedInputImage, ...]:
|
|
| 191 |
values = json.loads(images_b64_json)
|
| 192 |
except (json.JSONDecodeError, TypeError):
|
| 193 |
raise ContractError("I2I_BAD_REQUEST") from None
|
| 194 |
-
if not isinstance(values, list) or len(values)
|
| 195 |
raise ContractError("I2I_BAD_REQUEST")
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
|
| 199 |
def validate_edit_request(
|
|
|
|
| 19 |
from PIL import Image, ImageOps, UnidentifiedImageError
|
| 20 |
|
| 21 |
|
| 22 |
+
API_VERSION = "2026-08-12.3"
|
| 23 |
CONTENT_MODERATION_ENABLED = False
|
| 24 |
BASE_EDIT_MODE_ID = "__base__"
|
| 25 |
|
| 26 |
+
MIN_INPUT_IMAGES = 1
|
| 27 |
+
MAX_INPUT_IMAGES = 2
|
| 28 |
MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
| 29 |
+
MAX_TOTAL_IMAGE_BYTES = MAX_IMAGE_BYTES * MAX_INPUT_IMAGES
|
| 30 |
+
# Bound the JSON string before parsing or allocating decoded byte buffers. The
|
| 31 |
+
# fixed allowance covers two data-URL headers, JSON quoting/separators, and a
|
| 32 |
+
# small amount of harmless whitespace while each encoded image remains subject
|
| 33 |
+
# to its own exact limit below.
|
| 34 |
+
MAX_REQUEST_JSON_CHARS = MAX_INPUT_IMAGES * (4 * math.ceil(MAX_IMAGE_BYTES / 3) + 64) + 256
|
| 35 |
MAX_IMAGE_PIXELS = 16_000_000
|
| 36 |
+
MAX_TOTAL_IMAGE_PIXELS = MAX_IMAGE_PIXELS * MAX_INPUT_IMAGES
|
| 37 |
MIN_IMAGE_SIDE = 64
|
| 38 |
MAX_OUTPUT_SIDE = 1024
|
| 39 |
MAX_PROMPT_CHARS = 1000
|
|
|
|
| 93 |
steps: int
|
| 94 |
|
| 95 |
|
| 96 |
+
class SingleResidentAdapterManager:
|
| 97 |
+
"""Maintain a fail-closed, at-most-one-resident LoRA state machine.
|
| 98 |
+
|
| 99 |
+
The caller must serialize ``activate_mode`` with the same lock used for
|
| 100 |
+
inference. Marking a load dirty before mutation means a partial adapter
|
| 101 |
+
load is never treated as a clean base-model state.
|
| 102 |
+
"""
|
| 103 |
+
|
| 104 |
+
def __init__(
|
| 105 |
+
self,
|
| 106 |
+
*,
|
| 107 |
+
adapter_modes: Collection[str],
|
| 108 |
+
load_adapter: Callable[[str], None],
|
| 109 |
+
activate_adapter: Callable[[str], None],
|
| 110 |
+
unload_adapters: Callable[[], None],
|
| 111 |
+
):
|
| 112 |
+
self._adapter_modes = frozenset(adapter_modes)
|
| 113 |
+
self._load_adapter = load_adapter
|
| 114 |
+
self._activate_adapter = activate_adapter
|
| 115 |
+
self._unload_adapters = unload_adapters
|
| 116 |
+
self._resident_adapter_mode: str | None = None
|
| 117 |
+
self._dirty = False
|
| 118 |
+
|
| 119 |
+
@property
|
| 120 |
+
def resident_adapter_mode(self) -> str | None:
|
| 121 |
+
return self._resident_adapter_mode
|
| 122 |
+
|
| 123 |
+
@property
|
| 124 |
+
def dirty(self) -> bool:
|
| 125 |
+
return self._dirty
|
| 126 |
+
|
| 127 |
+
def _drop_resident_adapter(self) -> None:
|
| 128 |
+
if self._resident_adapter_mode is None and not self._dirty:
|
| 129 |
+
return
|
| 130 |
+
try:
|
| 131 |
+
self._unload_adapters()
|
| 132 |
+
except Exception:
|
| 133 |
+
self._resident_adapter_mode = None
|
| 134 |
+
self._dirty = True
|
| 135 |
+
raise
|
| 136 |
+
self._resident_adapter_mode = None
|
| 137 |
+
self._dirty = False
|
| 138 |
+
|
| 139 |
+
def activate_mode(self, edit_mode: str) -> None:
|
| 140 |
+
if edit_mode != BASE_EDIT_MODE_ID and edit_mode not in self._adapter_modes:
|
| 141 |
+
raise ContractError("I2I_LORA_NOT_ALLOWED")
|
| 142 |
+
if edit_mode == self._resident_adapter_mode and not self._dirty:
|
| 143 |
+
return
|
| 144 |
+
|
| 145 |
+
self._drop_resident_adapter()
|
| 146 |
+
if edit_mode == BASE_EDIT_MODE_ID:
|
| 147 |
+
return
|
| 148 |
+
|
| 149 |
+
# Loading can mutate the pipeline before raising, so assume the state
|
| 150 |
+
# is dirty until load + activation both complete.
|
| 151 |
+
self._dirty = True
|
| 152 |
+
try:
|
| 153 |
+
self._load_adapter(edit_mode)
|
| 154 |
+
self._activate_adapter(edit_mode)
|
| 155 |
+
except Exception:
|
| 156 |
+
try:
|
| 157 |
+
self._unload_adapters()
|
| 158 |
+
except Exception:
|
| 159 |
+
self._dirty = True
|
| 160 |
+
else:
|
| 161 |
+
self._dirty = False
|
| 162 |
+
self._resident_adapter_mode = None
|
| 163 |
+
raise
|
| 164 |
+
|
| 165 |
+
self._resident_adapter_mode = edit_mode
|
| 166 |
+
self._dirty = False
|
| 167 |
+
|
| 168 |
+
|
| 169 |
def public_error_text(code: str) -> str:
|
| 170 |
"""Return a stable client-safe code and message."""
|
| 171 |
|
|
|
|
| 271 |
values = json.loads(images_b64_json)
|
| 272 |
except (json.JSONDecodeError, TypeError):
|
| 273 |
raise ContractError("I2I_BAD_REQUEST") from None
|
| 274 |
+
if not isinstance(values, list) or not MIN_INPUT_IMAGES <= len(values) <= MAX_INPUT_IMAGES:
|
| 275 |
raise ContractError("I2I_BAD_REQUEST")
|
| 276 |
+
|
| 277 |
+
decoded_images: list[ValidatedInputImage] = []
|
| 278 |
+
total_bytes = 0
|
| 279 |
+
total_pixels = 0
|
| 280 |
+
for value in values:
|
| 281 |
+
image = _decode_image_data_url(value)
|
| 282 |
+
total_bytes += len(image.data)
|
| 283 |
+
if total_bytes > MAX_TOTAL_IMAGE_BYTES:
|
| 284 |
+
raise ContractError("I2I_FILE_TOO_LARGE")
|
| 285 |
+
total_pixels += image.width * image.height
|
| 286 |
+
if total_pixels > MAX_TOTAL_IMAGE_PIXELS:
|
| 287 |
+
raise ContractError("I2I_IMAGE_DIMENSIONS_INVALID")
|
| 288 |
+
decoded_images.append(image)
|
| 289 |
+
return tuple(decoded_images)
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def parse_example_index(value: object, total_examples: int) -> int | None:
|
| 293 |
+
"""Return a bounded integer example index, rejecting coercive values."""
|
| 294 |
+
|
| 295 |
+
if isinstance(total_examples, bool) or not isinstance(total_examples, int) or total_examples < 0:
|
| 296 |
+
raise ValueError("total_examples must be a non-negative integer")
|
| 297 |
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
| 298 |
+
return None
|
| 299 |
+
if isinstance(value, float) and (not math.isfinite(value) or not value.is_integer()):
|
| 300 |
+
return None
|
| 301 |
+
index = int(value)
|
| 302 |
+
return index if 0 <= index < total_examples else None
|
| 303 |
|
| 304 |
|
| 305 |
def validate_edit_request(
|
index.html
CHANGED
|
@@ -239,6 +239,11 @@ body{
|
|
| 239 |
@keyframes thumbIn{from{opacity:0;transform:scale(.92)}to{opacity:1;transform:scale(1)}}
|
| 240 |
.fs-thumb:hover{border-color:var(--border2);transform:translateY(-2px)}
|
| 241 |
.fs-thumb.selected{border-color:var(--accent);box-shadow:0 0 0 2px rgba(30,144,255,.25)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
.fs-thumb img{width:100%;height:100%;object-fit:cover}
|
| 243 |
.fs-badge{
|
| 244 |
position:absolute;bottom:4px;left:4px;background:rgba(0,0,0,.75);color:#fff;
|
|
@@ -311,6 +316,47 @@ body{
|
|
| 311 |
.suggestion-chip:hover{background:rgba(30,144,255,.15);border-color:rgba(30,144,255,.35);color:var(--accent2)}
|
| 312 |
.suggestion-chip.active{background:rgba(30,144,255,.25);border-color:var(--accent);color:#fff}
|
| 313 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
.btn-run{
|
| 315 |
display:flex;align-items:center;justify-content:center;gap:8px;width:100%;
|
| 316 |
background:linear-gradient(135deg,#1E90FF,#1873CC);border:none;border-radius:9px;
|
|
@@ -538,7 +584,7 @@ body{
|
|
| 538 |
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 23c-3.6 0-8-2.69-8-7.5 0-3.5 3-6.5 4.5-8 .27-.27.75-.08.75.28v2.44c0 .42.5.63.72.28C12.28 7.5 13 3 13 1c0-.42.48-.64.8-.35C18 4.5 20 9 20 12c0 5.5-3.5 11-8 11z"/></svg>
|
| 539 |
</div>
|
| 540 |
<div class="menubar-title">Hy3D Studio <span>/ Image Editor</span></div>
|
| 541 |
-
<span class="menubar-badge">GENERAL</span>
|
| 542 |
<span class="menubar-badge fast">4 STEPS DEFAULT</span>
|
| 543 |
<div class="menubar-spacer"></div>
|
| 544 |
<div class="menubar-status"><span class="dot pending" id="conn-dot"></span><span id="conn-text" role="status" aria-live="polite">connecting…</span></div>
|
|
@@ -578,12 +624,13 @@ body{
|
|
| 578 |
|
| 579 |
<!-- ══ Inspector ══ -->
|
| 580 |
<div class="inspector">
|
| 581 |
-
<div class="inspector-tabs">
|
| 582 |
-
<button class="insp-tab active" type="button">Edit</button>
|
|
|
|
| 583 |
</div>
|
| 584 |
|
| 585 |
<!-- Edit page -->
|
| 586 |
-
<div class="insp-page active" id="page-edit">
|
| 587 |
<div class="insp-section">
|
| 588 |
<div class="insp-section-title"><span>Instruction</span><span class="char-count" id="char-count">0</span></div>
|
| 589 |
<textarea id="custom-prompt-input" class="modern-textarea" rows="3" maxlength="1000"
|
|
@@ -595,6 +642,13 @@ body{
|
|
| 595 |
<div class="suggestions-wrap" id="suggestions-wrap"></div>
|
| 596 |
</div>
|
| 597 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 598 |
<div class="insp-section">
|
| 599 |
<button id="custom-run-btn" class="btn-run" disabled>
|
| 600 |
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 23c-3.6 0-8-2.69-8-7.5 0-3.5 3-6.5 4.5-8 .27-.27.75-.08.75.28v2.44c0 .42.5.63.72.28C12.28 7.5 13 3 13 1c0-.42.48-.64.8-.35C18 4.5 20 9 20 12c0 5.5-3.5 11-8 11z"/></svg>
|
|
@@ -632,6 +686,11 @@ body{
|
|
| 632 |
</div>
|
| 633 |
</details>
|
| 634 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 635 |
</div>
|
| 636 |
|
| 637 |
<!-- ══ Workspace ══ -->
|
|
@@ -662,7 +721,7 @@ body{
|
|
| 662 |
</svg>
|
| 663 |
<div class="cp-title">Canvas is empty</div>
|
| 664 |
<div class="cp-sub">
|
| 665 |
-
Drop
|
| 666 |
Then write an instruction and press <kbd>⌘/Ctrl</kbd>+<kbd>↵</kbd>.
|
| 667 |
</div>
|
| 668 |
</div>
|
|
@@ -693,14 +752,14 @@ body{
|
|
| 693 |
<div class="statusbar">
|
| 694 |
<div class="sb-section" id="sb-image-count">No inputs</div>
|
| 695 |
<div class="sb-right">
|
| 696 |
-
<span class="sb-link">General
|
| 697 |
<span class="sb-pill" id="sb-status" role="status" aria-live="polite" aria-atomic="true">Connecting…</span>
|
| 698 |
</div>
|
| 699 |
</div>
|
| 700 |
|
| 701 |
</div>
|
| 702 |
|
| 703 |
-
<input id="custom-file-input" type="file" accept="image/png,image/jpeg,image/webp" style="display:none;" />
|
| 704 |
|
| 705 |
<!-- Lightbox -->
|
| 706 |
<div class="lightbox" id="lightbox" role="dialog" aria-modal="true" aria-label="Image preview" aria-hidden="true" tabindex="-1">
|
|
@@ -709,7 +768,7 @@ body{
|
|
| 709 |
</div>
|
| 710 |
|
| 711 |
<!-- Global drop overlay -->
|
| 712 |
-
<div class="drop-overlay" id="drop-overlay"><div class="drop-overlay-inner">Drop
|
| 713 |
|
| 714 |
<script type="module" src="/hy3d-assets/app-ui.js"></script>
|
| 715 |
</body>
|
|
|
|
| 239 |
@keyframes thumbIn{from{opacity:0;transform:scale(.92)}to{opacity:1;transform:scale(1)}}
|
| 240 |
.fs-thumb:hover{border-color:var(--border2);transform:translateY(-2px)}
|
| 241 |
.fs-thumb.selected{border-color:var(--accent);box-shadow:0 0 0 2px rgba(30,144,255,.25)}
|
| 242 |
+
.fs-thumb-select{
|
| 243 |
+
display:block;width:100%;height:100%;padding:0;border:0;border-radius:inherit;
|
| 244 |
+
background:transparent;color:inherit;cursor:pointer;overflow:hidden;
|
| 245 |
+
}
|
| 246 |
+
.fs-thumb-select:focus-visible{outline:2px solid var(--accent2);outline-offset:-3px}
|
| 247 |
.fs-thumb img{width:100%;height:100%;object-fit:cover}
|
| 248 |
.fs-badge{
|
| 249 |
position:absolute;bottom:4px;left:4px;background:rgba(0,0,0,.75);color:#fff;
|
|
|
|
| 316 |
.suggestion-chip:hover{background:rgba(30,144,255,.15);border-color:rgba(30,144,255,.35);color:var(--accent2)}
|
| 317 |
.suggestion-chip.active{background:rgba(30,144,255,.25);border-color:var(--accent);color:#fff}
|
| 318 |
|
| 319 |
+
.lora-native-select{
|
| 320 |
+
width:100%;background:var(--panel2);
|
| 321 |
+
border:1px solid var(--border2);border-radius:8px;
|
| 322 |
+
padding:9px 34px 9px 13px;
|
| 323 |
+
font-family:'Inter',sans-serif;font-size:12px;font-weight:600;color:var(--text);
|
| 324 |
+
outline:none;appearance:none;-webkit-appearance:none;
|
| 325 |
+
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%231E90FF' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
| 326 |
+
background-repeat:no-repeat;background-position:right 11px center;
|
| 327 |
+
cursor:pointer;transition:border-color .2s;color-scheme:dark;
|
| 328 |
+
}
|
| 329 |
+
.lora-native-select:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(30,144,255,.15)}
|
| 330 |
+
.lora-native-select:disabled{opacity:.55;cursor:not-allowed}
|
| 331 |
+
.lora-native-select option{background:var(--panel);color:var(--text)}
|
| 332 |
+
|
| 333 |
+
/* Examples page */
|
| 334 |
+
.examples-list{padding:12px 14px;display:flex;flex-direction:column;gap:10px}
|
| 335 |
+
.example-card{
|
| 336 |
+
width:100%;text-align:left;background:var(--panel2);border:1px solid var(--border);
|
| 337 |
+
border-radius:10px;overflow:hidden;cursor:pointer;transition:all .2s ease;flex-shrink:0;
|
| 338 |
+
color:var(--text);font-family:'Inter',sans-serif;padding:0;
|
| 339 |
+
}
|
| 340 |
+
.example-card:hover:not(:disabled){border-color:var(--accent);box-shadow:0 4px 14px rgba(30,144,255,.15)}
|
| 341 |
+
.example-card:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
| 342 |
+
.example-card.loading,.example-card:disabled{opacity:.5;pointer-events:none}
|
| 343 |
+
.example-thumbs{display:flex;height:96px;overflow:hidden;background:var(--panel)}
|
| 344 |
+
.example-thumbs img{flex:1;object-fit:cover;min-width:0;border-bottom:1px solid var(--border)}
|
| 345 |
+
.example-meta{padding:6px 10px 2px;display:flex;align-items:center;gap:5px;flex-wrap:wrap}
|
| 346 |
+
.example-badge{
|
| 347 |
+
display:inline-flex;padding:2px 7px;background:rgba(30,144,255,.1);border-radius:4px;
|
| 348 |
+
font-size:9px;font-weight:600;color:var(--accent2);font-family:'JetBrains Mono',monospace;white-space:nowrap;
|
| 349 |
+
}
|
| 350 |
+
.example-lora-badge{
|
| 351 |
+
display:inline-flex;padding:2px 7px;background:rgba(255,255,255,.05);border-radius:4px;
|
| 352 |
+
font-size:9px;font-weight:600;color:var(--muted);font-family:'JetBrains Mono',monospace;
|
| 353 |
+
white-space:nowrap;border:1px solid var(--border);max-width:190px;overflow:hidden;text-overflow:ellipsis;
|
| 354 |
+
}
|
| 355 |
+
.example-prompt-text{
|
| 356 |
+
padding:4px 10px 9px;font-size:11px;color:var(--muted);line-height:1.45;
|
| 357 |
+
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
.btn-run{
|
| 361 |
display:flex;align-items:center;justify-content:center;gap:8px;width:100%;
|
| 362 |
background:linear-gradient(135deg,#1E90FF,#1873CC);border:none;border-radius:9px;
|
|
|
|
| 584 |
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 23c-3.6 0-8-2.69-8-7.5 0-3.5 3-6.5 4.5-8 .27-.27.75-.08.75.28v2.44c0 .42.5.63.72.28C12.28 7.5 13 3 13 1c0-.42.48-.64.8-.35C18 4.5 20 9 20 12c0 5.5-3.5 11-8 11z"/></svg>
|
| 585 |
</div>
|
| 586 |
<div class="menubar-title">Hy3D Studio <span>/ Image Editor</span></div>
|
| 587 |
+
<span class="menubar-badge">GENERAL + LORA</span>
|
| 588 |
<span class="menubar-badge fast">4 STEPS DEFAULT</span>
|
| 589 |
<div class="menubar-spacer"></div>
|
| 590 |
<div class="menubar-status"><span class="dot pending" id="conn-dot"></span><span id="conn-text" role="status" aria-live="polite">connecting…</span></div>
|
|
|
|
| 624 |
|
| 625 |
<!-- ══ Inspector ══ -->
|
| 626 |
<div class="inspector">
|
| 627 |
+
<div class="inspector-tabs" role="tablist" aria-label="Image editor panels">
|
| 628 |
+
<button class="insp-tab active" id="tab-edit" type="button" role="tab" aria-selected="true" aria-controls="page-edit">Edit</button>
|
| 629 |
+
<button class="insp-tab" id="tab-examples" type="button" role="tab" aria-selected="false" aria-controls="page-examples">Examples</button>
|
| 630 |
</div>
|
| 631 |
|
| 632 |
<!-- Edit page -->
|
| 633 |
+
<div class="insp-page active" id="page-edit" role="tabpanel" aria-labelledby="tab-edit">
|
| 634 |
<div class="insp-section">
|
| 635 |
<div class="insp-section-title"><span>Instruction</span><span class="char-count" id="char-count">0</span></div>
|
| 636 |
<textarea id="custom-prompt-input" class="modern-textarea" rows="3" maxlength="1000"
|
|
|
|
| 642 |
<div class="suggestions-wrap" id="suggestions-wrap"></div>
|
| 643 |
</div>
|
| 644 |
|
| 645 |
+
<div class="insp-section">
|
| 646 |
+
<div class="insp-section-title"><label for="custom-lora-select">Style / LoRA</label></div>
|
| 647 |
+
<select id="custom-lora-select" class="lora-native-select" disabled>
|
| 648 |
+
<option value="">Connecting…</option>
|
| 649 |
+
</select>
|
| 650 |
+
</div>
|
| 651 |
+
|
| 652 |
<div class="insp-section">
|
| 653 |
<button id="custom-run-btn" class="btn-run" disabled>
|
| 654 |
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 23c-3.6 0-8-2.69-8-7.5 0-3.5 3-6.5 4.5-8 .27-.27.75-.08.75.28v2.44c0 .42.5.63.72.28C12.28 7.5 13 3 13 1c0-.42.48-.64.8-.35C18 4.5 20 9 20 12c0 5.5-3.5 11-8 11z"/></svg>
|
|
|
|
| 686 |
</div>
|
| 687 |
</details>
|
| 688 |
</div>
|
| 689 |
+
|
| 690 |
+
<!-- Examples page -->
|
| 691 |
+
<div class="insp-page" id="page-examples" role="tabpanel" aria-labelledby="tab-examples">
|
| 692 |
+
<div class="examples-list" id="examples-scroll"></div>
|
| 693 |
+
</div>
|
| 694 |
</div>
|
| 695 |
|
| 696 |
<!-- ══ Workspace ══ -->
|
|
|
|
| 721 |
</svg>
|
| 722 |
<div class="cp-title">Canvas is empty</div>
|
| 723 |
<div class="cp-sub">
|
| 724 |
+
Drop up to two images anywhere, paste with <kbd>⌘/Ctrl+V</kbd>, or use the rail to upload.
|
| 725 |
Then write an instruction and press <kbd>⌘/Ctrl</kbd>+<kbd>↵</kbd>.
|
| 726 |
</div>
|
| 727 |
</div>
|
|
|
|
| 752 |
<div class="statusbar">
|
| 753 |
<div class="sb-section" id="sb-image-count">No inputs</div>
|
| 754 |
<div class="sb-right">
|
| 755 |
+
<span class="sb-link" id="sb-edit-mode">General / No LoRA</span>
|
| 756 |
<span class="sb-pill" id="sb-status" role="status" aria-live="polite" aria-atomic="true">Connecting…</span>
|
| 757 |
</div>
|
| 758 |
</div>
|
| 759 |
|
| 760 |
</div>
|
| 761 |
|
| 762 |
+
<input id="custom-file-input" type="file" accept="image/png,image/jpeg,image/webp" multiple style="display:none;" />
|
| 763 |
|
| 764 |
<!-- Lightbox -->
|
| 765 |
<div class="lightbox" id="lightbox" role="dialog" aria-modal="true" aria-label="Image preview" aria-hidden="true" tabindex="-1">
|
|
|
|
| 768 |
</div>
|
| 769 |
|
| 770 |
<!-- Global drop overlay -->
|
| 771 |
+
<div class="drop-overlay" id="drop-overlay"><div class="drop-overlay-inner">Drop up to two images to upload</div></div>
|
| 772 |
|
| 773 |
<script type="module" src="/hy3d-assets/app-ui.js"></script>
|
| 774 |
</body>
|
provenance.py
CHANGED
|
@@ -25,9 +25,8 @@ class AdapterSpec:
|
|
| 25 |
license_status: str
|
| 26 |
|
| 27 |
|
| 28 |
-
# These are provenance-pinned
|
| 29 |
-
# revision.
|
| 30 |
-
# candidate requires a later license/product decision and a code review.
|
| 31 |
CANDIDATE_ADAPTER_SPECS: dict[str, AdapterSpec] = {
|
| 32 |
"Multiple-Angles": AdapterSpec(
|
| 33 |
repo="dx8152/Qwen-Edit-2509-Multiple-angles",
|
|
@@ -165,12 +164,14 @@ CANDIDATE_ADAPTER_SPECS: dict[str, AdapterSpec] = {
|
|
| 165 |
}
|
| 166 |
|
| 167 |
|
| 168 |
-
#
|
| 169 |
-
#
|
| 170 |
-
|
|
|
|
| 171 |
ENABLED_ADAPTER_SPECS = {
|
| 172 |
adapter_id: CANDIDATE_ADAPTER_SPECS[adapter_id]
|
| 173 |
-
for adapter_id in
|
|
|
|
| 174 |
}
|
| 175 |
|
| 176 |
|
|
|
|
| 25 |
license_status: str
|
| 26 |
|
| 27 |
|
| 28 |
+
# These are the provenance-pinned adapters copied from the approved upstream
|
| 29 |
+
# revision. Runtime code may only load entries from this immutable allowlist.
|
|
|
|
| 30 |
CANDIDATE_ADAPTER_SPECS: dict[str, AdapterSpec] = {
|
| 31 |
"Multiple-Angles": AdapterSpec(
|
| 32 |
repo="dx8152/Qwen-Edit-2509-Multiple-angles",
|
|
|
|
| 164 |
}
|
| 165 |
|
| 166 |
|
| 167 |
+
# General editing remains available without an adapter, while all 19 pinned
|
| 168 |
+
# upstream adapters are explicitly enabled. The runtime keeps at most one LoRA
|
| 169 |
+
# resident at a time; this allowlist does not imply that all weights are loaded.
|
| 170 |
+
ENABLED_ADAPTER_IDS: frozenset[str] = frozenset(CANDIDATE_ADAPTER_SPECS)
|
| 171 |
ENABLED_ADAPTER_SPECS = {
|
| 172 |
adapter_id: CANDIDATE_ADAPTER_SPECS[adapter_id]
|
| 173 |
+
for adapter_id in CANDIDATE_ADAPTER_SPECS
|
| 174 |
+
if adapter_id in ENABLED_ADAPTER_IDS
|
| 175 |
}
|
| 176 |
|
| 177 |
|
tests/test_i2i_contract.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import base64
|
| 2 |
import json
|
| 3 |
import struct
|
|
@@ -6,26 +7,36 @@ import time
|
|
| 6 |
import unittest
|
| 7 |
import zlib
|
| 8 |
from io import BytesIO
|
|
|
|
|
|
|
| 9 |
|
| 10 |
from PIL import Image, features
|
|
|
|
| 11 |
|
| 12 |
from i2i_contract import (
|
| 13 |
BASE_EDIT_MODE_ID,
|
| 14 |
ContractError,
|
| 15 |
MAX_IMAGE_BYTES,
|
|
|
|
| 16 |
MAX_PROMPT_CHARS,
|
| 17 |
MAX_REQUEST_JSON_CHARS,
|
| 18 |
MAX_SEED,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
decode_validated_images,
|
| 20 |
encode_png_result,
|
| 21 |
execute_validated_edit,
|
| 22 |
output_dimensions,
|
|
|
|
| 23 |
serialize_png_candidate,
|
| 24 |
validate_edit_request,
|
| 25 |
)
|
| 26 |
|
| 27 |
|
| 28 |
-
ALLOWED_EDIT_MODES = {BASE_EDIT_MODE_ID}
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
def image_bytes(fmt="PNG", size=(128, 96), color=(20, 40, 60)):
|
|
@@ -77,6 +88,15 @@ class ContractTests(unittest.TestCase):
|
|
| 77 |
self.assertEqual(decoded[0].size, (128, 96))
|
| 78 |
self.assertEqual(output_dimensions(request.images[0]), (1024, 768))
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
def test_jpeg_and_webp_are_accepted(self):
|
| 81 |
jpeg = json.dumps([data_url(image_bytes("JPEG"), "image/jpeg")])
|
| 82 |
self.assertEqual(validate_edit_request(**request_args(images_b64_json=jpeg)).images[0].mime, "image/jpeg")
|
|
@@ -88,8 +108,8 @@ class ContractTests(unittest.TestCase):
|
|
| 88 |
self.assert_code("I2I_BAD_REQUEST", images_b64_json="not-json")
|
| 89 |
self.assert_code("I2I_BAD_REQUEST", images_b64_json="{}")
|
| 90 |
self.assert_code("I2I_BAD_REQUEST", images_b64_json="[]")
|
| 91 |
-
|
| 92 |
-
self.assert_code("I2I_BAD_REQUEST", images_b64_json=
|
| 93 |
self.assert_code("I2I_BAD_REQUEST", images_b64_json=json.dumps([123]))
|
| 94 |
|
| 95 |
def test_mime_base64_and_magic_are_strict(self):
|
|
@@ -108,6 +128,16 @@ class ContractTests(unittest.TestCase):
|
|
| 108 |
)
|
| 109 |
self.assert_code("I2I_FILE_TOO_LARGE", images_b64_json=" " * (MAX_REQUEST_JSON_CHARS + 1))
|
| 110 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
def test_dimensions_are_bounded(self):
|
| 112 |
small = json.dumps([data_url(image_bytes(size=(63, 128)))])
|
| 113 |
self.assert_code("I2I_IMAGE_DIMENSIONS_INVALID", images_b64_json=small)
|
|
@@ -120,7 +150,10 @@ class ContractTests(unittest.TestCase):
|
|
| 120 |
self.assert_code("I2I_PROMPT_REQUIRED", prompt=" ")
|
| 121 |
self.assert_code("I2I_PROMPT_TOO_LONG", prompt="x" * (MAX_PROMPT_CHARS + 1))
|
| 122 |
self.assert_code("I2I_LORA_NOT_ALLOWED", lora_adapter="unknown")
|
| 123 |
-
self.
|
|
|
|
|
|
|
|
|
|
| 124 |
self.assert_code("I2I_BAD_REQUEST", seed=True)
|
| 125 |
self.assert_code("I2I_BAD_REQUEST", seed=-1)
|
| 126 |
self.assert_code("I2I_BAD_REQUEST", seed=MAX_SEED + 1)
|
|
@@ -188,6 +221,228 @@ class ContractTests(unittest.TestCase):
|
|
| 188 |
worker.join()
|
| 189 |
self.assertEqual(max_active, 1)
|
| 190 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
def test_output_contract_rejects_non_png_bad_dimensions_and_seed(self):
|
| 192 |
with self.assertRaises(ContractError) as caught:
|
| 193 |
encode_png_result(image_bytes("JPEG"), 1)
|
|
|
|
| 1 |
+
import ast
|
| 2 |
import base64
|
| 3 |
import json
|
| 4 |
import struct
|
|
|
|
| 7 |
import unittest
|
| 8 |
import zlib
|
| 9 |
from io import BytesIO
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from unittest.mock import patch
|
| 12 |
|
| 13 |
from PIL import Image, features
|
| 14 |
+
from provenance import ENABLED_ADAPTER_SPECS
|
| 15 |
|
| 16 |
from i2i_contract import (
|
| 17 |
BASE_EDIT_MODE_ID,
|
| 18 |
ContractError,
|
| 19 |
MAX_IMAGE_BYTES,
|
| 20 |
+
MAX_INPUT_IMAGES,
|
| 21 |
MAX_PROMPT_CHARS,
|
| 22 |
MAX_REQUEST_JSON_CHARS,
|
| 23 |
MAX_SEED,
|
| 24 |
+
MAX_TOTAL_IMAGE_BYTES,
|
| 25 |
+
MAX_TOTAL_IMAGE_PIXELS,
|
| 26 |
+
MIN_INPUT_IMAGES,
|
| 27 |
+
SingleResidentAdapterManager,
|
| 28 |
decode_validated_images,
|
| 29 |
encode_png_result,
|
| 30 |
execute_validated_edit,
|
| 31 |
output_dimensions,
|
| 32 |
+
parse_example_index,
|
| 33 |
serialize_png_candidate,
|
| 34 |
validate_edit_request,
|
| 35 |
)
|
| 36 |
|
| 37 |
|
| 38 |
+
ALLOWED_EDIT_MODES = {BASE_EDIT_MODE_ID, "Anime-V2"}
|
| 39 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 40 |
|
| 41 |
|
| 42 |
def image_bytes(fmt="PNG", size=(128, 96), color=(20, 40, 60)):
|
|
|
|
| 88 |
self.assertEqual(decoded[0].size, (128, 96))
|
| 89 |
self.assertEqual(output_dimensions(request.images[0]), (1024, 768))
|
| 90 |
|
| 91 |
+
def test_one_or_two_images_are_accepted(self):
|
| 92 |
+
first = data_url(image_bytes(color=(1, 2, 3)))
|
| 93 |
+
second = data_url(image_bytes(color=(4, 5, 6)))
|
| 94 |
+
one = validate_edit_request(**request_args(images_b64_json=json.dumps([first])))
|
| 95 |
+
two = validate_edit_request(**request_args(images_b64_json=json.dumps([first, second])))
|
| 96 |
+
self.assertEqual(len(one.images), MIN_INPUT_IMAGES)
|
| 97 |
+
self.assertEqual(len(two.images), MAX_INPUT_IMAGES)
|
| 98 |
+
self.assertEqual([image.width for image in two.images], [128, 128])
|
| 99 |
+
|
| 100 |
def test_jpeg_and_webp_are_accepted(self):
|
| 101 |
jpeg = json.dumps([data_url(image_bytes("JPEG"), "image/jpeg")])
|
| 102 |
self.assertEqual(validate_edit_request(**request_args(images_b64_json=jpeg)).images[0].mime, "image/jpeg")
|
|
|
|
| 108 |
self.assert_code("I2I_BAD_REQUEST", images_b64_json="not-json")
|
| 109 |
self.assert_code("I2I_BAD_REQUEST", images_b64_json="{}")
|
| 110 |
self.assert_code("I2I_BAD_REQUEST", images_b64_json="[]")
|
| 111 |
+
three = json.dumps([data_url(image_bytes())] * 3)
|
| 112 |
+
self.assert_code("I2I_BAD_REQUEST", images_b64_json=three)
|
| 113 |
self.assert_code("I2I_BAD_REQUEST", images_b64_json=json.dumps([123]))
|
| 114 |
|
| 115 |
def test_mime_base64_and_magic_are_strict(self):
|
|
|
|
| 128 |
)
|
| 129 |
self.assert_code("I2I_FILE_TOO_LARGE", images_b64_json=" " * (MAX_REQUEST_JSON_CHARS + 1))
|
| 130 |
|
| 131 |
+
def test_total_byte_and_pixel_limits_are_enforced(self):
|
| 132 |
+
raw = image_bytes()
|
| 133 |
+
two = json.dumps([data_url(raw), data_url(raw)])
|
| 134 |
+
with patch("i2i_contract.MAX_TOTAL_IMAGE_BYTES", len(raw) * 2 - 1):
|
| 135 |
+
self.assert_code("I2I_FILE_TOO_LARGE", images_b64_json=two)
|
| 136 |
+
with patch("i2i_contract.MAX_TOTAL_IMAGE_PIXELS", (128 * 96 * 2) - 1):
|
| 137 |
+
self.assert_code("I2I_IMAGE_DIMENSIONS_INVALID", images_b64_json=two)
|
| 138 |
+
self.assertEqual(MAX_TOTAL_IMAGE_BYTES, MAX_IMAGE_BYTES * MAX_INPUT_IMAGES)
|
| 139 |
+
self.assertEqual(MAX_TOTAL_IMAGE_PIXELS, 16_000_000 * MAX_INPUT_IMAGES)
|
| 140 |
+
|
| 141 |
def test_dimensions_are_bounded(self):
|
| 142 |
small = json.dumps([data_url(image_bytes(size=(63, 128)))])
|
| 143 |
self.assert_code("I2I_IMAGE_DIMENSIONS_INVALID", images_b64_json=small)
|
|
|
|
| 150 |
self.assert_code("I2I_PROMPT_REQUIRED", prompt=" ")
|
| 151 |
self.assert_code("I2I_PROMPT_TOO_LONG", prompt="x" * (MAX_PROMPT_CHARS + 1))
|
| 152 |
self.assert_code("I2I_LORA_NOT_ALLOWED", lora_adapter="unknown")
|
| 153 |
+
self.assertEqual(
|
| 154 |
+
validate_edit_request(**request_args(lora_adapter="Anime-V2")).edit_mode,
|
| 155 |
+
"Anime-V2",
|
| 156 |
+
)
|
| 157 |
self.assert_code("I2I_BAD_REQUEST", seed=True)
|
| 158 |
self.assert_code("I2I_BAD_REQUEST", seed=-1)
|
| 159 |
self.assert_code("I2I_BAD_REQUEST", seed=MAX_SEED + 1)
|
|
|
|
| 221 |
worker.join()
|
| 222 |
self.assertEqual(max_active, 1)
|
| 223 |
|
| 224 |
+
def test_single_resident_adapter_state_machine(self):
|
| 225 |
+
events = []
|
| 226 |
+
manager = SingleResidentAdapterManager(
|
| 227 |
+
adapter_modes={"Anime-V2", "Style-Transfer"},
|
| 228 |
+
load_adapter=lambda mode: events.append(("load", mode)),
|
| 229 |
+
activate_adapter=lambda mode: events.append(("activate", mode)),
|
| 230 |
+
unload_adapters=lambda: events.append(("unload", None)),
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
manager.activate_mode(BASE_EDIT_MODE_ID)
|
| 234 |
+
self.assertEqual(events, [])
|
| 235 |
+
manager.activate_mode("Anime-V2")
|
| 236 |
+
manager.activate_mode("Anime-V2")
|
| 237 |
+
self.assertEqual(manager.resident_adapter_mode, "Anime-V2")
|
| 238 |
+
self.assertFalse(manager.dirty)
|
| 239 |
+
manager.activate_mode("Style-Transfer")
|
| 240 |
+
manager.activate_mode(BASE_EDIT_MODE_ID)
|
| 241 |
+
self.assertEqual(
|
| 242 |
+
events,
|
| 243 |
+
[
|
| 244 |
+
("load", "Anime-V2"),
|
| 245 |
+
("activate", "Anime-V2"),
|
| 246 |
+
("unload", None),
|
| 247 |
+
("load", "Style-Transfer"),
|
| 248 |
+
("activate", "Style-Transfer"),
|
| 249 |
+
("unload", None),
|
| 250 |
+
],
|
| 251 |
+
)
|
| 252 |
+
self.assertIsNone(manager.resident_adapter_mode)
|
| 253 |
+
self.assertFalse(manager.dirty)
|
| 254 |
+
with self.assertRaises(ContractError) as caught:
|
| 255 |
+
manager.activate_mode("unknown")
|
| 256 |
+
self.assertEqual(caught.exception.code, "I2I_LORA_NOT_ALLOWED")
|
| 257 |
+
|
| 258 |
+
def test_partial_adapter_load_is_cleaned_before_retry(self):
|
| 259 |
+
events = []
|
| 260 |
+
|
| 261 |
+
def load(mode):
|
| 262 |
+
events.append(("load", mode))
|
| 263 |
+
if mode == "Style-Transfer":
|
| 264 |
+
raise RuntimeError("synthetic load failure")
|
| 265 |
+
|
| 266 |
+
manager = SingleResidentAdapterManager(
|
| 267 |
+
adapter_modes={"Anime-V2", "Style-Transfer"},
|
| 268 |
+
load_adapter=load,
|
| 269 |
+
activate_adapter=lambda mode: events.append(("activate", mode)),
|
| 270 |
+
unload_adapters=lambda: events.append(("unload", None)),
|
| 271 |
+
)
|
| 272 |
+
manager.activate_mode("Anime-V2")
|
| 273 |
+
with self.assertRaises(RuntimeError):
|
| 274 |
+
manager.activate_mode("Style-Transfer")
|
| 275 |
+
self.assertIsNone(manager.resident_adapter_mode)
|
| 276 |
+
self.assertFalse(manager.dirty)
|
| 277 |
+
self.assertEqual(events[-3:], [("unload", None), ("load", "Style-Transfer"), ("unload", None)])
|
| 278 |
+
manager.activate_mode(BASE_EDIT_MODE_ID)
|
| 279 |
+
self.assertEqual(events[-1], ("unload", None))
|
| 280 |
+
|
| 281 |
+
def test_adapter_activation_failure_is_cleaned_before_next_mode(self):
|
| 282 |
+
events = []
|
| 283 |
+
fail_activation_once = True
|
| 284 |
+
|
| 285 |
+
def activate(mode):
|
| 286 |
+
nonlocal fail_activation_once
|
| 287 |
+
events.append(("activate", mode))
|
| 288 |
+
if fail_activation_once:
|
| 289 |
+
fail_activation_once = False
|
| 290 |
+
raise RuntimeError("synthetic activation failure")
|
| 291 |
+
|
| 292 |
+
manager = SingleResidentAdapterManager(
|
| 293 |
+
adapter_modes={"Anime-V2", "Style-Transfer"},
|
| 294 |
+
load_adapter=lambda mode: events.append(("load", mode)),
|
| 295 |
+
activate_adapter=activate,
|
| 296 |
+
unload_adapters=lambda: events.append(("unload", None)),
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
with self.assertRaises(RuntimeError):
|
| 300 |
+
manager.activate_mode("Anime-V2")
|
| 301 |
+
self.assertIsNone(manager.resident_adapter_mode)
|
| 302 |
+
self.assertFalse(manager.dirty)
|
| 303 |
+
self.assertEqual(
|
| 304 |
+
events,
|
| 305 |
+
[
|
| 306 |
+
("load", "Anime-V2"),
|
| 307 |
+
("activate", "Anime-V2"),
|
| 308 |
+
("unload", None),
|
| 309 |
+
],
|
| 310 |
+
)
|
| 311 |
+
|
| 312 |
+
manager.activate_mode("Style-Transfer")
|
| 313 |
+
self.assertEqual(manager.resident_adapter_mode, "Style-Transfer")
|
| 314 |
+
self.assertFalse(manager.dirty)
|
| 315 |
+
self.assertEqual(
|
| 316 |
+
events[-2:],
|
| 317 |
+
[("load", "Style-Transfer"), ("activate", "Style-Transfer")],
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
def test_failed_cleanup_stays_dirty_and_is_retried_before_base_mode(self):
|
| 321 |
+
events = []
|
| 322 |
+
fail_load_once = True
|
| 323 |
+
fail_unload_once = True
|
| 324 |
+
|
| 325 |
+
def load(mode):
|
| 326 |
+
nonlocal fail_load_once
|
| 327 |
+
events.append(("load", mode))
|
| 328 |
+
if fail_load_once:
|
| 329 |
+
fail_load_once = False
|
| 330 |
+
raise RuntimeError("synthetic load failure")
|
| 331 |
+
|
| 332 |
+
def unload():
|
| 333 |
+
nonlocal fail_unload_once
|
| 334 |
+
events.append(("unload", None))
|
| 335 |
+
if fail_unload_once:
|
| 336 |
+
fail_unload_once = False
|
| 337 |
+
raise RuntimeError("synthetic cleanup failure")
|
| 338 |
+
|
| 339 |
+
manager = SingleResidentAdapterManager(
|
| 340 |
+
adapter_modes={"Anime-V2"},
|
| 341 |
+
load_adapter=load,
|
| 342 |
+
activate_adapter=lambda mode: events.append(("activate", mode)),
|
| 343 |
+
unload_adapters=unload,
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
with self.assertRaises(RuntimeError):
|
| 347 |
+
manager.activate_mode("Anime-V2")
|
| 348 |
+
self.assertIsNone(manager.resident_adapter_mode)
|
| 349 |
+
self.assertTrue(manager.dirty)
|
| 350 |
+
|
| 351 |
+
manager.activate_mode(BASE_EDIT_MODE_ID)
|
| 352 |
+
self.assertIsNone(manager.resident_adapter_mode)
|
| 353 |
+
self.assertFalse(manager.dirty)
|
| 354 |
+
self.assertEqual(
|
| 355 |
+
events,
|
| 356 |
+
[
|
| 357 |
+
("load", "Anime-V2"),
|
| 358 |
+
("unload", None),
|
| 359 |
+
("unload", None),
|
| 360 |
+
],
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
def test_switch_unload_failure_is_retried_before_loading_new_mode(self):
|
| 364 |
+
events = []
|
| 365 |
+
unload_calls = 0
|
| 366 |
+
|
| 367 |
+
def unload():
|
| 368 |
+
nonlocal unload_calls
|
| 369 |
+
unload_calls += 1
|
| 370 |
+
events.append(("unload", None))
|
| 371 |
+
if unload_calls == 1:
|
| 372 |
+
raise RuntimeError("synthetic unload failure")
|
| 373 |
+
|
| 374 |
+
manager = SingleResidentAdapterManager(
|
| 375 |
+
adapter_modes={"Anime-V2", "Style-Transfer"},
|
| 376 |
+
load_adapter=lambda mode: events.append(("load", mode)),
|
| 377 |
+
activate_adapter=lambda mode: events.append(("activate", mode)),
|
| 378 |
+
unload_adapters=unload,
|
| 379 |
+
)
|
| 380 |
+
|
| 381 |
+
manager.activate_mode("Anime-V2")
|
| 382 |
+
with self.assertRaises(RuntimeError):
|
| 383 |
+
manager.activate_mode("Style-Transfer")
|
| 384 |
+
self.assertIsNone(manager.resident_adapter_mode)
|
| 385 |
+
self.assertTrue(manager.dirty)
|
| 386 |
+
self.assertNotIn(("load", "Style-Transfer"), events)
|
| 387 |
+
|
| 388 |
+
manager.activate_mode("Style-Transfer")
|
| 389 |
+
self.assertEqual(manager.resident_adapter_mode, "Style-Transfer")
|
| 390 |
+
self.assertFalse(manager.dirty)
|
| 391 |
+
self.assertEqual(
|
| 392 |
+
events[-3:],
|
| 393 |
+
[
|
| 394 |
+
("unload", None),
|
| 395 |
+
("load", "Style-Transfer"),
|
| 396 |
+
("activate", "Style-Transfer"),
|
| 397 |
+
],
|
| 398 |
+
)
|
| 399 |
+
|
| 400 |
+
def test_example_index_requires_a_finite_bounded_integer(self):
|
| 401 |
+
self.assertEqual(parse_example_index(0, 19), 0)
|
| 402 |
+
self.assertEqual(parse_example_index(18.0, 19), 18)
|
| 403 |
+
for invalid in (True, "1", None, -1, 19, 1.5, float("nan"), float("inf")):
|
| 404 |
+
self.assertIsNone(parse_example_index(invalid, 19))
|
| 405 |
+
with self.assertRaises(ValueError):
|
| 406 |
+
parse_example_index(0, -1)
|
| 407 |
+
|
| 408 |
+
def test_fixed_example_manifest_matches_all_enabled_loras(self):
|
| 409 |
+
tree = ast.parse((ROOT / "app.py").read_text(encoding="utf-8"))
|
| 410 |
+
assignment = next(
|
| 411 |
+
node
|
| 412 |
+
for node in tree.body
|
| 413 |
+
if isinstance(node, ast.Assign)
|
| 414 |
+
and any(isinstance(target, ast.Name) and target.id == "EXAMPLES_CONFIG" for target in node.targets)
|
| 415 |
+
)
|
| 416 |
+
examples = ast.literal_eval(assignment.value)
|
| 417 |
+
self.assertEqual(len(examples), 19)
|
| 418 |
+
self.assertEqual({example["lora"] for example in examples}, set(ENABLED_ADAPTER_SPECS))
|
| 419 |
+
self.assertTrue(all(1 <= len(example["images"]) <= 2 for example in examples))
|
| 420 |
+
self.assertEqual(sum(len(example["images"]) == 2 for example in examples), 3)
|
| 421 |
+
for example in examples:
|
| 422 |
+
for relative_path in example["images"]:
|
| 423 |
+
path = (ROOT / relative_path).resolve()
|
| 424 |
+
self.assertTrue(path.is_relative_to((ROOT / "examples").resolve()))
|
| 425 |
+
self.assertTrue(path.is_file())
|
| 426 |
+
upscaler = next(example for example in examples if example["lora"] == "Upscaler")
|
| 427 |
+
self.assertEqual(upscaler["prompt"], "Upscale and enhance image detail.")
|
| 428 |
+
|
| 429 |
+
def test_example_endpoint_is_non_gpu_and_adapter_mutation_is_pinned(self):
|
| 430 |
+
source = (ROOT / "app.py").read_text(encoding="utf-8")
|
| 431 |
+
tree = ast.parse(source)
|
| 432 |
+
load_example = next(
|
| 433 |
+
node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "load_example"
|
| 434 |
+
)
|
| 435 |
+
decorators = [ast.unparse(decorator) for decorator in load_example.decorator_list]
|
| 436 |
+
self.assertEqual(decorators, ["app.api(name='load_example', queue=False)"])
|
| 437 |
+
self.assertIn("revision=spec.revision", source)
|
| 438 |
+
self.assertIn("pipe.unload_lora_weights()", source)
|
| 439 |
+
self.assertIn("ADAPTER_MANAGER.activate_mode(request.edit_mode)", source)
|
| 440 |
+
lock_start = source.index("with PIPELINE_LOCK:")
|
| 441 |
+
activation = source.index("ADAPTER_MANAGER.activate_mode(request.edit_mode)")
|
| 442 |
+
inference = source.index("result = pipe(", activation)
|
| 443 |
+
self.assertLess(lock_start, activation)
|
| 444 |
+
self.assertLess(activation, inference)
|
| 445 |
+
|
| 446 |
def test_output_contract_rejects_non_png_bad_dimensions_and_seed(self):
|
| 447 |
with self.assertRaises(ContractError) as caught:
|
| 448 |
encode_png_result(image_bytes("JPEG"), 1)
|
tests/test_space_static.py
CHANGED
|
@@ -15,7 +15,7 @@ class SpaceStaticTests(unittest.TestCase):
|
|
| 15 |
def test_remote_assets_are_pinned_and_candidate_count_is_stable(self):
|
| 16 |
provenance.assert_pinned_provenance()
|
| 17 |
self.assertEqual(len(provenance.CANDIDATE_ADAPTER_SPECS), 19)
|
| 18 |
-
self.assertEqual(set(provenance.ENABLED_ADAPTER_SPECS), set())
|
| 19 |
for spec in provenance.CANDIDATE_ADAPTER_SPECS.values():
|
| 20 |
self.assertRegex(spec.revision, r"^[0-9a-f]{40}$")
|
| 21 |
self.assertNotEqual(spec.revision, "main")
|
|
@@ -45,6 +45,9 @@ class SpaceStaticTests(unittest.TestCase):
|
|
| 45 |
"I2I_INVALID_OUTPUT",
|
| 46 |
},
|
| 47 |
)
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
def test_edit_endpoint_has_seven_fields_and_serial_concurrency(self):
|
| 50 |
tree = ast.parse((ROOT / "app.py").read_text(encoding="utf-8"))
|
|
@@ -75,45 +78,68 @@ class SpaceStaticTests(unittest.TestCase):
|
|
| 75 |
self.assertNotRegex(source, r"\{\s*(?:e|exc)\s*\}")
|
| 76 |
self.assertNotIn("str(exc)", source)
|
| 77 |
self.assertNotIn("traceback", source)
|
| 78 |
-
self.
|
| 79 |
-
self.
|
| 80 |
-
self.
|
|
|
|
|
|
|
| 81 |
|
| 82 |
-
def
|
| 83 |
source = (ROOT / "app.py").read_text(encoding="utf-8")
|
| 84 |
-
self.assertIn('"edit_mode": "general"', source)
|
| 85 |
-
self.assertIn('"uses_lora":
|
| 86 |
-
self.assertIn("
|
|
|
|
|
|
|
| 87 |
|
| 88 |
-
def
|
| 89 |
html = (ROOT / "index.html").read_text(encoding="utf-8")
|
| 90 |
script = (ROOT / "assets" / "app-ui.js").read_text(encoding="utf-8")
|
| 91 |
|
| 92 |
self.assertIn('src="/hy3d-assets/app-ui.js"', html)
|
| 93 |
self.assertIn('accept="image/png,image/jpeg,image/webp"', html)
|
| 94 |
-
self.
|
| 95 |
-
self.
|
| 96 |
-
self.
|
|
|
|
| 97 |
self.assertNotRegex(html, r'<(?:script|link)[^>]+(?:src|href)=["\']https?://')
|
| 98 |
self.assertNotIn("googleapis", html.lower())
|
| 99 |
self.assertNotIn("jsdelivr", html.lower())
|
| 100 |
|
| 101 |
self.assertIn('const GENERAL_MODE_ID = "__base__"', script)
|
| 102 |
-
self.assertIn(
|
| 103 |
-
self.assertIn("
|
|
|
|
| 104 |
self.assertIn('events: ["data", "status"]', script)
|
| 105 |
self.assertIn("await currentJob.wait_for_id()", script)
|
| 106 |
self.assertIn("await job.return()", script)
|
| 107 |
-
self.assertIn("uses_lora !==
|
| 108 |
-
self.assertIn("config.loras.length !==
|
| 109 |
-
self.assertIn("nextLimits.input_images !==
|
|
|
|
|
|
|
|
|
|
| 110 |
self.assertIn("Stopped waiting for this job. The backend may still finish it.", script)
|
| 111 |
self.assertNotIn("Queued job cancelled", script)
|
| 112 |
self.assertNotIn('hideLoader("Cancelled")', script)
|
| 113 |
-
self.
|
| 114 |
-
self.
|
| 115 |
-
self.
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
self.assertIn('class="dot pending"', html)
|
| 119 |
self.assertIn("4 STEPS DEFAULT", html)
|
|
@@ -125,6 +151,17 @@ class SpaceStaticTests(unittest.TestCase):
|
|
| 125 |
self.assertIn("studio.inert = true", script)
|
| 126 |
self.assertIn("function isLightboxOpen()", script)
|
| 127 |
self.assertGreaterEqual(script.count("if (isLightboxOpen()) return;"), 3)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
def test_browser_client_is_exactly_vendored(self):
|
| 130 |
client_path = ROOT / "assets" / "gradio-client-2.3.1.js"
|
|
@@ -156,9 +193,12 @@ class SpaceStaticTests(unittest.TestCase):
|
|
| 156 |
self.assertRegex(package, r"^[A-Za-z0-9_.-]+==[^=<>~]+$")
|
| 157 |
self.assertEqual((ROOT / "pre-requirements.txt").read_text(encoding="utf-8").strip(), "pip==26.1.2")
|
| 158 |
|
| 159 |
-
def
|
| 160 |
source = (ROOT / "app.py").read_text(encoding="utf-8")
|
| 161 |
-
self.
|
|
|
|
|
|
|
|
|
|
| 162 |
self.assertIn("blocked_paths=[examples_path]", source)
|
| 163 |
|
| 164 |
def test_kernel_revision_is_pinned(self):
|
|
|
|
| 15 |
def test_remote_assets_are_pinned_and_candidate_count_is_stable(self):
|
| 16 |
provenance.assert_pinned_provenance()
|
| 17 |
self.assertEqual(len(provenance.CANDIDATE_ADAPTER_SPECS), 19)
|
| 18 |
+
self.assertEqual(set(provenance.ENABLED_ADAPTER_SPECS), set(provenance.CANDIDATE_ADAPTER_SPECS))
|
| 19 |
for spec in provenance.CANDIDATE_ADAPTER_SPECS.values():
|
| 20 |
self.assertRegex(spec.revision, r"^[0-9a-f]{40}$")
|
| 21 |
self.assertNotEqual(spec.revision, "main")
|
|
|
|
| 45 |
"I2I_INVALID_OUTPUT",
|
| 46 |
},
|
| 47 |
)
|
| 48 |
+
source = (ROOT / "app.py").read_text(encoding="utf-8")
|
| 49 |
+
self.assertIn("return message in {", source)
|
| 50 |
+
self.assertNotIn('message.startswith(f"{code}:")', source)
|
| 51 |
|
| 52 |
def test_edit_endpoint_has_seven_fields_and_serial_concurrency(self):
|
| 53 |
tree = ast.parse((ROOT / "app.py").read_text(encoding="utf-8"))
|
|
|
|
| 78 |
self.assertNotRegex(source, r"\{\s*(?:e|exc)\s*\}")
|
| 79 |
self.assertNotIn("str(exc)", source)
|
| 80 |
self.assertNotIn("traceback", source)
|
| 81 |
+
self.assertIn("load_lora_weights", source)
|
| 82 |
+
self.assertIn("revision=spec.revision", source)
|
| 83 |
+
self.assertIn("set_adapters", source)
|
| 84 |
+
self.assertIn("unload_lora_weights", source)
|
| 85 |
+
self.assertIn("SingleResidentAdapterManager", source)
|
| 86 |
|
| 87 |
+
def test_public_runtime_has_general_default_and_19_opt_in_loras(self):
|
| 88 |
source = (ROOT / "app.py").read_text(encoding="utf-8")
|
| 89 |
+
self.assertIn('"edit_mode": "general-and-lora"', source)
|
| 90 |
+
self.assertIn('"uses_lora": True', source)
|
| 91 |
+
self.assertIn('"supports_general_mode": True', source)
|
| 92 |
+
self.assertIn('"default_edit_mode": BASE_EDIT_MODE_ID', source)
|
| 93 |
+
self.assertIn("allowed_edit_modes={BASE_EDIT_MODE_ID, *ENABLED_ADAPTER_SPECS}", source)
|
| 94 |
|
| 95 |
+
def test_homepage_is_a_hardened_full_feature_editor(self):
|
| 96 |
html = (ROOT / "index.html").read_text(encoding="utf-8")
|
| 97 |
script = (ROOT / "assets" / "app-ui.js").read_text(encoding="utf-8")
|
| 98 |
|
| 99 |
self.assertIn('src="/hy3d-assets/app-ui.js"', html)
|
| 100 |
self.assertIn('accept="image/png,image/jpeg,image/webp"', html)
|
| 101 |
+
self.assertIn(" multiple", html)
|
| 102 |
+
self.assertIn("Style / LoRA", html)
|
| 103 |
+
self.assertIn('id="tab-examples"', html)
|
| 104 |
+
self.assertIn('id="examples-scroll"', html)
|
| 105 |
self.assertNotRegex(html, r'<(?:script|link)[^>]+(?:src|href)=["\']https?://')
|
| 106 |
self.assertNotIn("googleapis", html.lower())
|
| 107 |
self.assertNotIn("jsdelivr", html.lower())
|
| 108 |
|
| 109 |
self.assertIn('const GENERAL_MODE_ID = "__base__"', script)
|
| 110 |
+
self.assertIn('const EXPECTED_API_VERSION = "2026-08-12.3"', script)
|
| 111 |
+
self.assertIn("images_b64_json: JSON.stringify(inputSnapshot)", script)
|
| 112 |
+
self.assertIn("lora_adapter: editMode", script)
|
| 113 |
self.assertIn('events: ["data", "status"]', script)
|
| 114 |
self.assertIn("await currentJob.wait_for_id()", script)
|
| 115 |
self.assertIn("await job.return()", script)
|
| 116 |
+
self.assertIn("config.uses_lora !== true", script)
|
| 117 |
+
self.assertIn("config.loras.length !== EXPECTED_LORA_IDS.length", script)
|
| 118 |
+
self.assertIn("nextLimits.input_images !== 2", script)
|
| 119 |
+
self.assertIn("nextLimits.min_input_images !== 1", script)
|
| 120 |
+
self.assertIn("nextLimits.max_input_images !== 2", script)
|
| 121 |
+
self.assertIn('client.predict("/load_example", { idx: index })', script)
|
| 122 |
self.assertIn("Stopped waiting for this job. The backend may still finish it.", script)
|
| 123 |
self.assertNotIn("Queued job cancelled", script)
|
| 124 |
self.assertNotIn('hideLoader("Cancelled")', script)
|
| 125 |
+
self.assertIn('"Anime-V2"', script)
|
| 126 |
+
self.assertIn('"Photo-to-Anime"', script)
|
| 127 |
+
self.assertIn('"General / No LoRA"', script)
|
| 128 |
+
lora_block = re.search(r"const EXPECTED_LORA_IDS = Object\.freeze\(\[(.*?)\]\);", script, re.DOTALL)
|
| 129 |
+
self.assertIsNotNone(lora_block)
|
| 130 |
+
ui_loras = re.findall(r'^\s*"([^"]+)",?$', lora_block.group(1), re.MULTILINE)
|
| 131 |
+
self.assertEqual(ui_loras, list(provenance.CANDIDATE_ADAPTER_SPECS))
|
| 132 |
+
suggestion_block = re.search(r"const SUGGESTIONS = Object\.freeze\(\[(.*?)\]\);", script, re.DOTALL)
|
| 133 |
+
self.assertIsNotNone(suggestion_block)
|
| 134 |
+
self.assertEqual(len(re.findall(r'^\s*\["', suggestion_block.group(1), re.MULTILINE)), 16)
|
| 135 |
+
suggestion_handler = re.search(r"SUGGESTIONS\.forEach\(.*?\n\}\);", script, re.DOTALL)
|
| 136 |
+
self.assertIsNotNone(suggestion_handler)
|
| 137 |
+
self.assertNotIn("loraSelect", suggestion_handler.group(0))
|
| 138 |
+
self.assertIn('["Upscale / Enhance", "Enhance clarity and fine detail while preserving the original composition."]', script)
|
| 139 |
+
self.assertNotIn("Upscale 4K", script)
|
| 140 |
+
self.assertNotIn("4K resolution", script)
|
| 141 |
+
self.assertNotIn("card.innerHTML", script)
|
| 142 |
+
self.assertIn("card.append(thumbnails, meta, promptText)", script)
|
| 143 |
|
| 144 |
self.assertIn('class="dot pending"', html)
|
| 145 |
self.assertIn("4 STEPS DEFAULT", html)
|
|
|
|
| 151 |
self.assertIn("studio.inert = true", script)
|
| 152 |
self.assertIn("function isLightboxOpen()", script)
|
| 153 |
self.assertGreaterEqual(script.count("if (isLightboxOpen()) return;"), 3)
|
| 154 |
+
input_strip = re.search(r"function renderInputStrip\(\) \{(.*?)\n\}", script, re.DOTALL)
|
| 155 |
+
self.assertIsNotNone(input_strip)
|
| 156 |
+
self.assertIn('const select = document.createElement("button")', input_strip.group(1))
|
| 157 |
+
self.assertIn('select.type = "button"', input_strip.group(1))
|
| 158 |
+
self.assertIn('select.className = "fs-thumb-select"', input_strip.group(1))
|
| 159 |
+
self.assertIn('select.setAttribute("aria-label", `Select input image ${index + 1}`)', input_strip.group(1))
|
| 160 |
+
self.assertIn('select.setAttribute("aria-pressed", String(index === selectedImageIndex))', input_strip.group(1))
|
| 161 |
+
self.assertIn("select.append(image, badge)", input_strip.group(1))
|
| 162 |
+
self.assertIn("thumb.append(select, remove)", input_strip.group(1))
|
| 163 |
+
self.assertNotIn('thumb.addEventListener("click"', input_strip.group(1))
|
| 164 |
+
self.assertIn(".fs-thumb-select:focus-visible", html)
|
| 165 |
|
| 166 |
def test_browser_client_is_exactly_vendored(self):
|
| 167 |
client_path = ROOT / "assets" / "gradio-client-2.3.1.js"
|
|
|
|
| 193 |
self.assertRegex(package, r"^[A-Za-z0-9_.-]+==[^=<>~]+$")
|
| 194 |
self.assertEqual((ROOT / "pre-requirements.txt").read_text(encoding="utf-8").strip(), "pip==26.1.2")
|
| 195 |
|
| 196 |
+
def test_examples_are_fixed_non_gpu_api_assets(self):
|
| 197 |
source = (ROOT / "app.py").read_text(encoding="utf-8")
|
| 198 |
+
self.assertIn('@app.api(name="load_example", queue=False)', source)
|
| 199 |
+
self.assertIn("def load_example(idx: float) -> dict", source)
|
| 200 |
+
self.assertIn("parse_example_index", source)
|
| 201 |
+
self.assertIn("EXAMPLES_CONFIG", source)
|
| 202 |
self.assertIn("blocked_paths=[examples_path]", source)
|
| 203 |
|
| 204 |
def test_kernel_revision_is_pinned(self):
|