tingled commited on
Commit
e99af70
·
1 Parent(s): c2884a2

gradio server + workflow app (#3)

Browse files

- Replace the form UI with a gr.Server studio + gr.Workflow canvas (b3e9f7f6ef3536e2f4e629132f38ba20d095ac82)
- Stop serving the whole temp directory (e5a481139d8dedce07eeb7661bb7c873b753b660)
- Stop fetching image URLs on the server (5db3e9cc499e8c32795fe55b3973d104902daac3)
- Only open images from the upload folder (8c509230f4a71c79e4f8654ded5b515eaeb9b186)
- Escape the query string on the login page (957668ffab264b8512bdb64ad069b7c2ed42d8c1)
- Drop the write-repos OAuth scope (9eced8ec4e761fbda73cb803b7ed978e30f1cb2d)
- Cap steps at MAX_STEPS on the API (035a9dd825dfef13cbbed0baa31847dd1ae55d67)
- Queue /edit and /vgl one call at a time (e9d39cbeb37c8e7e30aba20ac781cbab99612f4b)
- Pin @gradio/client in the studio page (5d7616be84c168c54296f81b7e37188f13e7b134)
- Point the studio examples at example_images (456fb6ec66a39f83ec19612908376bfa22c6f227)
- Mount the workflow canvas after the registration launch (8105a406f3c6bcc044d5f50c315e5214f5f0e00a)

Files changed (4) hide show
  1. README.md +18 -1
  2. app.py +240 -222
  3. studio.html +751 -0
  4. workflow.json +155 -0
README.md CHANGED
@@ -4,11 +4,14 @@ emoji: ⚡
4
  colorFrom: indigo
5
  colorTo: purple
6
  sdk: gradio
7
- sdk_version: 5.49.1
8
  python_version: "3.12" # diffusers from the fibo-2 PR needs >= 3.11
9
  app_file: app.py
10
  pinned: false
11
  short_description: Fibo 2.0 Edit Turbo image editing in 4 steps
 
 
 
12
  models:
13
  - briaai/Fibo-2.0-edit-turbo
14
  - briaai/Fibo-2.0-dev # listed so the Space shows on the Dev model page too
@@ -18,4 +21,18 @@ Fibo 2.0 Edit Turbo on ZeroGPU: instruction-based image editing in 4 guidance-fr
18
  reference images and an optional mask. The JSON instructions come from the Bria API's
19
  `/v2/vgl/edit`, written with Fibo 2.0's VGL prompts.
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  Space secrets: `HF_TOKEN` (read access to the gated model), `BRIA_API_TOKEN` (the Bria API).
 
4
  colorFrom: indigo
5
  colorTo: purple
6
  sdk: gradio
7
+ sdk_version: 6.30.0
8
  python_version: "3.12" # diffusers from the fibo-2 PR needs >= 3.11
9
  app_file: app.py
10
  pinned: false
11
  short_description: Fibo 2.0 Edit Turbo image editing in 4 steps
12
+ hf_oauth: true
13
+ hf_oauth_scopes:
14
+ - inference-api
15
  models:
16
  - briaai/Fibo-2.0-edit-turbo
17
  - briaai/Fibo-2.0-dev # listed so the Space shows on the Dev model page too
 
21
  reference images and an optional mask. The JSON instructions come from the Bria API's
22
  `/v2/vgl/edit`, written with Fibo 2.0's VGL prompts.
23
 
24
+ Two surfaces:
25
+
26
+ - **`/` — Studio** (replaces the classic form UI): a bespoke Photoshop-style editor (`gr.Server`
27
+ API + custom UI): brush-to-mask, layers (source / mask / up to 3 extra references), before/after
28
+ split view, run history with one-click restore, bundled example edits. All classic functionality
29
+ is kept: 4 references, mask, JSON instruction override (auto-filled by the VGL writer, cleared
30
+ when inputs change), seed/steps. Edits call the same `render` as before, so the AOTI-accelerated
31
+ pipeline in `pipelines.py` and the `/v2/vgl/edit` prompt writing behave identically.
32
+ - **`/workflow` — Workflow canvas** (`gr.Workflow`): node-based edit pipelines you can rewire
33
+ and chain. Run nodes one at a time with ▶ — up-to-date upstream results are reused.
34
+ Saving the graph back to the Space is off: it needs the `write-repos` OAuth scope, which
35
+ would give the Space write access to every signed-in visitor's repos. To change the starter
36
+ graph, edit `workflow.json` in the repo.
37
+
38
  Space secrets: `HF_TOKEN` (read access to the gated model), `BRIA_API_TOKEN` (the Bria API).
app.py CHANGED
@@ -1,13 +1,28 @@
1
- import os
2
- import json
 
 
 
 
 
3
  import base64
 
 
 
4
  import random
5
  from contextlib import contextmanager
 
6
 
7
  import gradio as gr
8
  import numpy as np
9
  import spaces
10
  import torch
 
 
 
 
 
 
11
  from PIL import Image
12
 
13
  import bria_api
@@ -15,7 +30,7 @@ import pipelines
15
  from pipelines import GUIDANCE_SCALE, MAX_STEPS, STEPS
16
 
17
  MAX_SEED = np.iinfo(np.int32).max
18
- MAX_REFERENCES = bria_api.MAX_REFERENCES
19
 
20
  pipe = pipelines.load_pipeline()
21
 
@@ -38,6 +53,7 @@ def render(json_prompt, seed, steps, references, mask=None):
38
 
39
 
40
  # --------------------------------------------------------------------------- helpers
 
41
 
42
 
43
  @contextmanager
@@ -54,6 +70,14 @@ def resolve_seed(seed, randomize):
54
  return random.randint(1, MAX_SEED) if randomize else max(int(seed), 1)
55
 
56
 
 
 
 
 
 
 
 
 
57
  def pretty(json_text):
58
  """Format for the code box, leaving invalid text untouched."""
59
  try:
@@ -75,247 +99,241 @@ def as_prompt(json_value):
75
  raise gr.Error("The JSON instruction is not a valid JSON object. Fix it, or clear it to regenerate it.")
76
 
77
 
78
- def collect_references(*images):
79
- """Gap-free reference list; slot order is what <image_1>..<image_N> mean."""
80
- return [image.convert("RGB") for image in images if image is not None]
81
 
82
 
83
- def mask_from_editor(editor_value, source):
84
- """Brush layer -> 'L' mask sized to the source. White means repaint here."""
85
- if not editor_value:
86
- return None
87
- layers = editor_value.get("layers") or []
88
- if not layers:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  return None
90
- painted = layers[0].convert("RGBA")
91
- mask = painted.getchannel("A").point(lambda value: 255 if value > 8 else 0)
92
  if mask.getbbox() is None:
93
  return None
94
  return mask.resize(source.size, Image.NEAREST)
95
 
96
 
97
- def busy():
98
- return gr.update(interactive=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
 
 
 
 
 
100
 
101
- def idle():
102
- return gr.update(interactive=True)
103
 
 
 
 
 
104
 
105
- # ------------------------------------------------------------------------------- edit
106
 
 
 
 
 
 
 
 
 
 
107
 
108
- def check_edit_inputs(references, mask):
109
- if not references:
110
- raise gr.Error("Upload a source image first.")
111
- if mask is not None and len(references) != 1:
112
- raise gr.Error(
113
- "A mask only works with a single reference image. Clear the extra references, "
114
- "or clear the mask."
115
- )
116
 
 
 
 
 
 
 
117
 
118
- def run_edit(instruction, source, ref2, ref3, ref4, mask_editor, json_prompt, seed, randomize,
119
- steps, progress=gr.Progress(track_tqdm=True)):
120
- seed = resolve_seed(seed, randomize)
121
- references = collect_references(source, ref2, ref3, ref4)
122
- mask = mask_from_editor(mask_editor, source) if source is not None else None
123
- check_edit_inputs(references, mask)
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  if not json_prompt:
126
  with user_errors():
127
  json_prompt = bria_api.vgl_edit(
128
- instruction=instruction, images=references, mask=mask, seed=seed
129
  )
130
  json_prompt = as_prompt(json_prompt)
131
-
132
- image = render(json_prompt, seed, steps, references, mask=mask)
133
- return image, seed, pretty(json_prompt)
134
-
135
-
136
- def invalidate_edit_json():
137
- """A changed image, instruction or mask makes the generated JSON instruction stale."""
138
- return ""
139
-
140
-
141
- # --------------------------------------------------------------------------------- ui
142
-
143
- def _load_logo():
144
- """Embed the bundled logo as a data URI so the header never depends on an external CDN."""
145
- try:
146
- with open(os.path.join(os.path.dirname(__file__), "bria_logo.png"), "rb") as f:
147
- return "data:image/png;base64," + base64.b64encode(f.read()).decode("utf-8")
148
- except Exception:
149
- return "https://cdn.sanity.io/images/zppcnj0l/production/d0f4aef10abdcc4bacaebeade789ea8c3e453882-210x132.png"
150
-
151
-
152
- BRIA_LOGO = _load_logo()
153
-
154
- # Bria brand theme (purple #5300C9, magenta accent).
155
- bria_theme = gr.themes.Soft(
156
- primary_hue=gr.themes.Color(
157
- c50="#f3ebff", c100="#e4d3ff", c200="#c9a6ff", c300="#a86bff",
158
- c400="#8a3dff", c500="#5300C9", c600="#4700ab", c700="#37008a",
159
- c800="#28006b", c900="#1c0050", c950="#120038",
160
- ),
161
- secondary_hue=gr.themes.Color(
162
- c50="#ffe9f4", c100="#ffd0e6", c200="#ffa1cd", c300="#ff6fb2",
163
- c400="#f5399a", c500="#e2138a", c600="#c00b74", c700="#99095c",
164
- c800="#730745", c900="#520531", c950="#340120",
165
- ),
166
- neutral_hue="slate",
167
- font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
168
- radius_size=gr.themes.sizes.radius_lg,
169
- ).set(
170
- body_background_fill="#f6f5fb",
171
- body_background_fill_dark="#0f0b1a",
172
- block_background_fill="white",
173
- block_background_fill_dark="#1a1330",
174
- block_border_width="1px",
175
- block_shadow="0 1px 3px rgba(28,0,80,0.06), 0 8px 24px rgba(28,0,80,0.05)",
176
- button_primary_background_fill="linear-gradient(90deg, #5300C9 0%, #8a3dff 100%)",
177
- button_primary_background_fill_hover="linear-gradient(90deg, #4700ab 0%, #7a2dff 100%)",
178
- button_primary_text_color="white",
179
- button_large_radius="12px",
180
  )
 
181
 
182
- css = """
183
- #col-container { margin: 0 auto; max-width: 1000px; }
184
- #json_instruction { max-height: 800px; }
185
-
186
- #bria-header, #bria-footer { max-width: 1000px; margin: 0 auto; }
187
- #bria-header {
188
- display: flex; align-items: center; gap: 18px;
189
- padding: 22px 26px; margin-bottom: 10px;
190
- background: linear-gradient(120deg, #ffffff 0%, #f3ecff 100%);
191
- border: 1px solid rgba(83, 0, 201, 0.12); border-radius: 18px;
192
- }
193
- .dark #bria-header { background: linear-gradient(120deg, #1a1330 0%, #241640 100%); border-color: rgba(138, 61, 255, 0.25); }
194
- #bria-header img { height: 52px; width: auto; }
195
- #bria-header .title-block h1 { margin: 0; font-size: 1.7rem; font-weight: 800; letter-spacing: -0.02em; color: #1c0050; }
196
- .dark #bria-header .title-block h1 { color: #f0eafc; }
197
- #bria-header .title-block p { margin: 4px 0 0 0; font-size: 0.95rem; color: #6b6580; }
198
- .dark #bria-header .title-block p { color: #b3abc9; }
199
- #bria-header .pill {
200
- display: inline-block; margin-top: 8px; padding: 3px 12px;
201
- font-size: 0.72rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
202
- color: #5300C9; background: rgba(83, 0, 201, 0.09); border-radius: 999px;
203
- }
204
- .dark #bria-header .pill { color: #c9a6ff; background: rgba(138, 61, 255, 0.15); }
205
-
206
- #bria-footer {
207
- margin-top: 24px; padding-top: 16px; border-top: 1px solid rgba(83, 0, 201, 0.12);
208
- text-align: center; font-size: 0.88rem; color: #6b6580; line-height: 1.7;
209
- }
210
- #bria-footer a { color: #5300C9; text-decoration: none; font-weight: 600; }
211
- #bria-footer a:hover { text-decoration: underline; }
212
- .dark #bria-footer { color: #b3abc9; border-color: rgba(138, 61, 255, 0.22); }
213
- .dark #bria-footer a { color: #c9a6ff; }
214
- """
215
-
216
- with gr.Blocks(css=css, theme=bria_theme, title="BRIA FIBO 2.0 Edit Turbo") as demo:
217
-
218
- gr.HTML(f"""
219
- <div id="bria-header">
220
- <img src="{BRIA_LOGO}" alt="BRIA logo" />
221
- <div class="title-block">
222
- <h1>FIBO 2.0 Edit Turbo</h1>
223
- <p>8B image editing in 4 steps, driven by structured JSON instructions — with references and masks.</p>
224
- <span class="pill">Open model · 8B · Edit Turbo</span>
225
- </div>
226
- </div>
227
- """)
228
-
229
- with gr.Row(elem_id="col-container"):
230
- with gr.Column(scale=1):
231
- with gr.Tabs():
232
-
233
- # ---------------------------------------------------------- edit
234
- with gr.Tab("Edit", id="edit_tab"):
235
- gr.Markdown(
236
- "Upload a source image and describe the edit. Add up to three more "
237
- "references — the instruction refers to them in slot order as "
238
- "`<image_1>` … `<image_4>`."
239
- )
240
- with gr.Row():
241
- edit_source = gr.Image(label="1 · Source (required)", type="pil")
242
- edit_ref2 = gr.Image(label="2 · Reference", type="pil")
243
- with gr.Row():
244
- edit_ref3 = gr.Image(label="3 · Reference", type="pil")
245
- edit_ref4 = gr.Image(label="4 · Reference", type="pil")
246
-
247
- with gr.Accordion(
248
- "Mask — paint the area to repaint (single reference only)", open=False
249
- ):
250
- edit_mask = gr.ImageEditor(
251
- label="Brush over the region you want changed",
252
- type="pil",
253
- brush=gr.Brush(colors=["#ffffff"], color_mode="fixed"),
254
- )
255
-
256
- edit_instruction = gr.Textbox(
257
- label="Edit instruction",
258
- placeholder="make the sky dramatic and stormy", lines=2,
259
- )
260
- edit_json = gr.Code(
261
- label="JSON Instruction", language="json", wrap_lines=True,
262
- lines=10, elem_id="json_instruction",
263
- )
264
- with gr.Accordion("Advanced Settings", open=False):
265
- with gr.Row():
266
- edit_seed = gr.Slider(
267
- label="Seed", minimum=1, maximum=MAX_SEED, step=1, value=1
268
- )
269
- edit_randomize_seed = gr.Checkbox(label="Randomize seed", value=True)
270
- edit_steps = gr.Slider(
271
- label="Steps", minimum=1, maximum=MAX_STEPS, step=1, value=STEPS,
272
- info="Fibo 2.0 Edit Turbo is distilled for 4 guidance-free steps.",
273
- )
274
- edit_run_btn = gr.Button("Generate Edit", variant="primary")
275
-
276
- gr.Examples(
277
- examples=[
278
- ["example_images/edit_bicycle.jpg", "Change the bicycle color to bright red"],
279
- ["example_images/edit_portrait.jpg", "Add round glasses"],
280
- ["example_images/edit_cat.jpg", "Put a small party hat on the cat"],
281
- ],
282
- inputs=[edit_source, edit_instruction],
283
- label="Example edits",
284
- )
285
-
286
- with gr.Column(scale=1):
287
- result = gr.Image(label="output")
288
-
289
- # -------------------------------------------------------------------- wiring
290
-
291
-
292
- edit_run_btn.click(
293
- fn=busy, inputs=None, outputs=edit_run_btn,
294
- ).then(
295
- fn=run_edit,
296
- inputs=[edit_instruction, edit_source, edit_ref2, edit_ref3, edit_ref4, edit_mask,
297
- edit_json, edit_seed, edit_randomize_seed, edit_steps],
298
- outputs=[result, edit_seed, edit_json],
299
- ).then(
300
- fn=idle, inputs=None, outputs=edit_run_btn,
301
- )
302
 
303
- # "Generate Edit" reuses the JSON box when it isn't empty, so anything that makes it stale clears it.
304
- for slot in (edit_source, edit_ref2, edit_ref3, edit_ref4, edit_instruction, edit_mask):
305
- slot.change(fn=invalidate_edit_json, inputs=None, outputs=[edit_json])
306
-
307
- # The mask must match the source, so seed the editor from it.
308
- edit_source.change(fn=lambda image: image, inputs=[edit_source], outputs=[edit_mask])
309
-
310
- gr.HTML("""
311
- <div id="bria-footer">
312
- <p>
313
- Edits run <b>Fibo 2.0 Edit Turbo</b> (4 guidance-free steps, up to 4 reference images).
314
- JSON instructions by the Bria API (/v2/vgl/edit). Trained on licensed data.<br/>
315
- <a href="https://huggingface.co/briaai/Fibo-2.0-edit-turbo" target="_blank">Model</a> ·
316
- <a href="https://arxiv.org/abs/2511.06876" target="_blank">arXiv</a>
317
- </p>
318
- </div>
319
- """)
320
-
321
- demo.queue().launch(ssr_mode=False, show_error=True)
 
1
+ """FIBO 2.0 Edit Turbo — gr.Server studio + gr.Workflow canvas.
2
+
3
+ The bespoke studio at / replaces the classic form UI; the backend is unchanged:
4
+ the same AOTI-accelerated pipeline load (pipelines.py), the same Bria VGL prompt
5
+ writing (bria_api.py), and edits run through the same render function.
6
+ """
7
+
8
  import base64
9
+ import html
10
+ import json
11
+ import os
12
  import random
13
  from contextlib import contextmanager
14
+ from urllib.parse import unquote, urlparse
15
 
16
  import gradio as gr
17
  import numpy as np
18
  import spaces
19
  import torch
20
+ import uvicorn
21
+ from fastapi import Request
22
+ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
23
+ from fastapi.staticfiles import StaticFiles
24
+ from gradio.utils import get_upload_folder
25
+ from gradio_client import utils as client_utils
26
  from PIL import Image
27
 
28
  import bria_api
 
30
  from pipelines import GUIDANCE_SCALE, MAX_STEPS, STEPS
31
 
32
  MAX_SEED = np.iinfo(np.int32).max
33
+ APP_DIR = os.path.dirname(os.path.abspath(__file__))
34
 
35
  pipe = pipelines.load_pipeline()
36
 
 
53
 
54
 
55
  # --------------------------------------------------------------------------- helpers
56
+ # (unchanged from the classic app)
57
 
58
 
59
  @contextmanager
 
70
  return random.randint(1, MAX_SEED) if randomize else max(int(seed), 1)
71
 
72
 
73
+ def checked_steps(steps) -> int:
74
+ """The classic slider capped steps server-side; the API must too, or one call can hold the GPU."""
75
+ steps = int(float(steps))
76
+ if not 1 <= steps <= MAX_STEPS:
77
+ raise gr.Error(f"Steps must be between 1 and {MAX_STEPS}.")
78
+ return steps
79
+
80
+
81
  def pretty(json_text):
82
  """Format for the code box, leaving invalid text untouched."""
83
  try:
 
99
  raise gr.Error("The JSON instruction is not a valid JSON object. Fix it, or clear it to regenerate it.")
100
 
101
 
102
+ # ------------------------------------------------------------------ media helpers
 
 
103
 
104
 
105
+ def _as_data_uri(path: str) -> str:
106
+ """Inline a result image so the studio page never needs a file route."""
107
+ with open(path, "rb") as f:
108
+ return "data:image/png;base64," + base64.b64encode(f.read()).decode()
109
+
110
+
111
+ def _requested_path(value) -> str:
112
+ """Turn whatever the canvas/API client sends into a path."""
113
+ if isinstance(value, str):
114
+ return value
115
+ if not isinstance(value, dict):
116
+ raise gr.Error(f"Expected an image, got {type(value).__name__}")
117
+ if path := value.get("path"):
118
+ return path
119
+ url = value.get("url") or ""
120
+ if "/gradio_api/file=" in url:
121
+ return unquote(url.split("/gradio_api/file=", 1)[1])
122
+ if url.startswith(("http://", "https://")):
123
+ raise gr.Error("Upload the image instead of passing a URL.")
124
+ return unquote(urlparse(url).path)
125
+
126
+
127
+ def _local_path(value) -> str:
128
+ """A path this process may open: only files inside gradio's upload folder,
129
+ where /gradio_api/upload puts uploads and _save_tmp puts results."""
130
+ path = os.path.realpath(_requested_path(value))
131
+ folder = os.path.realpath(get_upload_folder())
132
+ if os.path.commonpath([path, folder]) != folder:
133
+ raise gr.Error("Upload the image first.")
134
+ return path
135
+
136
+
137
+ def _open_rgb(value) -> Image.Image:
138
+ return Image.open(_local_path(value)).convert("RGB")
139
+
140
+
141
+ def _mask_from_upload(value, source: Image.Image):
142
+ """A white-on-black mask image from the studio's brush export, sized to the source."""
143
+ if value is None:
144
  return None
145
+ mask = Image.open(_local_path(value)).convert("L").point(lambda v: 255 if v > 8 else 0)
 
146
  if mask.getbbox() is None:
147
  return None
148
  return mask.resize(source.size, Image.NEAREST)
149
 
150
 
151
+ def _save_tmp(image: Image.Image) -> str:
152
+ """Save into gradio's upload folder, which it already serves, so no extra paths are exposed."""
153
+ folder = get_upload_folder()
154
+ os.makedirs(folder, exist_ok=True)
155
+ path = os.path.join(folder, f"fibo_edit_{os.urandom(8).hex()}.png")
156
+ image.save(path)
157
+ return path
158
+
159
+
160
+ def _as_media(path: str) -> dict:
161
+ """The dict shape the workflow canvas renders media from."""
162
+ return {
163
+ "path": path,
164
+ "url": f"/gradio_api/file={client_utils.encode_file_path(path)}",
165
+ "is_file": True,
166
+ }
167
+
168
+
169
+ # ------------------------------------------------------------------ workflow fn
170
+
171
+
172
+ def fibo_edit(image, json_instruction: str, seed: str = "42", steps: str = str(STEPS)) -> dict:
173
+ """Edit an image with Fibo 2.0 Edit Turbo (4 guidance-free steps)."""
174
+ source = _open_rgb(image)
175
+ json_prompt = as_prompt(json_instruction)
176
+ result = render(json_prompt, int(float(seed)), checked_steps(steps), [source])
177
+ return _as_media(_save_tmp(result))
178
+
179
+
180
+ # ------------------------------------------------------------------ studio server
181
+
182
+ server = gr.Server(title="FIBO 2.0 Edit Turbo")
183
+
184
+
185
+ # OAuth routes attach only on the mounted workflow app (at /workflow/...), but
186
+ # gradio's login buttons and the OAuth redirect_uri point at the Space root —
187
+ # forward the three auth paths so sign-in completes instead of 404ing. The login
188
+ # itself opens in a new tab (HF auth pages refuse to render inside an iframe).
189
+ @server.get("/login/huggingface", response_class=HTMLResponse)
190
+ def _oauth_login(request: Request):
191
+ qs = str(request.url.query)
192
+ # The query is user-controlled and goes into an href, so escape it.
193
+ target = html.escape("/workflow/login/huggingface" + ("?" + qs if qs else ""), quote=True)
194
+ return (
195
+ '<!DOCTYPE html><html><body style="'
196
+ 'font-family:Inter,system-ui,sans-serif;background:#0e0e10;color:rgba(255,255,255,0.87);'
197
+ 'display:flex;flex-direction:column;align-items:center;justify-content:center;'
198
+ 'height:100vh;margin:0;gap:14px;text-align:center">'
199
+ "<div style='font-size:1.4rem'>⚡</div>"
200
+ f"<a href='{target}' target='_blank' rel='noopener' style='"
201
+ 'background:linear-gradient(90deg,#5300C9,#8a3dff);color:white;'
202
+ "padding:12px 26px;border-radius:999px;text-decoration:none;font-weight:700'>"
203
+ 'Sign in with Hugging Face →</a>'
204
+ "<p style='color:rgba(255,255,255,0.5);font-size:0.82rem;margin:0'>Opens in a new tab — this page stays here.</p>"
205
+ f"<a href='/workflow/?ui=canvas' style='color:#8a3dff;font-size:0.82rem;text-decoration:none'>"
206
+ 'Signed in? Back to the canvas ↻</a>'
207
+ '</body></html>'
208
+ )
209
+
210
 
211
+ @server.get("/login/callback")
212
+ def _oauth_callback(request: Request):
213
+ qs = str(request.url.query)
214
+ return RedirectResponse("/workflow/login/callback" + ("?" + qs if qs else ""))
215
 
 
 
216
 
217
+ @server.get("/logout")
218
+ def _oauth_logout(request: Request):
219
+ qs = str(request.url.query)
220
+ return RedirectResponse("/workflow/logout" + ("?" + qs if qs else ""))
221
 
 
222
 
223
+ @server.get("/", response_class=HTMLResponse)
224
+ def _studio_page():
225
+ with open(os.path.join(APP_DIR, "studio.html"), encoding="utf-8") as f:
226
+ return f.read()
227
+
228
+
229
+ @server.get("/bria_logo.png")
230
+ def _logo():
231
+ return FileResponse(os.path.join(APP_DIR, "bria_logo.png"))
232
 
 
 
 
 
 
 
 
 
233
 
234
+ # No isdir guard: a missing folder should fail at startup, not 404 silently.
235
+ server.mount(
236
+ "/example_images",
237
+ StaticFiles(directory=os.path.join(APP_DIR, "example_images")),
238
+ name="example_images",
239
+ )
240
 
 
 
 
 
 
 
241
 
242
+ @server.api(name="vgl", concurrency_limit=1)
243
+ def api_vgl(
244
+ instruction: str,
245
+ source: str,
246
+ ref2: str | None = None,
247
+ ref3: str | None = None,
248
+ ref4: str | None = None,
249
+ mask: str | None = None,
250
+ seed: float = 42,
251
+ ) -> dict:
252
+ """Write the edit's structured JSON instruction with the Bria VGL API."""
253
+ images = [_open_rgb(p) for p in (source, ref2, ref3, ref4) if p]
254
+ mask_img = _mask_from_upload(mask, images[0]) if mask and len(images) == 1 else None
255
+ with user_errors():
256
+ result = bria_api.vgl_edit(
257
+ instruction=instruction, images=images, mask=mask_img, seed=int(seed)
258
+ )
259
+ return {"json_instruction": pretty(result) if isinstance(result, str) else result}
260
+
261
+
262
+ @server.api(name="edit", concurrency_limit=1)
263
+ def api_edit(
264
+ source: str,
265
+ instruction: str = "",
266
+ json_instruction: str = "",
267
+ ref2: str | None = None,
268
+ ref3: str | None = None,
269
+ ref4: str | None = None,
270
+ mask: str | None = None,
271
+ seed: float = 42,
272
+ randomize: bool = True,
273
+ steps: float = STEPS,
274
+ ) -> dict:
275
+ """Edit the source (+ up to 3 references, optional mask) with Fibo 2.0 Edit Turbo."""
276
+ if source is None:
277
+ raise gr.Error("Upload a source image first.")
278
+ steps = checked_steps(steps)
279
+ images = [_open_rgb(p) for p in (source, ref2, ref3, ref4) if p]
280
+ mask_img = _mask_from_upload(mask, images[0]) if mask else None
281
+ if mask_img is not None and len(images) != 1:
282
+ raise gr.Error("A mask only works with a single reference image.")
283
+ seed_value = resolve_seed(seed, randomize)
284
+ json_prompt = (json_instruction or "").strip()
285
  if not json_prompt:
286
  with user_errors():
287
  json_prompt = bria_api.vgl_edit(
288
+ instruction=instruction, images=images, mask=mask_img, seed=seed_value
289
  )
290
  json_prompt = as_prompt(json_prompt)
291
+ result = render(json_prompt, seed_value, steps, images, mask=mask_img)
292
+ return {
293
+ "image_data": _as_data_uri(_save_tmp(result)),
294
+ "seed": seed_value,
295
+ "json_instruction": pretty(json_prompt),
296
+ }
297
+
298
+
299
+ # The @server.api endpoints are deferred until Server.launch(), which also fires
300
+ # the ZeroGPU registration hook (pack weights + startup report) through the
301
+ # patched Blocks.launch path. Launch on a loopback port for registration, then
302
+ # serve the same app via uvicorn.
303
+ _port_env = os.environ.get("GRADIO_SERVER_PORT")
304
+ os.environ["GRADIO_SERVER_PORT"] = "7861"
305
+ try:
306
+ server.launch(
307
+ prevent_thread_lock=True,
308
+ server_name="127.0.0.1",
309
+ server_port=7861,
310
+ quiet=True,
311
+ show_error=False,
312
+ ssr_mode=False,
313
+ )
314
+ finally:
315
+ if _port_env is None:
316
+ del os.environ["GRADIO_SERVER_PORT"]
317
+ else:
318
+ os.environ["GRADIO_SERVER_PORT"] = _port_env
319
+
320
+ try:
321
+ server.close()
322
+ except Exception:
323
+ pass
324
+
325
+ # ------------------------------------------------------------------ workflow canvas
326
+ # Mounted only after the registration launch above. Mounting first starts the
327
+ # workflow's queue on the loopback server's event loop, and close() then strands
328
+ # it: jobs are accepted on port 7860 but never run.
329
+
330
+ workflow_demo = gr.Workflow(
331
+ graph="workflow.json",
332
+ bind={"fibo_edit": fibo_edit},
 
 
 
 
 
 
 
333
  )
334
+ workflow_demo.title = "FIBO 2.0 Edit Turbo Workflow"
335
 
336
+ server = gr.mount_gradio_app(server, workflow_demo, path="/workflow", ssr_mode=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
 
338
+ if __name__ == "__main__":
339
+ uvicorn.run(server, host="0.0.0.0", port=7860)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
studio.html ADDED
@@ -0,0 +1,751 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>FIBO 2.0 Edit Turbo Studio</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0e0e10; --panel: #16171a; --panel2: #101114; --raised: #1e2024;
10
+ --border: rgba(255,255,255,0.07);
11
+ --text: rgba(255,255,255,0.87); --muted: rgba(255,255,255,0.5); --dim: rgba(255,255,255,0.35);
12
+ --accent: #7c5cff; --accent2: #8a3dff; --accent-soft: rgba(124,92,255,0.14);
13
+ --radius: 10px;
14
+ }
15
+ * { margin: 0; padding: 0; box-sizing: border-box; }
16
+ [hidden] { display: none !important; }
17
+ body {
18
+ font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
19
+ background: var(--bg); color: var(--text); height: 100vh;
20
+ display: flex; flex-direction: column; overflow: hidden;
21
+ font-size: 13px; -webkit-font-smoothing: antialiased;
22
+ }
23
+ .topbar {
24
+ display: flex; align-items: center; gap: 16px; padding: 8px 16px;
25
+ background: var(--panel); border-bottom: 1px solid var(--border); flex-shrink: 0;
26
+ }
27
+ .brand { font-weight: 700; font-size: 0.92rem; white-space: nowrap; letter-spacing: -0.01em; }
28
+ .mode-tabs { display: flex; gap: 2px; background: var(--panel2); border: 1px solid var(--border); border-radius: 9px; padding: 3px; }
29
+ .mode-tabs button {
30
+ border: none; border-radius: 7px; padding: 6px 16px; font-weight: 550; font-size: 0.82rem;
31
+ background: transparent; color: var(--muted); cursor: pointer; transition: color 0.15s, background 0.15s;
32
+ }
33
+ .mode-tabs button:hover { color: var(--text); }
34
+ .mode-tabs button.active { background: var(--raised); color: var(--text); box-shadow: 0 1px 3px rgba(0,0,0,0.45); }
35
+ .spacer { flex: 1; }
36
+ .ml-badge { color: var(--muted); text-decoration: none; font-size: 0.8rem; font-weight: 500; transition: color 0.15s; white-space: nowrap; }
37
+ .ml-badge:hover { color: var(--text); }
38
+ .ml-cta {
39
+ color: var(--accent); text-decoration: none; font-size: 0.8rem; font-weight: 600;
40
+ padding: 5px 13px; border: 1px solid var(--accent); border-radius: 999px;
41
+ background: var(--accent-soft); transition: filter 0.15s; white-space: nowrap;
42
+ }
43
+ .ml-cta:hover { filter: brightness(1.2); }
44
+ .main { display: flex; flex: 1; min-height: 0; }
45
+ .rail {
46
+ width: 52px; flex-shrink: 0; display: flex; flex-direction: column; align-items: center;
47
+ gap: 6px; padding: 10px 0; background: var(--panel); border-right: 1px solid var(--border);
48
+ }
49
+ .rail button {
50
+ width: 38px; height: 38px; border: 1px solid transparent; border-radius: 8px;
51
+ background: transparent; color: var(--muted); cursor: pointer;
52
+ display: flex; align-items: center; justify-content: center;
53
+ transition: color 0.15s, background 0.15s;
54
+ }
55
+ .rail button:hover { background: var(--panel2); color: var(--text); }
56
+ .rail button.active { color: var(--accent); background: var(--accent-soft); }
57
+ .viewport { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: auto; position: relative; }
58
+ .workflow-view { position: absolute; inset: 0; padding: 12px; }
59
+ .workflow-view iframe { width: 100%; height: 100%; border: none; border-radius: 10px; background: #0c0d10; }
60
+ .doc-grid { flex: 1; min-height: 0; display: flex; gap: 16px; width: 100%; align-items: stretch; justify-content: center; padding: 20px 20px 10px; }
61
+ .doc {
62
+ flex: 1; min-width: 0; max-width: 640px; display: flex; flex-direction: column;
63
+ background: var(--panel); border: 1px solid var(--border); border-radius: 10px; overflow: hidden;
64
+ }
65
+ .doc figcaption {
66
+ padding: 8px 12px; font-size: 0.62rem; font-weight: 650; letter-spacing: 0.12em;
67
+ color: var(--dim); border-bottom: 1px solid var(--border); text-transform: uppercase;
68
+ display: flex; align-items: center;
69
+ }
70
+ .doc .stage {
71
+ position: relative; flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center;
72
+ overflow: hidden; background-color: #0b0b0d;
73
+ background-image:
74
+ linear-gradient(45deg, #141419 25%, transparent 25%),
75
+ linear-gradient(-45deg, #141419 25%, transparent 25%),
76
+ linear-gradient(45deg, transparent 75%, #141419 75%),
77
+ linear-gradient(-45deg, transparent 75%, #141419 75%);
78
+ background-size: 20px 20px;
79
+ background-position: 0 0, 0 10px, 10px -10px, -10px 0;
80
+ }
81
+ .doc img { max-width: 100%; max-height: 100%; object-fit: contain; display: block; }
82
+ .doc img.zoomed { max-width: none; max-height: none; }
83
+ .doc .stage.scrollable { overflow: auto; justify-content: flex-start; align-items: flex-start; }
84
+ #mask-canvas { position: absolute; inset: 0; cursor: crosshair; display: none; }
85
+ #mask-canvas.active { display: block; }
86
+ .drop-hint { color: var(--dim); font-size: 0.85rem; text-align: center; padding: 20px; }
87
+ .drop-hint b { color: var(--muted); display: block; margin-bottom: 6px; }
88
+ .zoom-btn {
89
+ margin-left: auto; border: 1px solid var(--border); background: transparent; color: var(--muted);
90
+ border-radius: 5px; font-size: 0.62rem; padding: 2px 8px; cursor: pointer; letter-spacing: 0.08em;
91
+ }
92
+ .zoom-btn:hover { color: var(--text); border-color: var(--accent); }
93
+ .doc-bar {
94
+ display: flex; align-items: center; gap: 10px; padding: 6px 12px;
95
+ border-top: 1px solid var(--border); background: var(--panel);
96
+ font-size: 0.72rem; color: var(--muted); flex-shrink: 0;
97
+ }
98
+ .doc-bar button {
99
+ border: 1px solid var(--border); background: transparent; color: var(--muted);
100
+ border-radius: 5px; padding: 3px 10px; font-size: 0.68rem; cursor: pointer; letter-spacing: 0.06em;
101
+ }
102
+ .doc-bar button:hover, .doc-bar button.active { color: var(--text); border-color: var(--accent); }
103
+ .doc-bar button.active { background: var(--accent-soft); color: var(--accent); }
104
+ .split-wrap { position: absolute; inset: 0; overflow: hidden; }
105
+ .split-wrap img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
106
+ .split-divider {
107
+ position: absolute; top: 0; bottom: 0; width: 3px; background: var(--accent);
108
+ cursor: ew-resize; z-index: 5; box-shadow: 0 0 8px rgba(124,92,255,0.6);
109
+ }
110
+ .split-divider::after {
111
+ content: "◂ ▸"; position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
112
+ background: var(--accent); color: white; border-radius: 999px; padding: 4px 7px;
113
+ font-size: 0.6rem; white-space: nowrap;
114
+ }
115
+ .history-strip {
116
+ display: flex; gap: 8px; padding: 8px 12px; overflow-x: auto; flex-shrink: 0;
117
+ background: var(--panel); border-top: 1px solid var(--border); align-items: center;
118
+ }
119
+ .history-strip .hlabel { font-size: 0.62rem; font-weight: 650; letter-spacing: 0.14em; color: var(--dim); text-transform: uppercase; white-space: nowrap; }
120
+ .history-chip {
121
+ width: 52px; height: 52px; border-radius: 6px; border: 1px solid var(--border);
122
+ overflow: hidden; cursor: pointer; flex-shrink: 0; position: relative;
123
+ }
124
+ .history-chip:hover { border-color: var(--accent); }
125
+ .history-chip img { width: 100%; height: 100%; object-fit: cover; }
126
+ .history-chip .hseed {
127
+ position: absolute; bottom: 0; left: 0; right: 0; background: rgba(0,0,0,0.65);
128
+ color: #ddd; font-size: 0.52rem; padding: 1px 3px; text-align: center;
129
+ }
130
+ .panels {
131
+ width: 320px; flex-shrink: 0; display: flex; flex-direction: column; gap: 10px;
132
+ padding: 12px; overflow-y: auto; background: var(--panel); border-left: 1px solid var(--border);
133
+ }
134
+ .panel { background: var(--panel2); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; }
135
+ .panel-title { font-size: 0.62rem; font-weight: 650; letter-spacing: 0.12em; color: var(--dim); text-transform: uppercase; margin-bottom: 10px; cursor: pointer; user-select: none; display: flex; align-items: center; justify-content: space-between; }
136
+ .panel .panel-title::after { content: "▾"; font-size: 0.7rem; color: var(--dim); }
137
+ .panel.collapsed .panel-title::after { content: "▸"; }
138
+ .panel.collapsed .panel-body { display: none; }
139
+ .panel label { display: block; font-size: 0.72rem; font-weight: 550; color: var(--muted); margin: 10px 0 4px; }
140
+ .panel label:first-of-type { margin-top: 0; }
141
+ textarea, input[type="text"], input[type="number"] {
142
+ width: 100%; background: var(--raised); color: var(--text); border: 1px solid var(--border);
143
+ border-radius: 7px; padding: 8px 10px; font-family: inherit; font-size: 0.83rem; resize: vertical;
144
+ transition: border-color 0.15s, box-shadow 0.15s;
145
+ }
146
+ textarea:focus, input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
147
+ textarea.code { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 0.75rem; }
148
+ .row { display: flex; gap: 8px; align-items: center; }
149
+ .row > * { flex: 1; }
150
+ input[type="range"] {
151
+ -webkit-appearance: none; appearance: none; width: 100%; height: 3px;
152
+ border-radius: 2px; background: var(--raised); outline: none;
153
+ }
154
+ input[type="range"]::-webkit-slider-thumb {
155
+ -webkit-appearance: none; appearance: none; width: 14px; height: 14px; border-radius: 50%;
156
+ background: #fff; border: none; box-shadow: 0 1px 4px rgba(0,0,0,0.55); cursor: pointer;
157
+ transition: transform 0.12s;
158
+ }
159
+ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.2); }
160
+ input[type="range"]::-moz-range-thumb {
161
+ width: 14px; height: 14px; border-radius: 50%; background: #fff; border: none;
162
+ box-shadow: 0 1px 4px rgba(0,0,0,0.55); cursor: pointer;
163
+ }
164
+ .switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; }
165
+ .switch input { display: none; }
166
+ .switch .sw {
167
+ display: inline-block;
168
+ width: 34px; height: 20px; border-radius: 999px; background: var(--raised);
169
+ border: 1px solid var(--border); position: relative; flex-shrink: 0; transition: background 0.18s, border-color 0.18s;
170
+ }
171
+ .switch .sw::after {
172
+ content: ""; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px;
173
+ border-radius: 50%; background: var(--muted); transition: transform 0.18s, background 0.18s;
174
+ }
175
+ .switch input:checked + .sw { background: var(--accent); border-color: var(--accent); }
176
+ .switch input:checked + .sw::after { transform: translateX(14px); background: #fff; }
177
+ .switch .sw-label { font-size: 0.75rem; color: var(--muted); }
178
+ .layer-row {
179
+ display: flex; align-items: center; gap: 10px; padding: 6px 8px;
180
+ border: 1px solid var(--border); border-radius: 6px; margin-top: 6px;
181
+ background: var(--raised); cursor: pointer;
182
+ }
183
+ .layer-row:hover { border-color: var(--accent); }
184
+ .layer-row .thumb {
185
+ width: 40px; height: 40px; border-radius: 4px; object-fit: cover; flex-shrink: 0;
186
+ background: #101014; border: 1px solid var(--border);
187
+ display: flex; align-items: center; justify-content: center;
188
+ color: var(--dim); font-size: 0.65rem; text-align: center;
189
+ }
190
+ .layer-row .thumb img { width: 100%; height: 100%; object-fit: cover; border-radius: 3px; }
191
+ .layer-row .lname { flex: 1; font-size: 0.78rem; color: var(--text); }
192
+ .layer-row .lsub { font-size: 0.62rem; color: var(--dim); display: block; }
193
+ .layer-row .ldel {
194
+ border: none; background: transparent; color: var(--dim); cursor: pointer;
195
+ font-size: 0.9rem; padding: 2px 6px; border-radius: 4px;
196
+ }
197
+ .layer-row .ldel:hover { color: #ff6f91; background: rgba(255,111,145,0.1); }
198
+ .layer-row.mask-layer .thumb { border-color: var(--accent); }
199
+ .ref-slot {
200
+ height: 72px; border: 1px dashed var(--border); border-radius: 6px; margin-top: 8px;
201
+ display: flex; align-items: center; justify-content: center; cursor: pointer; overflow: hidden;
202
+ color: var(--dim); font-size: 0.72rem; background: var(--raised);
203
+ }
204
+ .ref-slot:hover { border-color: var(--accent); }
205
+ .ref-slot img { width: 100%; height: 100%; object-fit: cover; }
206
+ .hint { font-size: 0.68rem; color: var(--dim); margin-top: 8px; line-height: 1.4; }
207
+ .ex-row { display: flex; gap: 8px; overflow-x: auto; }
208
+ .ex-chip {
209
+ flex: 1; min-width: 76px; border: 1px solid var(--border); border-radius: 8px;
210
+ overflow: hidden; cursor: pointer; background: var(--raised); padding: 0;
211
+ text-align: left; transition: border-color 0.15s;
212
+ }
213
+ .ex-chip:hover { border-color: var(--accent); }
214
+ .ex-chip img { width: 100%; height: 64px; object-fit: cover; display: block; }
215
+ .ex-chip span { display: block; padding: 5px 7px; font-size: 0.62rem; color: var(--muted); line-height: 1.25; }
216
+ #run-btn {
217
+ margin-top: 4px; padding: 12px; border: none; border-radius: 8px; cursor: pointer;
218
+ background: var(--accent); color: white;
219
+ font-weight: 600; font-size: 0.9rem; letter-spacing: 0.01em;
220
+ transition: filter 0.15s, transform 0.05s;
221
+ }
222
+ #run-btn:hover { filter: brightness(1.12); }
223
+ #run-btn:active { transform: translateY(1px); }
224
+ #run-btn:disabled { opacity: 0.5; cursor: wait; }
225
+ .statusbar {
226
+ display: flex; align-items: center; gap: 16px; padding: 7px 16px; flex-shrink: 0;
227
+ background: var(--panel); border-top: 1px solid var(--border);
228
+ font-size: 0.75rem; color: var(--muted);
229
+ }
230
+ .statusbar .pulse { width: 8px; height: 8px; border-radius: 50%; background: #4fd1a5; }
231
+ .statusbar.busy .pulse { background: var(--accent); animation: pulse 1s infinite alternate; }
232
+ @keyframes pulse { from { opacity: 0.4; } to { opacity: 1; } }
233
+ #file-input { display: none; }
234
+ @media (max-width: 860px) {
235
+ html, body { height: auto !important; overflow: visible !important; }
236
+ body { display: block; }
237
+ .topbar { flex-wrap: wrap; gap: 8px; padding: 8px 12px; }
238
+ .brand { font-size: 0.88rem; }
239
+ .topbar .ml-badge { display: none; }
240
+ .main { display: block; height: auto; min-height: 0; }
241
+ .rail {
242
+ flex-direction: row; width: 100%; justify-content: center; gap: 10px;
243
+ padding: 6px 10px; border-right: none; border-bottom: 1px solid var(--border);
244
+ }
245
+ .rail button { width: 44px; height: 44px; }
246
+ .viewport { display: block; padding: 10px; overflow: visible; }
247
+ .workflow-view { position: static; height: 78vh; padding: 0 0 10px; }
248
+ .doc-grid { flex-direction: column; align-items: stretch; height: auto; }
249
+ .doc { max-width: 100%; width: 100%; }
250
+ .doc .stage { min-height: 220px; }
251
+ .panels {
252
+ width: 100%; border-left: none; border-top: 1px solid var(--border);
253
+ padding: 10px 12px; overflow-y: visible;
254
+ }
255
+ .mode-tabs button { padding: 6px 13px; font-size: 0.8rem; }
256
+ .statusbar { position: sticky; bottom: 0; }
257
+ }
258
+ </style>
259
+ </head>
260
+ <body>
261
+ <header class="topbar">
262
+ <div class="brand"><img src="/bria_logo.png" alt="BRIA" style="height:22px;vertical-align:middle;margin-right:8px" />FIBO 2.0 Edit Turbo</div>
263
+ <div class="mode-tabs">
264
+ <button id="mode-edit" class="active">Edit</button>
265
+ <button id="mode-workflow">Workflow</button>
266
+ </div>
267
+ <div class="spacer"></div>
268
+ <a class="ml-badge" href="https://huggingface.co/briaai/Fibo-2.0-edit-turbo" target="_blank" rel="noopener">Model</a>
269
+ <a class="ml-cta" href="https://arxiv.org/abs/2511.06876" target="_blank" rel="noopener">arXiv</a>
270
+ </header>
271
+
272
+ <div class="main">
273
+ <div class="rail">
274
+ <button id="tool-upload" title="Upload source image"><svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg></button>
275
+ <button id="tool-brush" title="Brush mask (single reference)"><svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.07"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg></button>
276
+ <button id="tool-clear-mask" title="Clear mask"><svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20H8L3 15a2 2 0 0 1 0-2.83l7.17-7.17a2 2 0 0 1 2.83 0l5.66 5.66a2 2 0 0 1 0 2.83L13 19"/><line x1="18" y1="11" x2="9" y2="20"/></svg></button>
277
+ <button id="tool-clear-doc" title="Clear document"><svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>
278
+ </div>
279
+
280
+ <div class="viewport">
281
+ <div class="workflow-view" id="workflow-view" hidden>
282
+ <iframe src="/workflow/?ui=canvas" title="Workflow canvas"></iframe>
283
+ </div>
284
+ <div class="doc-grid">
285
+ <figure class="doc" id="doc-before">
286
+ <figcaption id="before-caption">Source</figcaption>
287
+ <div class="stage" id="before-stage">
288
+ <img id="source-img" alt="" hidden />
289
+ <canvas id="mask-canvas"></canvas>
290
+ <div class="drop-hint" id="source-hint"><b>No source image</b>Use the upload tool or drop a file here</div>
291
+ </div>
292
+ </figure>
293
+ <figure class="doc" id="doc-after">
294
+ <figcaption id="after-caption">Result</figcaption>
295
+ <div class="stage" id="after-stage">
296
+ <img id="result-img" alt="" hidden />
297
+ <div class="split-wrap" id="split-wrap" hidden>
298
+ <img id="split-source" alt="" style="clip-path: inset(0 50% 0 0)" />
299
+ <div class="split-divider" id="split-divider" style="left:50%"></div>
300
+ </div>
301
+ <div class="drop-hint" id="result-hint"><b>Nothing yet</b>Run to see the result</div>
302
+ </div>
303
+ <div class="doc-bar">
304
+ <span id="result-meta">—</span>
305
+ <div class="spacer"></div>
306
+ <button id="split-toggle" hidden>SPLIT</button>
307
+ </div>
308
+ </figure>
309
+ </div>
310
+ <div class="history-strip" id="history-strip"><span class="hlabel">History</span></div>
311
+ </div>
312
+
313
+ <aside class="panels">
314
+ <section class="panel">
315
+ <div class="panel-title">Examples</div>
316
+ <div class="ex-row">
317
+ <button class="ex-chip" data-img="/example_images/edit_cat.jpg" data-instruction="Put a small party hat on the cat">
318
+ <img src="/example_images/edit_cat.jpg" alt="" /><span>Party hat on the cat</span>
319
+ </button>
320
+ <button class="ex-chip" data-img="/example_images/edit_bicycle.jpg" data-instruction="Change the bicycle color to bright red">
321
+ <img src="/example_images/edit_bicycle.jpg" alt="" /><span>Red bicycle</span>
322
+ </button>
323
+ <button class="ex-chip" data-img="/example_images/edit_portrait.jpg" data-instruction="Add round glasses">
324
+ <img src="/example_images/edit_portrait.jpg" alt="" /><span>Round glasses</span>
325
+ </button>
326
+ </div>
327
+ </section>
328
+ <section class="panel">
329
+ <div class="panel-title">Instruction</div>
330
+ <label for="instruction">What should change?</label>
331
+ <textarea id="instruction" rows="3" placeholder="make the sky dramatic and stormy"></textarea>
332
+ <label for="json-instruction">JSON override (optional; auto-filled by the VGL prompt writer)</label>
333
+ <textarea id="json-instruction" class="code" rows="5" placeholder='{"edit_instruction": "…"}'></textarea>
334
+ </section>
335
+ <section class="panel">
336
+ <div class="panel-title">Layers</div>
337
+ <div class="layer-row" id="layer-source">
338
+ <div class="thumb" id="layer-source-thumb">BG</div>
339
+ <div class="lname">Background<span class="lsub">source image</span></div>
340
+ </div>
341
+ <div class="layer-row mask-layer" id="layer-mask" hidden>
342
+ <div class="thumb" id="layer-mask-thumb"></div>
343
+ <div class="lname">Mask<span class="lsub">repaint region</span></div>
344
+ <button class="ldel" id="layer-mask-del" title="Clear mask">✕</button>
345
+ </div>
346
+ <div class="ref-slot" data-ref="2">+ Reference 2</div>
347
+ <div class="ref-slot" data-ref="3">+ Reference 3</div>
348
+ <div class="ref-slot" data-ref="4">+ Reference 4</div>
349
+ <div class="hint">Slots map to &lt;image_1&gt;…&lt;image_4&gt; in the instruction. A mask only works with a single reference.</div>
350
+ </section>
351
+ <section class="panel">
352
+ <div class="panel-title">Advanced</div>
353
+ <div class="row">
354
+ <div><label for="seed">Seed</label><input type="number" id="seed" value="42" min="1" /></div>
355
+ <label class="switch"><input type="checkbox" id="randomize" checked /><span class="sw"></span><span class="sw-label">Randomize</span></label>
356
+ </div>
357
+ <div class="row" style="margin-top:10px">
358
+ <div><label>Steps <span id="steps-val">4</span></label><input type="range" id="steps" min="1" max="8" value="4" /></div>
359
+ </div>
360
+ <div class="hint">Edit Turbo is distilled for 4 guidance-free steps.</div>
361
+ </section>
362
+ <button id="run-btn">Generate Edit</button>
363
+ </aside>
364
+ </div>
365
+
366
+ <footer class="statusbar" id="statusbar">
367
+ <div class="pulse"></div>
368
+ <span id="status">Idle</span>
369
+ <div class="spacer"></div>
370
+ <span id="seed-out"></span>
371
+ </footer>
372
+
373
+ <input type="file" id="file-input" accept="image/*" />
374
+
375
+ <script type="module">
376
+ import { client } from "https://cdn.jsdelivr.net/npm/@gradio/client@2.7.1/dist/index.min.js";
377
+
378
+ const $ = (id) => document.getElementById(id);
379
+ const state = { mode: "edit", sourcePath: null, refPaths: { 2: null, 3: null, 4: null }, app: null };
380
+ const docState = { source: null, result: null, history: [] };
381
+
382
+ async function getApp() {
383
+ if (!state.app) state.app = await client(location.origin);
384
+ return state.app;
385
+ }
386
+
387
+ async function uploadFile(file) {
388
+ const form = new FormData();
389
+ form.append("files", file);
390
+ const resp = await fetch("/gradio_api/upload", { method: "POST", body: form });
391
+ if (!resp.ok) throw new Error(`Upload failed (${resp.status})`);
392
+ const paths = await resp.json();
393
+ return Array.isArray(paths) ? paths[0] : paths;
394
+ }
395
+
396
+ function setStatus(text, busy = false) {
397
+ $("status").textContent = text;
398
+ $("statusbar").classList.toggle("busy", busy);
399
+ $("run-btn").disabled = busy;
400
+ }
401
+
402
+ function setMode(mode) {
403
+ state.mode = mode;
404
+ $("mode-edit").classList.toggle("active", mode === "edit");
405
+ $("mode-workflow").classList.toggle("active", mode === "workflow");
406
+ const workflow = mode === "workflow";
407
+ $("workflow-view").hidden = !workflow;
408
+ document.querySelector(".doc-grid").style.display = workflow ? "none" : "";
409
+ document.querySelector(".rail").style.display = workflow ? "none" : "";
410
+ document.querySelector(".panels").style.display = workflow ? "none" : "";
411
+ document.getElementById("history-strip").style.display = workflow ? "none" : "";
412
+ }
413
+ $("mode-edit").onclick = () => setMode("edit");
414
+ $("mode-workflow").onclick = () => setMode("workflow");
415
+
416
+ // ---------- uploads ----------
417
+ let uploadTarget = "source";
418
+ function pickFile(target) {
419
+ uploadTarget = target;
420
+ $("file-input").click();
421
+ }
422
+ $("tool-upload").onclick = () => pickFile("source");
423
+ document.querySelectorAll(".ref-slot").forEach((slot) => {
424
+ slot.onclick = () => pickFile(slot.dataset.ref);
425
+ });
426
+ $("file-input").onchange = async (e) => {
427
+ const file = e.target.files[0];
428
+ e.target.value = "";
429
+ if (!file) return;
430
+ setStatus("Uploading…", true);
431
+ try {
432
+ const path = await uploadFile(file);
433
+ if (uploadTarget === "source") {
434
+ state.sourcePath = path;
435
+ showSource(file);
436
+ } else {
437
+ state.refPaths[uploadTarget] = path;
438
+ const slot = document.querySelector(`.ref-slot[data-ref="${uploadTarget}"]`);
439
+ slot.innerHTML = "";
440
+ const img = document.createElement("img");
441
+ img.src = URL.createObjectURL(file);
442
+ slot.appendChild(img);
443
+ }
444
+ setStatus("Idle");
445
+ } catch (err) {
446
+ setStatus(String(err));
447
+ }
448
+ };
449
+ $("before-stage").addEventListener("dragover", (e) => e.preventDefault());
450
+ $("before-stage").addEventListener("drop", async (e) => {
451
+ e.preventDefault();
452
+ const file = e.dataTransfer.files[0];
453
+ if (!file) return;
454
+ setStatus("Uploading…", true);
455
+ try {
456
+ state.sourcePath = await uploadFile(file);
457
+ showSource(file);
458
+ setStatus("Idle");
459
+ } catch (err) {
460
+ setStatus(String(err));
461
+ }
462
+ });
463
+
464
+ function showSource(file) {
465
+ const img = $("source-img");
466
+ img.src = URL.createObjectURL(file);
467
+ img.hidden = false;
468
+ $("source-hint").style.display = "none";
469
+ img.onload = () => sizeMaskCanvas();
470
+ clearMask();
471
+ $("json-instruction").value = "";
472
+ docState.source = img.src;
473
+ const st = $("layer-source-thumb");
474
+ st.innerHTML = "";
475
+ const stImg = document.createElement("img");
476
+ stImg.src = img.src;
477
+ st.appendChild(stImg);
478
+ }
479
+
480
+ // ---------- mask brush ----------
481
+ const maskCanvas = $("mask-canvas");
482
+ const mctx = maskCanvas.getContext("2d");
483
+ let strokes = [];
484
+ let drawing = false;
485
+ function sizeMaskCanvas() {
486
+ const stage = $("before-stage");
487
+ const img = $("source-img");
488
+ if (!img.src || img.hidden) return;
489
+ const box = img.getBoundingClientRect();
490
+ const stageBox = stage.getBoundingClientRect();
491
+ maskCanvas.width = box.width;
492
+ maskCanvas.height = box.height;
493
+ maskCanvas.style.left = box.left - stageBox.left + "px";
494
+ maskCanvas.style.top = box.top - stageBox.top + "px";
495
+ maskCanvas.style.width = box.width + "px";
496
+ maskCanvas.style.height = box.height + "px";
497
+ mctx.fillStyle = "#000";
498
+ mctx.fillRect(0, 0, maskCanvas.width, maskCanvas.height);
499
+ }
500
+ function brushPos(e) {
501
+ const box = maskCanvas.getBoundingClientRect();
502
+ return { x: e.clientX - box.left, y: e.clientY - box.top };
503
+ }
504
+ maskCanvas.addEventListener("pointerdown", (e) => { drawing = true; strokes.push([brushPos(e)]); });
505
+ maskCanvas.addEventListener("pointermove", (e) => {
506
+ if (!drawing) return;
507
+ strokes[strokes.length - 1].push(brushPos(e));
508
+ redrawMask();
509
+ });
510
+ window.addEventListener("pointerup", () => {
511
+ drawing = false;
512
+ if (state.brushOn && strokes.length) {
513
+ $("layer-mask").hidden = false;
514
+ const mt = $("layer-mask-thumb");
515
+ mt.innerHTML = "";
516
+ const t = document.createElement("img");
517
+ t.src = maskCanvas.toDataURL();
518
+ mt.appendChild(t);
519
+ }
520
+ });
521
+ function redrawMask() {
522
+ mctx.fillStyle = "#000";
523
+ mctx.fillRect(0, 0, maskCanvas.width, maskCanvas.height);
524
+ mctx.strokeStyle = "#fff";
525
+ mctx.lineWidth = Math.max(16, maskCanvas.width / 40);
526
+ mctx.lineCap = "round";
527
+ mctx.lineJoin = "round";
528
+ for (const stroke of strokes) {
529
+ mctx.beginPath();
530
+ stroke.forEach((p, i) => (i ? mctx.lineTo(p.x, p.y) : mctx.moveTo(p.x, p.y)));
531
+ mctx.stroke();
532
+ }
533
+ mctx.save();
534
+ mctx.globalCompositeOperation = "source-atop";
535
+ mctx.fillStyle = "rgba(124,92,255,0.35)";
536
+ mctx.fillRect(0, 0, maskCanvas.width, maskCanvas.height);
537
+ mctx.restore();
538
+ }
539
+ function clearMask() {
540
+ strokes = [];
541
+ maskCanvas.classList.remove("active");
542
+ $("tool-brush").classList.remove("active");
543
+ mctx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
544
+ $("layer-mask").hidden = true;
545
+ }
546
+ state.brushOn = false;
547
+ $("tool-brush").onclick = () => {
548
+ if (state.sourcePath == null) { setStatus("Upload a source image first."); return; }
549
+ state.brushOn = !state.brushOn;
550
+ maskCanvas.classList.toggle("active", state.brushOn);
551
+ $("tool-brush").classList.toggle("active", state.brushOn);
552
+ if (state.brushOn) { sizeMaskCanvas(); redrawMask(); setStatus("Brush the area to repaint."); }
553
+ else setStatus("Idle");
554
+ };
555
+ $("tool-clear-mask").onclick = clearMask;
556
+ $("layer-mask-del").onclick = (e) => { e.stopPropagation(); clearMask(); };
557
+ $("tool-clear-doc").onclick = () => {
558
+ state.sourcePath = null;
559
+ docState.source = null;
560
+ docState.result = null;
561
+ $("source-img").hidden = true;
562
+ $("source-hint").style.display = "";
563
+ $("result-img").hidden = true;
564
+ $("result-hint").style.display = "";
565
+ $("layer-source-thumb").textContent = "BG";
566
+ clearMask();
567
+ };
568
+
569
+ async function maskPath() {
570
+ if (!strokes.length) return null;
571
+ const img = $("source-img");
572
+ const out = document.createElement("canvas");
573
+ out.width = img.naturalWidth;
574
+ out.height = img.naturalHeight;
575
+ const octx = out.getContext("2d");
576
+ octx.fillStyle = "#000";
577
+ octx.fillRect(0, 0, out.width, out.height);
578
+ const sx = out.width / maskCanvas.width;
579
+ const sy = out.height / maskCanvas.height;
580
+ octx.strokeStyle = "#fff";
581
+ octx.lineWidth = Math.max(16, maskCanvas.width / 40) * sx;
582
+ octx.lineCap = "round";
583
+ octx.lineJoin = "round";
584
+ for (const stroke of strokes) {
585
+ octx.beginPath();
586
+ stroke.forEach((p, i) => (i ? octx.lineTo(p.x * sx, p.y * sy) : octx.moveTo(p.x * sx, p.y * sy)));
587
+ octx.stroke();
588
+ }
589
+ const blob = await new Promise((r) => out.toBlob(r, "image/png"));
590
+ return uploadFile(blob);
591
+ }
592
+
593
+ // ---------- history ----------
594
+ function renderHistory() {
595
+ const strip = $("history-strip");
596
+ strip.innerHTML = '<span class="hlabel">History</span>';
597
+ docState.history.forEach((h) => {
598
+ const chip = document.createElement("div");
599
+ chip.className = "history-chip";
600
+ chip.title = h.instruction || "";
601
+ const img = document.createElement("img");
602
+ img.src = h.uri;
603
+ const tag = document.createElement("div");
604
+ tag.className = "hseed";
605
+ tag.textContent = h.seed ?? "";
606
+ chip.appendChild(img);
607
+ chip.appendChild(tag);
608
+ chip.onclick = () => {
609
+ $("instruction").value = h.instruction;
610
+ $("seed").value = h.seed ?? 42;
611
+ $("randomize").checked = false;
612
+ docState.result = h.uri;
613
+ $("result-img").src = h.uri;
614
+ $("result-img").hidden = false;
615
+ $("result-hint").style.display = "none";
616
+ $("split-toggle").hidden = false;
617
+ };
618
+ strip.appendChild(chip);
619
+ });
620
+ }
621
+ function pushHistory(uri, seed, instruction) {
622
+ docState.history.unshift({ uri, seed, instruction });
623
+ if (docState.history.length > 8) docState.history.pop();
624
+ renderHistory();
625
+ }
626
+
627
+ // ---------- before/after split view ----------
628
+ let splitOn = false;
629
+ $("split-toggle").onclick = () => {
630
+ if ($("result-img").hidden) return;
631
+ splitOn = !splitOn;
632
+ $("split-toggle").classList.toggle("active", splitOn);
633
+ $("split-wrap").hidden = !splitOn;
634
+ if (splitOn && !$("source-img").hidden) $("split-source").src = $("source-img").src;
635
+ };
636
+ (function () {
637
+ const divider = $("split-divider");
638
+ let dragging = false;
639
+ divider.addEventListener("pointerdown", (e) => { dragging = true; e.preventDefault(); });
640
+ window.addEventListener("pointermove", (e) => {
641
+ if (!dragging) return;
642
+ const stage = $("after-stage").getBoundingClientRect();
643
+ const pct = Math.min(97, Math.max(3, ((e.clientX - stage.left) / stage.width) * 100));
644
+ divider.style.left = pct + "%";
645
+ $("split-source").style.clipPath = `inset(0 ${100 - pct}% 0 0)`;
646
+ });
647
+ window.addEventListener("pointerup", () => { dragging = false; });
648
+ })();
649
+
650
+ // ---------- zoom ----------
651
+ document.querySelectorAll(".doc figcaption").forEach((caption) => {
652
+ const btn = document.createElement("button");
653
+ btn.className = "zoom-btn";
654
+ btn.textContent = "FIT";
655
+ btn.onclick = () => {
656
+ const img = caption.parentElement.querySelector("img");
657
+ const stage = caption.parentElement.querySelector(".stage");
658
+ const zoomed = img.classList.toggle("zoomed");
659
+ stage.classList.toggle("scrollable", zoomed);
660
+ btn.textContent = zoomed ? "1:1" : "FIT";
661
+ };
662
+ caption.appendChild(btn);
663
+ });
664
+
665
+ // ---------- collapsible panels ----------
666
+ document.querySelectorAll(".panel .panel-title").forEach((title) => {
667
+ const body = document.createElement("div");
668
+ body.className = "panel-body";
669
+ [...title.parentElement.children].forEach((child) => {
670
+ if (child !== title) body.appendChild(child);
671
+ });
672
+ title.parentElement.appendChild(body);
673
+ title.onclick = () => title.parentElement.classList.toggle("collapsed");
674
+ });
675
+
676
+ // Changing the instruction or the source makes a previously written JSON stale,
677
+ // like the classic app: clear it so the next run regenerates it.
678
+ $("instruction").addEventListener("input", () => { $("json-instruction").value = ""; });
679
+
680
+ // ---------- examples ----------
681
+ document.querySelectorAll(".ex-chip").forEach((chip) => {
682
+ chip.onclick = async () => {
683
+ setStatus("Loading example…", true);
684
+ try {
685
+ const blob = await (await fetch(chip.dataset.img)).blob();
686
+ const file = new File([blob], "example.jpg", { type: "image/jpeg" });
687
+ state.sourcePath = await uploadFile(file);
688
+ showSource(file);
689
+ $("instruction").value = chip.dataset.instruction;
690
+ setMode("edit");
691
+ setStatus("Example loaded — hit Generate Edit");
692
+ } catch (err) {
693
+ setStatus(String(err));
694
+ }
695
+ };
696
+ });
697
+
698
+ // ---------- result handling ----------
699
+ function unwrap(result) {
700
+ const data = Array.isArray(result?.data) ? result.data[0] : result?.data;
701
+ return data ?? {};
702
+ }
703
+ function showResult(result) {
704
+ const payload = unwrap(result);
705
+ const url = typeof payload.image_data === "string" ? payload.image_data : null;
706
+ if (!url) { setStatus("Run finished but no image came back (see console)."); return false; }
707
+ const img = $("result-img");
708
+ img.src = url;
709
+ img.hidden = false;
710
+ $("result-hint").style.display = "none";
711
+ docState.result = url;
712
+ if (payload.seed != null) $("seed-out").textContent = "seed " + payload.seed;
713
+ $("split-toggle").hidden = false;
714
+ $("result-meta").textContent = "Edited";
715
+ const instruction = ($("json-instruction").value.trim() || $("instruction").value);
716
+ pushHistory(url, payload.seed, instruction);
717
+ return true;
718
+ }
719
+
720
+ $("steps").oninput = () => ($("steps-val").textContent = $("steps").value);
721
+
722
+ $("run-btn").onclick = async () => {
723
+ const app = await getApp();
724
+ if (!state.sourcePath) { setStatus("Upload a source image first."); return; }
725
+ const seed = parseInt($("seed").value, 10) || 42;
726
+ const randomize = $("randomize").checked;
727
+ const steps = parseInt($("steps").value, 10);
728
+ try {
729
+ setStatus("Preparing mask…", true);
730
+ const mask = await maskPath();
731
+ setStatus($("json-instruction").value.trim() ? "Editing…" : "Writing the instruction (VGL)…", true);
732
+ const result = await app.predict("/edit", {
733
+ source: state.sourcePath,
734
+ instruction: $("instruction").value,
735
+ json_instruction: $("json-instruction").value,
736
+ ref2: state.refPaths[2],
737
+ ref3: state.refPaths[3],
738
+ ref4: state.refPaths[4],
739
+ mask,
740
+ seed, randomize, steps,
741
+ });
742
+ const payload = unwrap(result);
743
+ if (payload.json_instruction) $("json-instruction").value = payload.json_instruction;
744
+ if (showResult({ data: [payload] })) setStatus("Done");
745
+ } catch (err) {
746
+ setStatus(err?.message || String(err));
747
+ }
748
+ };
749
+ </script>
750
+ </body>
751
+ </html>
workflow.json ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "schema_version": "2",
3
+ "name": "FIBO 2.0 Edit Turbo Workflow",
4
+ "references": [
5
+ {
6
+ "id": "ref_source",
7
+ "label": "Source Image",
8
+ "role": "reference",
9
+ "asset_type": "image",
10
+ "inputs": [{"id": "in", "label": "Image", "type": "image"}],
11
+ "outputs": [{"id": "out", "label": "Image", "type": "image"}],
12
+ "x": 40,
13
+ "y": 40,
14
+ "width": 220,
15
+ "height": 130,
16
+ "data": {}
17
+ },
18
+ {
19
+ "id": "ref_instruction",
20
+ "label": "Edit Instruction (JSON)",
21
+ "role": "reference",
22
+ "asset_type": "text",
23
+ "inputs": [{"id": "in", "label": "Text", "type": "text"}],
24
+ "outputs": [{"id": "out", "label": "Text", "type": "text"}],
25
+ "x": 40,
26
+ "y": 200,
27
+ "width": 280,
28
+ "height": 140,
29
+ "data": {
30
+ "out": "{\n \"edit_instruction\": \"make the sky dramatic and stormy\"\n}"
31
+ }
32
+ },
33
+ {
34
+ "id": "ref_seed",
35
+ "label": "Seed",
36
+ "role": "reference",
37
+ "asset_type": "text",
38
+ "inputs": [{"id": "in", "label": "Text", "type": "text"}],
39
+ "outputs": [{"id": "out", "label": "Text", "type": "text"}],
40
+ "x": 340,
41
+ "y": 200,
42
+ "width": 130,
43
+ "height": 90,
44
+ "data": {"out": "42"}
45
+ },
46
+ {
47
+ "id": "ref_steps",
48
+ "label": "Steps",
49
+ "role": "reference",
50
+ "asset_type": "text",
51
+ "inputs": [{"id": "in", "label": "Text", "type": "text"}],
52
+ "outputs": [{"id": "out", "label": "Text", "type": "text"}],
53
+ "x": 340,
54
+ "y": 310,
55
+ "width": 130,
56
+ "height": 90,
57
+ "data": {"out": "4"}
58
+ },
59
+ {
60
+ "id": "ref_instruction_2",
61
+ "label": "Second Instruction (JSON)",
62
+ "role": "reference",
63
+ "asset_type": "text",
64
+ "inputs": [{"id": "in", "label": "Text", "type": "text"}],
65
+ "outputs": [{"id": "out", "label": "Text", "type": "text"}],
66
+ "x": 640,
67
+ "y": 40,
68
+ "width": 280,
69
+ "height": 130,
70
+ "data": {
71
+ "out": "{\n \"edit_instruction\": \"turn the scene into night with warm window lights\"\n}"
72
+ }
73
+ }
74
+ ],
75
+ "operators": [
76
+ {
77
+ "id": "op_edit",
78
+ "label": "FIBO Edit",
79
+ "role": "operator",
80
+ "kind": "fn",
81
+ "fn": "fibo_edit",
82
+ "inputs": [
83
+ {"id": "image", "label": "Image", "type": "image", "required": true},
84
+ {"id": "json_instruction", "label": "JSON Instruction", "type": "text", "required": true},
85
+ {"id": "seed", "label": "Seed", "type": "text"},
86
+ {"id": "steps", "label": "Steps", "type": "text"}
87
+ ],
88
+ "outputs": [{"id": "out_0", "label": "Image", "type": "image", "output_index": 0}],
89
+ "x": 520,
90
+ "y": 120,
91
+ "width": 230,
92
+ "height": 160,
93
+ "data": {}
94
+ },
95
+ {
96
+ "id": "op_edit_2",
97
+ "label": "FIBO Edit (chained)",
98
+ "role": "operator",
99
+ "kind": "fn",
100
+ "fn": "fibo_edit",
101
+ "inputs": [
102
+ {"id": "image", "label": "Image", "type": "image", "required": true},
103
+ {"id": "json_instruction", "label": "JSON Instruction", "type": "text", "required": true},
104
+ {"id": "seed", "label": "Seed", "type": "text"},
105
+ {"id": "steps", "label": "Steps", "type": "text"}
106
+ ],
107
+ "outputs": [{"id": "out_0", "label": "Image", "type": "image", "output_index": 0}],
108
+ "x": 960,
109
+ "y": 120,
110
+ "width": 230,
111
+ "height": 160,
112
+ "data": {}
113
+ }
114
+ ],
115
+ "subjects": [
116
+ {
117
+ "id": "sub_edited",
118
+ "label": "Edited Image",
119
+ "role": "subject",
120
+ "asset_type": "image",
121
+ "inputs": [{"id": "in", "label": "Image", "type": "image"}],
122
+ "outputs": [{"id": "out", "label": "Image", "type": "image"}],
123
+ "x": 780,
124
+ "y": 340,
125
+ "width": 230,
126
+ "height": 110,
127
+ "data": {}
128
+ },
129
+ {
130
+ "id": "sub_chained",
131
+ "label": "Final Image",
132
+ "role": "subject",
133
+ "asset_type": "image",
134
+ "inputs": [{"id": "in", "label": "Image", "type": "image"}],
135
+ "outputs": [{"id": "out", "label": "Image", "type": "image"}],
136
+ "x": 1240,
137
+ "y": 120,
138
+ "width": 230,
139
+ "height": 110,
140
+ "data": {}
141
+ }
142
+ ],
143
+ "edges": [
144
+ {"id": "e_source", "from_node_id": "ref_source", "from_port_id": "out", "to_node_id": "op_edit", "to_port_id": "image", "type": "image"},
145
+ {"id": "e_instruction", "from_node_id": "ref_instruction", "from_port_id": "out", "to_node_id": "op_edit", "to_port_id": "json_instruction", "type": "text"},
146
+ {"id": "e_seed", "from_node_id": "ref_seed", "from_port_id": "out", "to_node_id": "op_edit", "to_port_id": "seed", "type": "text"},
147
+ {"id": "e_steps", "from_node_id": "ref_steps", "from_port_id": "out", "to_node_id": "op_edit", "to_port_id": "steps", "type": "text"},
148
+ {"id": "e_out", "from_node_id": "op_edit", "from_port_id": "out_0", "to_node_id": "sub_edited", "to_port_id": "in", "type": "image"},
149
+ {"id": "e_chain", "from_node_id": "op_edit", "from_port_id": "out_0", "to_node_id": "op_edit_2", "to_port_id": "image", "type": "image"},
150
+ {"id": "e_instruction_2", "from_node_id": "ref_instruction_2", "from_port_id": "out", "to_node_id": "op_edit_2", "to_port_id": "json_instruction", "type": "text"},
151
+ {"id": "e_seed_2", "from_node_id": "ref_seed", "from_port_id": "out", "to_node_id": "op_edit_2", "to_port_id": "seed", "type": "text"},
152
+ {"id": "e_steps_2", "from_node_id": "ref_steps", "from_port_id": "out", "to_node_id": "op_edit_2", "to_port_id": "steps", "type": "text"},
153
+ {"id": "e_out_2", "from_node_id": "op_edit_2", "from_port_id": "out_0", "to_node_id": "sub_chained", "to_port_id": "in", "type": "image"}
154
+ ]
155
+ }