hy3dlab commited on
Commit
df13472
·
verified ·
1 Parent(s): 3389179

feat: restore General plus 19 LoRA editor

Browse files
Files changed (9) hide show
  1. MODEL_BOM.md +18 -14
  2. README.md +19 -14
  3. app.py +233 -31
  4. assets/app-ui.js +455 -109
  5. i2i_contract.py +111 -5
  6. index.html +67 -8
  7. provenance.py +8 -7
  8. tests/test_i2i_contract.py +259 -4
  9. 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
- ## Candidate LoRAs
22
 
23
- The source Space exposed all 19 candidates below, and H2 pinned their revisions
24
- for provenance. The current public runtime enables none of them: requests run
25
- the pinned replacement transformer directly in general edit mode without loading,
26
- selecting, or applying a LoRA adapter.
 
 
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
- - Resolve `Style-Transfer`'s metadata/card conflict before enabling it.
55
- - Decide whether brand-named adapter IDs are appropriate for first-party UI.
56
- - Confirm that the upstream example images may be redistributed. H2 keeps their
57
- files for source-history fidelity but removes their API/UI exposure and blocks
58
- the directory from Gradio file serving.
 
 
59
  - Produce an exact Linux transitive dependency lock after a controlled build.
60
- - A previous `Anime-V2` smoke does not validate the new no-LoRA general edit path.
61
- Run a separate, explicitly authorized synthetic-image smoke before describing
62
- general no-LoRA image generation as end-to-end verified.
 
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 and API
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
- Current mode: general image editing through the pinned replacement transformer.
22
- No LoRA adapter is loaded. The legacy `lora_adapter` API field is retained for
23
- compatibility and accepts only the reserved internal value `__base__`.
 
 
24
 
25
- The Space App includes a first-party single-image editor with upload, Prompt,
26
- queue status, before/after comparison, session history, and PNG download. Its
27
- browser code is self-hosted and uses the same strict seven-field API contract;
28
- the removed upstream LoRA and example controls are not exposed.
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 URL, at most 10 MiB and 16 MP,
36
- Prompt length, numeric ranges, the reserved general edit mode, and one
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 candidate LoRA revisions are pinned in
54
  `provenance.py`. See `MODEL_BOM.md` for the recorded supply-chain facts and open
55
- release gates. Candidate LoRAs are provenance records only and none are enabled
56
- by the runtime. Python direct dependencies are pinned in `requirements.txt` and
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 any(message.startswith(f"{code}:") for code in (
99
- "I2I_BAD_REQUEST",
100
- "I2I_UNSUPPORTED_TYPE",
101
- "I2I_FILE_TOO_LARGE",
102
- "I2I_IMAGE_DECODE_FAILED",
103
- "I2I_IMAGE_DIMENSIONS_INVALID",
104
- "I2I_PROMPT_REQUIRED",
105
- "I2I_PROMPT_TOO_LONG",
106
- "I2I_LORA_NOT_ALLOWED",
107
- "I2I_PROVIDER_BUSY",
108
- "I2I_INFERENCE_FAILED",
109
- "I2I_INVALID_OUTPUT",
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
- """Run one general edit with the pinned replacement transformer and no LoRA."""
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. The legacy
181
- lora_adapter field must contain the reserved general-mode value; no LoRA is
182
- loaded. Validation is technical only, with no semantic content moderation.
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": False,
224
- "required_lora_adapter_value": BASE_EDIT_MODE_ID,
225
- "enabled_loras": [],
226
- "ready": deploy_sha != "unknown",
 
 
 
 
 
 
 
227
  "limits": {
228
- "input_images": 1,
 
 
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() -> dict:
245
  """Non-inference configuration and revision check."""
246
 
247
- return _service_metadata()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
 
249
 
250
  @app.get("/api/config")
251
- def client_config() -> dict:
252
  """Public, non-secret configuration for the first-party frontend."""
253
 
254
  metadata = _service_metadata()
255
- return {
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
- "loras": [],
 
 
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: 1,
 
 
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
- ["Studio Background", "Replace the background with a clean studio backdrop while preserving the subject."],
34
- ["Warm Light", "Add warm natural lighting while keeping the subject unchanged."],
35
- ["Cool Light", "Add cool balanced lighting while keeping the subject unchanged."],
36
- ["B&W", "Convert the image to black and white with balanced contrast."],
37
- ["Blur Background", "Add a natural shallow depth of field and softly blur the background."],
38
- ["Sharpen", "Improve clarity and fine detail without changing the composition."],
39
- ["Remove Object", "Remove the distracting object and reconstruct the background naturally."],
40
- ["Product Photo", "Turn this into a clean professional product photo with even lighting."],
 
 
 
 
 
 
 
 
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 inputImage = null;
 
 
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
- runBtn.disabled = !contractReady || running || !inputImage;
 
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 || inputImage)) canvasImage.style.display = "";
179
  }
180
 
181
  function updateRailState() {
182
  const hasResult = Boolean(currentResult);
183
- btnUpload.disabled = running || !contractReady;
184
- dlBtn.disabled = running || !hasResult;
185
- useAsInputBtn.disabled = running || !hasResult;
186
- compareBtn.disabled = running || !(hasResult && currentResult.beforeB64);
187
- zoomBtn.disabled = running || !(hasResult || inputImage);
188
- btnRemove.disabled = running || !inputImage;
189
- btnClear.disabled = running || !inputImage;
 
190
  }
191
 
192
  function renderCanvas() {
193
  exitCompareMode();
194
  let canvasImage = getCanvasImage();
195
- const shown = viewMode === "result" ? currentResult : inputImage;
 
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 : inputImage.b64;
210
  canvasMeta.textContent = viewMode === "result"
211
- ? `result · seed ${currentResult.seed}`
212
- : `input · ${inputImage.name || "image"}`;
213
  } else {
214
  if (canvasImage) canvasImage.style.display = "none";
215
  outPh.style.display = "";
216
- canvasMeta.textContent = inputImage ? "Input ready — run an edit" : "No image loaded";
 
 
217
  }
218
  updateRailState();
219
  }
@@ -226,47 +277,63 @@ function setViewMode(mode) {
226
  }
227
 
228
  function updateCounts() {
229
- const inputCount = inputImage ? 1 : 0;
230
- imgCountTb.textContent = String(inputCount);
231
- imgCountSb.textContent = inputCount
232
- ? `1 input · ${history.length} result${history.length === 1 ? "" : "s"}`
233
  : "No inputs";
234
  }
235
 
236
  function renderInputStrip() {
237
  galleryGrid.replaceChildren();
238
- if (!inputImage) {
239
  const empty = document.createElement("div");
240
  empty.className = "fs-empty";
241
- empty.textContent = "Drop or paste one image…";
242
  galleryGrid.appendChild(empty);
243
- } else {
 
244
  const thumb = document.createElement("div");
245
- thumb.className = "fs-thumb selected";
 
 
 
 
 
246
  const image = document.createElement("img");
247
- image.src = inputImage.b64;
248
- image.alt = "Input image";
249
  const badge = document.createElement("span");
250
  badge.className = "fs-badge";
251
- badge.textContent = "#1";
252
  const remove = document.createElement("button");
253
  remove.className = "fs-remove";
254
  remove.type = "button";
255
  remove.textContent = "✕";
256
- remove.setAttribute("aria-label", "Remove input image");
257
  remove.addEventListener("click", (event) => {
258
  event.stopPropagation();
259
- clearInput();
260
  });
261
- thumb.append(image, badge, remove);
262
- thumb.addEventListener("click", () => setViewMode("input"));
 
 
 
 
 
263
  galleryGrid.appendChild(thumb);
264
- }
265
  const add = document.createElement("button");
266
  add.type = "button";
267
  add.className = "fs-add";
268
- add.innerHTML = '<span class="add-icon">+</span><span class="add-text">Replace</span>';
269
- add.addEventListener("click", () => fileInput.click());
 
 
 
 
 
 
270
  galleryGrid.appendChild(add);
271
  updateRailState();
272
  }
@@ -302,29 +369,42 @@ function renderHistory() {
302
  });
303
  }
304
 
305
- function clearInput() {
306
  selectionId += 1;
307
- inputImage = null;
308
- fileInput.value = "";
309
  renderInputStrip();
310
  updateCounts();
311
- renderCanvas();
 
312
  updateRunButton();
313
  }
314
 
315
- function replaceInput(b64, name) {
316
- inputImage = { b64, name };
317
- renderInputStrip();
318
- updateCounts();
319
- setViewMode("input");
320
- updateRunButton();
 
 
 
 
 
 
 
 
 
 
 
 
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("invalid_reader_result"));
327
- reader.onerror = () => reject(new Error("file_read_failed"));
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("image_decode_failed"));
343
  };
344
  image.src = url;
345
  });
346
  }
347
 
348
- async function processFile(file) {
349
- if (!file) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- const dimensions = await decodeImageFile(file);
369
- if (
370
- Math.min(dimensions.width, dimensions.height) < limits.min_image_side ||
371
- dimensions.width * dimensions.height > limits.max_image_pixels
372
- ) {
373
- showToast(PUBLIC_ERROR_MESSAGES.I2I_IMAGE_DIMENSIONS_INVALID);
374
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  }
376
- const b64 = await readFileAsDataUrl(file);
377
- if (localSelectionId !== selectionId) return;
378
- replaceInput(b64, file.name);
379
- showToast("Input image ready", "info");
380
  } catch (error) {
381
  console.warn("[image-editor] input validation failed", { error_class: errorClass(error) });
382
- showToast(PUBLIC_ERROR_MESSAGES.I2I_IMAGE_DECODE_FAILED);
 
383
  }
384
  }
385
 
386
  fileInput.addEventListener("change", () => {
387
- const file = fileInput.files && fileInput.files[0];
388
- void processFile(file);
389
  fileInput.value = "";
 
390
  });
391
- btnUpload.addEventListener("click", () => fileInput.click());
392
- btnRemove.addEventListener("click", clearInput);
393
- btnClear.addEventListener("click", clearInput);
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
- if (files.length > 1) showToast("Only the first image will be used.", "warning");
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 item = Array.from(items).find((candidate) => candidate.kind === "file" && limits.mime_types.includes(candidate.type));
426
- const file = item && item.getAsFile();
427
- if (!file) return;
 
 
428
  event.preventDefault();
429
- void processFile(file);
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 source = viewMode === "result" && currentResult ? currentResult.b64 : inputImage && inputImage.b64;
 
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
- replaceInput(currentResult.b64, `hy3d-image-edit-${currentResult.seed}.png`);
637
- showToast("Result is ready for another edit", "info");
 
 
 
 
 
 
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 (!inputImage) {
662
- showToast("Upload one image first.");
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 beforeB64 = inputImage.b64;
 
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([beforeB64]),
692
  prompt,
693
- lora_adapter: GENERAL_MODE_ID,
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.edit_mode !== "general" ||
802
- config.uses_lora !== false ||
803
- config.required_lora_adapter_value !== GENERAL_MODE_ID ||
 
 
 
 
 
804
  config.content_moderation_enabled !== false ||
805
  !Array.isArray(config.loras) ||
806
- config.loras.length !== 0 ||
 
 
 
 
 
 
 
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 !== 1 ||
 
 
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
- !Number.isInteger(nextLimits.max_output_side) || nextLimits.max_output_side <= 0
829
  ) {
830
  throw new Error("invalid_limits");
831
  }
832
- return nextLimits;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- applyLimits(validateConfig(await configResponse.json()));
 
 
 
 
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.2"
23
  CONTENT_MODERATION_ENABLED = False
24
  BASE_EDIT_MODE_ID = "__base__"
25
 
26
- MAX_INPUT_IMAGES = 1
 
27
  MAX_IMAGE_BYTES = 10 * 1024 * 1024
28
- MAX_REQUEST_JSON_CHARS = 4 * math.ceil(MAX_IMAGE_BYTES / 3) + 256
 
 
 
 
 
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) != MAX_INPUT_IMAGES:
195
  raise ContractError("I2I_BAD_REQUEST")
196
- return tuple(_decode_image_data_url(value) for value in values)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 an image anywhere, paste with <kbd>⌘/Ctrl+V</kbd>, or use the rail to upload.
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 mode · no LoRA</span>
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 one image to upload</div></div>
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&hellip;</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 candidates copied from the approved upstream
29
- # revision. They are not automatically enabled for the public API. Enabling a
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
- # The public base edit mode deliberately loads no LoRA. Candidate records remain
169
- # pinned for provenance only and are not available to the runtime API.
170
- ENABLED_ADAPTER_IDS: frozenset[str] = frozenset()
 
171
  ENABLED_ADAPTER_SPECS = {
172
  adapter_id: CANDIDATE_ADAPTER_SPECS[adapter_id]
173
- for adapter_id in sorted(ENABLED_ADAPTER_IDS)
 
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
- two = json.dumps([data_url(image_bytes()), data_url(image_bytes())])
92
- self.assert_code("I2I_BAD_REQUEST", images_b64_json=two)
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.assert_code("I2I_LORA_NOT_ALLOWED", lora_adapter="Anime-V2")
 
 
 
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.assertNotIn("load_lora_weights", source)
79
- self.assertNotIn("set_adapters", source)
80
- self.assertNotIn("unload_lora_weights", source)
 
 
81
 
82
- def test_public_runtime_is_explicitly_general_no_lora_mode(self):
83
  source = (ROOT / "app.py").read_text(encoding="utf-8")
84
- self.assertIn('"edit_mode": "general"', source)
85
- self.assertIn('"uses_lora": False', source)
86
- self.assertIn("allowed_edit_modes={BASE_EDIT_MODE_ID}", source)
 
 
87
 
88
- def test_homepage_is_an_interactive_self_hosted_single_image_editor(self):
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.assertNotIn(" multiple", html)
95
- self.assertNotIn("Style / LoRA", html)
96
- self.assertNotIn("tab-examples", html)
 
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("images_b64_json: JSON.stringify([beforeB64])", script)
103
- self.assertIn("lora_adapter: GENERAL_MODE_ID", script)
 
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 !== false", script)
108
- self.assertIn("config.loras.length !== 0", script)
109
- self.assertIn("nextLimits.input_images !== 1", script)
 
 
 
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.assertNotIn("Anime-V2", script)
114
- self.assertNotIn("Photo-to-Anime", script)
115
- self.assertNotIn("/load_example", script)
116
- self.assertNotIn("cfg.examples", script)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 test_examples_are_not_exposed_by_api_or_homepage(self):
160
  source = (ROOT / "app.py").read_text(encoding="utf-8")
161
- self.assertNotIn("load_example", source)
 
 
 
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):