Download examples/pr3-gradio-server-workflow.patch from akhaliq/Fibo-2.0-ComfyUI: direct link, hf CLI and curl.
- Browser
- Download file 79.9 kB
-
https://huggingface.co/akhaliq/Fibo-2.0-ComfyUI/resolve/main/examples/pr3-gradio-server-workflow.patch
- Command line
-
hf download hf://akhaliq/Fibo-2.0-ComfyUI/examples/pr3-gradio-server-workflow.patch
-
curl -L -o pr3-gradio-server-workflow.patch https://huggingface.co/akhaliq/Fibo-2.0-ComfyUI/resolve/main/examples/pr3-gradio-server-workflow.patch
79.9 kB
| From 4a7d045978112eaf211d0bac75662a81be096471 Mon Sep 17 00:00:00 2001 | |
| From: akhaliq <akhaliq@hf.co> | |
| Date: Thu, 22 Oct 2026 03:35:51 +0000 | |
| Subject: [PATCH] gr.Server studio + gr.Workflow canvas on top of the classic | |
| app | |
| - app.py: gr.Server endpoints (/edit, /vgl) + bespoke Photoshop-style studio | |
| at /, gr.Workflow canvas at /workflow (fibo_edit fn node), classic UI | |
| preserved untouched at /classic (classic_app.py) | |
| - studio.html: brush-to-mask, layers, before/after split view, run history, | |
| one-click examples, mobile-responsive | |
| - workflow.json: edit + chained-edit starter graph | |
| - README: gradio 6.30.0 (gr.Server/gr.Workflow), hf_oauth for per-visitor | |
| ZeroGPU quota, document the three surfaces | |
| - pipelines.py / aoti_loader.py / bria_api.py / requirements.txt untouched: | |
| AOTI-accelerated pipeline and Bria VGL prompt writing are shared by all | |
| surfaces via the classic app's render function | |
| --- | |
| README.md | 15 +- | |
| app.py | 483 ++++++++++++++------------------ | |
| classic_app.py | 320 +++++++++++++++++++++ | |
| studio.html | 746 +++++++++++++++++++++++++++++++++++++++++++++++++ | |
| workflow.json | 155 ++++++++++ | |
| 5 files changed, 1442 insertions(+), 277 deletions(-) | |
| create mode 100644 classic_app.py | |
| create mode 100644 studio.html | |
| create mode 100644 workflow.json | |
| diff --git a/README.md b/README.md | |
| index 9f365e1..bcf8d27 100644 | |
| --- a/README.md | |
| +++ b/README.md | |
| emoji: ⚡ | |
| colorFrom: indigo | |
| colorTo: purple | |
| sdk: gradio | |
| -sdk_version: 5.49.1 | |
| +sdk_version: 6.30.0 | |
| python_version: "3.12" # diffusers from the fibo-2 PR needs >= 3.11 | |
| app_file: app.py | |
| pinned: false | |
| short_description: Fibo 2.0 Edit Turbo image editing in 4 steps | |
| +hf_oauth: true | |
| +hf_oauth_scopes: | |
| + - inference-api | |
| + - write-repos | |
| models: | |
| - briaai/Fibo-2.0-edit-turbo | |
| - briaai/Fibo-2.0-dev # listed so the Space shows on the Dev model page too | |
| Fibo 2.0 Edit Turbo on ZeroGPU: instruction-based image editing in 4 guidance-fr | |
| reference images and an optional mask. The JSON instructions come from the Bria API's | |
| `/v2/vgl/edit`, written with Fibo 2.0's VGL prompts. | |
| +Three surfaces: | |
| + | |
| +- **`/` — Studio**: a bespoke Photoshop-style editor (`gr.Server` API + custom UI): brush-to-mask, | |
| + layers, before/after split view, run history, one-click examples. Edits call the same `render` | |
| + as the classic app (the AOTI-accelerated pipeline in `pipelines.py` is shared by every surface). | |
| +- **`/workflow` — Workflow canvas** (`gr.Workflow`): node-based edit pipelines you can rewire, | |
| + chain, and save. Run nodes one at a time with ▶ — up-to-date upstream results are reused. | |
| +- **`/classic` — the classic UI**, unchanged. | |
| + | |
| Space secrets: `HF_TOKEN` (read access to the gated model), `BRIA_API_TOKEN` (the Bria API). | |
| diff --git a/app.py b/app.py | |
| index 096ce49..72a07b1 100644 | |
| --- a/app.py | |
| +++ b/app.py | |
| -import os | |
| -import json | |
| +"""FIBO 2.0 Edit Turbo — gr.Server studio + gr.Workflow canvas on top of the classic app. | |
| + | |
| +The classic Gradio UI is preserved untouched in classic_app.py (mounted at /classic); | |
| +this adds a bespoke Photoshop-style studio at / (gr.Server API endpoints) and a | |
| +node-based workflow canvas at /workflow. All GPU work goes through the same | |
| +`render` function as the classic app, so the AOTI-accelerated pipeline loading in | |
| +pipelines.py is shared by every surface. | |
| +""" | |
| + | |
| import base64 | |
| -import random | |
| -from contextlib import contextmanager | |
| +import json | |
| +import os | |
| +import tempfile | |
| +from urllib.parse import unquote, urlparse | |
| import gradio as gr | |
| -import numpy as np | |
| -import spaces | |
| -import torch | |
| +import uvicorn | |
| +from fastapi import Request | |
| +from fastapi.responses import HTMLResponse, RedirectResponse | |
| +from fastapi.staticfiles import StaticFiles | |
| +from gradio_client import utils as client_utils | |
| from PIL import Image | |
| import bria_api | |
| -import pipelines | |
| -from pipelines import GUIDANCE_SCALE, MAX_STEPS, STEPS | |
| +from classic_app import ( # noqa: F401 (classic_app loads the pipeline once, at import) | |
| + MAX_SEED, | |
| + as_prompt, | |
| + collect_references, | |
| + demo as classic_demo, | |
| + pretty, | |
| + render, | |
| + resolve_seed, | |
| + user_errors, | |
| +) | |
| +from pipelines import MAX_STEPS, STEPS | |
| -MAX_SEED = np.iinfo(np.int32).max | |
| -MAX_REFERENCES = bria_api.MAX_REFERENCES | |
| +APP_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| -pipe = pipelines.load_pipeline() | |
| -# A 4-step edit of one 1024 px image measured 7 s on an L40S; references add tokens. | |
| -GPU_DURATION = 60 | |
| +def _as_data_uri(path: str) -> str: | |
| + """Inline a result image: Server mode's /gradio_api/file= route 403s the | |
| + tempdir even with allowed_paths, so the page gets data URIs instead.""" | |
| + with open(path, "rb") as f: | |
| + return "data:image/png;base64," + base64.b64encode(f.read()).decode() | |
| -@spaces.GPU(duration=GPU_DURATION, size=pipelines.GPU_SIZE) | |
| -def render(json_prompt, seed, steps, references, mask=None): | |
| - """An edit of references[0], with the rest as extra references.""" | |
| - with torch.inference_mode(): | |
| - return pipe( | |
| - prompt=json_prompt, | |
| - image=references[0] if len(references) == 1 else references, | |
| - mask=mask, | |
| - num_inference_steps=int(steps), | |
| - guidance_scale=GUIDANCE_SCALE, | |
| - generator=torch.Generator(device="cuda").manual_seed(int(seed)), | |
| - ).images[0] | |
| +def _local_path(value) -> str: | |
| + """Turn whatever the canvas/API client sends into a path this process can open.""" | |
| + if isinstance(value, str): | |
| + return value | |
| + if not isinstance(value, dict): | |
| + raise gr.Error(f"Expected an image, got {type(value).__name__}") | |
| + if path := value.get("path"): | |
| + return path | |
| + url = value.get("url") or "" | |
| + if "/gradio_api/file=" in url: | |
| + return unquote(url.split("/gradio_api/file=", 1)[1]) | |
| + if url.startswith(("http://", "https://")): | |
| + return client_utils.download_tmp_copy_of_file(url) | |
| + return unquote(urlparse(url).path) | |
| -# --------------------------------------------------------------------------- helpers | |
| +def _open_rgb(value) -> Image.Image: | |
| + return Image.open(_local_path(value)).convert("RGB") | |
| -@contextmanager | |
| -def user_errors(): | |
| - """Prompt-service failures surface as messages, not stack traces.""" | |
| - try: | |
| - yield | |
| - except bria_api.BriaAPIError as error: | |
| - raise gr.Error(str(error)) | |
| +def _mask_from_upload(value, source: Image.Image): | |
| + """A white-on-black mask image from the studio's brush export, sized to the source.""" | |
| + if value is None: | |
| + return None | |
| + mask = Image.open(_local_path(value)).convert("L").point(lambda v: 255 if v > 8 else 0) | |
| + if mask.getbbox() is None: | |
| + return None | |
| + return mask.resize(source.size, Image.NEAREST) | |
| -def resolve_seed(seed, randomize): | |
| - # The /v2/vgl endpoints reject seed 0 (they take 1..int32 max). | |
| - return random.randint(1, MAX_SEED) if randomize else max(int(seed), 1) | |
| +def _save_tmp(image: Image.Image) -> str: | |
| + path = os.path.join(tempfile.gettempdir(), f"fibo_edit_{os.urandom(8).hex()}.png") | |
| + image.save(path) | |
| + return path | |
| -def pretty(json_text): | |
| - """Format for the code box, leaving invalid text untouched.""" | |
| - try: | |
| - return json.dumps(json.loads(json_text), indent=4) | |
| - except (TypeError, ValueError): | |
| - return json_text or "" | |
| +def _as_media(path: str) -> dict: | |
| + """The dict shape the workflow canvas renders media from.""" | |
| + return { | |
| + "path": path, | |
| + "url": f"/gradio_api/file={client_utils.encode_file_path(path)}", | |
| + "is_file": True, | |
| + } | |
| -def as_prompt(json_value): | |
| - """The code box returns a string; a fresh API response may already be a dict.""" | |
| - if isinstance(json_value, (dict, list)): | |
| - return json.dumps(json_value, indent=4) | |
| - text = str(json_value or "") | |
| - try: | |
| - if isinstance(json.loads(text), dict): | |
| - return text | |
| - except ValueError: | |
| - pass | |
| - raise gr.Error("The JSON instruction is not a valid JSON object. Fix it, or clear it to regenerate it.") | |
| +# ------------------------------------------------------------------ workflow fn | |
| -def collect_references(*images): | |
| - """Gap-free reference list; slot order is what <image_1>..<image_N> mean.""" | |
| - return [image.convert("RGB") for image in images if image is not None] | |
| +def fibo_edit(image, json_instruction: str, seed: str = "42", steps: str = str(STEPS)) -> dict: | |
| + """Edit an image with Fibo 2.0 Edit Turbo (4 guidance-free steps).""" | |
| + source = _open_rgb(image) | |
| + json_prompt = as_prompt(json_instruction) | |
| + result = render(json_prompt, int(float(seed)), int(float(steps)), [source]) | |
| + return _as_media(_save_tmp(result)) | |
| -def mask_from_editor(editor_value, source): | |
| - """Brush layer -> 'L' mask sized to the source. White means repaint here.""" | |
| - if not editor_value: | |
| - return None | |
| - layers = editor_value.get("layers") or [] | |
| - if not layers: | |
| - return None | |
| - painted = layers[0].convert("RGBA") | |
| - mask = painted.getchannel("A").point(lambda value: 255 if value > 8 else 0) | |
| - if mask.getbbox() is None: | |
| - return None | |
| - return mask.resize(source.size, Image.NEAREST) | |
| +# ------------------------------------------------------------------ studio server | |
| +server = gr.Server(title="FIBO 2.0 Edit Turbo Studio") | |
| -def busy(): | |
| - return gr.update(interactive=False) | |
| +# OAuth routes attach only on the mounted workflow app (at /workflow/...), but | |
| +# gradio's login buttons and the OAuth redirect_uri point at the Space root — | |
| +# forward the three auth paths so sign-in completes instead of 404ing. The login | |
| +# itself opens in a new tab (HF auth pages refuse to render inside an iframe). | |
| +@server.get("/login/huggingface", response_class=HTMLResponse) | |
| +def _oauth_login(request: Request): | |
| + qs = str(request.url.query) | |
| + target = "/workflow/login/huggingface" + ("?" + qs if qs else "") | |
| + return ( | |
| + '<!DOCTYPE html><html><body style="' | |
| + 'font-family:Inter,system-ui,sans-serif;background:#0e0e10;color:rgba(255,255,255,0.87);' | |
| + 'display:flex;flex-direction:column;align-items:center;justify-content:center;' | |
| + 'height:100vh;margin:0;gap:14px;text-align:center">' | |
| + "<div style='font-size:1.4rem'>⚡</div>" | |
| + f"<a href='{target}' target='_blank' rel='noopener' style='" | |
| + 'background:linear-gradient(90deg,#5300C9,#8a3dff);color:white;' | |
| + "padding:12px 26px;border-radius:999px;text-decoration:none;font-weight:700'>" | |
| + 'Sign in with Hugging Face →</a>' | |
| + "<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>" | |
| + f"<a href='/workflow/?ui=canvas' style='color:#8a3dff;font-size:0.82rem;text-decoration:none'>" | |
| + 'Signed in? Back to the canvas ↻</a>' | |
| + '</body></html>' | |
| + ) | |
| -def idle(): | |
| - return gr.update(interactive=True) | |
| +@server.get("/login/callback") | |
| +def _oauth_callback(request: Request): | |
| + qs = str(request.url.query) | |
| + return RedirectResponse("/workflow/login/callback" + ("?" + qs if qs else "")) | |
| -# ------------------------------------------------------------------------------- edit | |
| +@server.get("/logout") | |
| +def _oauth_logout(request: Request): | |
| + qs = str(request.url.query) | |
| + return RedirectResponse("/workflow/logout" + ("?" + qs if qs else "")) | |
| -def check_edit_inputs(references, mask): | |
| - if not references: | |
| - raise gr.Error("Upload a source image first.") | |
| - if mask is not None and len(references) != 1: | |
| - raise gr.Error( | |
| - "A mask only works with a single reference image. Clear the extra references, " | |
| - "or clear the mask." | |
| - ) | |
| + | |
| +@server.get("/", response_class=HTMLResponse) | |
| +def _studio_page(): | |
| + with open(os.path.join(APP_DIR, "studio.html"), encoding="utf-8") as f: | |
| + return f.read() | |
| -def run_edit(instruction, source, ref2, ref3, ref4, mask_editor, json_prompt, seed, randomize, | |
| - steps, progress=gr.Progress(track_tqdm=True)): | |
| - seed = resolve_seed(seed, randomize) | |
| - references = collect_references(source, ref2, ref3, ref4) | |
| - mask = mask_from_editor(mask_editor, source) if source is not None else None | |
| - check_edit_inputs(references, mask) | |
| +_examples_dir = os.path.join(APP_DIR, "examples") | |
| +if os.path.isdir(_examples_dir): | |
| + server.mount("/examples", StaticFiles(directory=_examples_dir), name="examples") | |
| + | |
| +@server.api(name="vgl", concurrency_limit=None) | |
| +def api_vgl( | |
| + instruction: str, | |
| + source: str, | |
| + ref2: str | None = None, | |
| + ref3: str | None = None, | |
| + ref4: str | None = None, | |
| + mask: str | None = None, | |
| + seed: float = 42, | |
| +) -> dict: | |
| + """Write the edit's structured JSON instruction with the Bria VGL API.""" | |
| + images = [_open_rgb(p) for p in (source, ref2, ref3, ref4) if p] | |
| + mask_img = _mask_from_upload(mask, images[0]) if mask and len(images) == 1 else None | |
| + with user_errors(): | |
| + result = bria_api.vgl_edit( | |
| + instruction=instruction, images=images, mask=mask_img, seed=int(seed) | |
| + ) | |
| + return {"json_instruction": pretty(result) if isinstance(result, str) else result} | |
| + | |
| + | |
| +@server.api(name="edit", concurrency_limit=None) | |
| +def api_edit( | |
| + source: str, | |
| + instruction: str = "", | |
| + json_instruction: str = "", | |
| + ref2: str | None = None, | |
| + ref3: str | None = None, | |
| + ref4: str | None = None, | |
| + mask: str | None = None, | |
| + seed: float = 42, | |
| + randomize: bool = True, | |
| + steps: float = STEPS, | |
| +) -> dict: | |
| + """Edit the source (+ up to 3 references, optional mask) with Fibo 2.0 Edit Turbo.""" | |
| + if source is None: | |
| + raise gr.Error("Upload a source image first.") | |
| + images = [_open_rgb(p) for p in (source, ref2, ref3, ref4) if p] | |
| + mask_img = _mask_from_upload(mask, images[0]) if mask else None | |
| + if mask_img is not None and len(images) != 1: | |
| + raise gr.Error("A mask only works with a single reference image.") | |
| + seed_value = resolve_seed(seed, randomize) | |
| + json_prompt = (json_instruction or "").strip() | |
| if not json_prompt: | |
| with user_errors(): | |
| json_prompt = bria_api.vgl_edit( | |
| - instruction=instruction, images=references, mask=mask, seed=seed | |
| + instruction=instruction, images=images, mask=mask_img, seed=seed_value | |
| ) | |
| json_prompt = as_prompt(json_prompt) | |
| + result = render(json_prompt, seed_value, int(steps), images, mask=mask_img) | |
| + return { | |
| + "image_data": _as_data_uri(_save_tmp(result)), | |
| + "seed": seed_value, | |
| + "json_instruction": pretty(json_prompt), | |
| + } | |
| - image = render(json_prompt, seed, steps, references, mask=mask) | |
| - return image, seed, pretty(json_prompt) | |
| - | |
| - | |
| -def invalidate_edit_json(): | |
| - """A changed image, instruction or mask makes the generated JSON instruction stale.""" | |
| - return "" | |
| - | |
| - | |
| -# --------------------------------------------------------------------------------- ui | |
| - | |
| -def _load_logo(): | |
| - """Embed the bundled logo as a data URI so the header never depends on an external CDN.""" | |
| - try: | |
| - with open(os.path.join(os.path.dirname(__file__), "bria_logo.png"), "rb") as f: | |
| - return "data:image/png;base64," + base64.b64encode(f.read()).decode("utf-8") | |
| - except Exception: | |
| - return "https://cdn.sanity.io/images/zppcnj0l/production/d0f4aef10abdcc4bacaebeade789ea8c3e453882-210x132.png" | |
| - | |
| - | |
| -BRIA_LOGO = _load_logo() | |
| - | |
| -# Bria brand theme (purple #5300C9, magenta accent). | |
| -bria_theme = gr.themes.Soft( | |
| - primary_hue=gr.themes.Color( | |
| - c50="#f3ebff", c100="#e4d3ff", c200="#c9a6ff", c300="#a86bff", | |
| - c400="#8a3dff", c500="#5300C9", c600="#4700ab", c700="#37008a", | |
| - c800="#28006b", c900="#1c0050", c950="#120038", | |
| - ), | |
| - secondary_hue=gr.themes.Color( | |
| - c50="#ffe9f4", c100="#ffd0e6", c200="#ffa1cd", c300="#ff6fb2", | |
| - c400="#f5399a", c500="#e2138a", c600="#c00b74", c700="#99095c", | |
| - c800="#730745", c900="#520531", c950="#340120", | |
| - ), | |
| - neutral_hue="slate", | |
| - font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"], | |
| - radius_size=gr.themes.sizes.radius_lg, | |
| -).set( | |
| - body_background_fill="#f6f5fb", | |
| - body_background_fill_dark="#0f0b1a", | |
| - block_background_fill="white", | |
| - block_background_fill_dark="#1a1330", | |
| - block_border_width="1px", | |
| - block_shadow="0 1px 3px rgba(28,0,80,0.06), 0 8px 24px rgba(28,0,80,0.05)", | |
| - button_primary_background_fill="linear-gradient(90deg, #5300C9 0%, #8a3dff 100%)", | |
| - button_primary_background_fill_hover="linear-gradient(90deg, #4700ab 0%, #7a2dff 100%)", | |
| - button_primary_text_color="white", | |
| - button_large_radius="12px", | |
| -) | |
| -css = """ | |
| -#col-container { margin: 0 auto; max-width: 1000px; } | |
| -#json_instruction { max-height: 800px; } | |
| - | |
| -#bria-header, #bria-footer { max-width: 1000px; margin: 0 auto; } | |
| -#bria-header { | |
| - display: flex; align-items: center; gap: 18px; | |
| - padding: 22px 26px; margin-bottom: 10px; | |
| - background: linear-gradient(120deg, #ffffff 0%, #f3ecff 100%); | |
| - border: 1px solid rgba(83, 0, 201, 0.12); border-radius: 18px; | |
| -} | |
| -.dark #bria-header { background: linear-gradient(120deg, #1a1330 0%, #241640 100%); border-color: rgba(138, 61, 255, 0.25); } | |
| -#bria-header img { height: 52px; width: auto; } | |
| -#bria-header .title-block h1 { margin: 0; font-size: 1.7rem; font-weight: 800; letter-spacing: -0.02em; color: #1c0050; } | |
| -.dark #bria-header .title-block h1 { color: #f0eafc; } | |
| -#bria-header .title-block p { margin: 4px 0 0 0; font-size: 0.95rem; color: #6b6580; } | |
| -.dark #bria-header .title-block p { color: #b3abc9; } | |
| -#bria-header .pill { | |
| - display: inline-block; margin-top: 8px; padding: 3px 12px; | |
| - font-size: 0.72rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; | |
| - color: #5300C9; background: rgba(83, 0, 201, 0.09); border-radius: 999px; | |
| -} | |
| -.dark #bria-header .pill { color: #c9a6ff; background: rgba(138, 61, 255, 0.15); } | |
| - | |
| -#bria-footer { | |
| - margin-top: 24px; padding-top: 16px; border-top: 1px solid rgba(83, 0, 201, 0.12); | |
| - text-align: center; font-size: 0.88rem; color: #6b6580; line-height: 1.7; | |
| -} | |
| -#bria-footer a { color: #5300C9; text-decoration: none; font-weight: 600; } | |
| -#bria-footer a:hover { text-decoration: underline; } | |
| -.dark #bria-footer { color: #b3abc9; border-color: rgba(138, 61, 255, 0.22); } | |
| -.dark #bria-footer a { color: #c9a6ff; } | |
| -""" | |
| +# ------------------------------------------------------------------ workflow canvas | |
| -with gr.Blocks(css=css, theme=bria_theme, title="BRIA FIBO 2.0 Edit Turbo") as demo: | |
| - | |
| - gr.HTML(f""" | |
| - <div id="bria-header"> | |
| - <img src="{BRIA_LOGO}" alt="BRIA logo" /> | |
| - <div class="title-block"> | |
| - <h1>FIBO 2.0 Edit Turbo</h1> | |
| - <p>8B image editing in 4 steps, driven by structured JSON instructions — with references and masks.</p> | |
| - <span class="pill">Open model · 8B · Edit Turbo</span> | |
| - </div> | |
| - </div> | |
| - """) | |
| - | |
| - with gr.Row(elem_id="col-container"): | |
| - with gr.Column(scale=1): | |
| - with gr.Tabs(): | |
| - | |
| - # ---------------------------------------------------------- edit | |
| - with gr.Tab("Edit", id="edit_tab"): | |
| - gr.Markdown( | |
| - "Upload a source image and describe the edit. Add up to three more " | |
| - "references — the instruction refers to them in slot order as " | |
| - "`<image_1>` … `<image_4>`." | |
| - ) | |
| - with gr.Row(): | |
| - edit_source = gr.Image(label="1 · Source (required)", type="pil") | |
| - edit_ref2 = gr.Image(label="2 · Reference", type="pil") | |
| - with gr.Row(): | |
| - edit_ref3 = gr.Image(label="3 · Reference", type="pil") | |
| - edit_ref4 = gr.Image(label="4 · Reference", type="pil") | |
| - | |
| - with gr.Accordion( | |
| - "Mask — paint the area to repaint (single reference only)", open=False | |
| - ): | |
| - edit_mask = gr.ImageEditor( | |
| - label="Brush over the region you want changed", | |
| - type="pil", | |
| - brush=gr.Brush(colors=["#ffffff"], color_mode="fixed"), | |
| - ) | |
| - | |
| - edit_instruction = gr.Textbox( | |
| - label="Edit instruction", | |
| - placeholder="make the sky dramatic and stormy", lines=2, | |
| - ) | |
| - edit_json = gr.Code( | |
| - label="JSON Instruction", language="json", wrap_lines=True, | |
| - lines=10, elem_id="json_instruction", | |
| - ) | |
| - with gr.Accordion("Advanced Settings", open=False): | |
| - with gr.Row(): | |
| - edit_seed = gr.Slider( | |
| - label="Seed", minimum=1, maximum=MAX_SEED, step=1, value=1 | |
| - ) | |
| - edit_randomize_seed = gr.Checkbox(label="Randomize seed", value=True) | |
| - edit_steps = gr.Slider( | |
| - label="Steps", minimum=1, maximum=MAX_STEPS, step=1, value=STEPS, | |
| - info="Fibo 2.0 Edit Turbo is distilled for 4 guidance-free steps.", | |
| - ) | |
| - edit_run_btn = gr.Button("Generate Edit", variant="primary") | |
| - | |
| - gr.Examples( | |
| - examples=[ | |
| - ["examples/bicycle_wall.jpg", "Change the bicycle color to bright red"], | |
| - ["examples/businessman_suit.jpg", "Add round glasses"], | |
| - ["examples/cat_tabby.jpg", "Put a small party hat on the cat"], | |
| - ], | |
| - inputs=[edit_source, edit_instruction], | |
| - label="Example edits", | |
| - ) | |
| - | |
| - with gr.Column(scale=1): | |
| - result = gr.Image(label="output") | |
| - | |
| - # -------------------------------------------------------------------- wiring | |
| - | |
| - | |
| - edit_run_btn.click( | |
| - fn=busy, inputs=None, outputs=edit_run_btn, | |
| - ).then( | |
| - fn=run_edit, | |
| - inputs=[edit_instruction, edit_source, edit_ref2, edit_ref3, edit_ref4, edit_mask, | |
| - edit_json, edit_seed, edit_randomize_seed, edit_steps], | |
| - outputs=[result, edit_seed, edit_json], | |
| - ).then( | |
| - fn=idle, inputs=None, outputs=edit_run_btn, | |
| +workflow_demo = gr.Workflow( | |
| + graph="workflow.json", | |
| + bind={"fibo_edit": fibo_edit}, | |
| +) | |
| +workflow_demo.title = "FIBO 2.0 Edit Turbo Workflow" | |
| + | |
| +# Mounts: the specific paths first, the studio's catch-all last. | |
| +server = gr.mount_gradio_app(server, workflow_demo, path="/workflow", ssr_mode=False) | |
| +server = gr.mount_gradio_app(server, classic_demo, path="/classic", ssr_mode=False) | |
| + | |
| +# The @server.api endpoints are deferred until Server.launch(), which also fires | |
| +# the ZeroGPU registration hook (pack weights + startup report) through the | |
| +# patched Blocks.launch path. Launch on a loopback port for registration, then | |
| +# serve the same app via uvicorn. | |
| +_port_env = os.environ.get("GRADIO_SERVER_PORT") | |
| +os.environ["GRADIO_SERVER_PORT"] = "7861" | |
| +try: | |
| + server.launch( | |
| + prevent_thread_lock=True, | |
| + server_name="127.0.0.1", | |
| + server_port=7861, | |
| + quiet=True, | |
| + show_error=False, | |
| + ssr_mode=False, | |
| + allowed_paths=[tempfile.gettempdir()], | |
| ) | |
| - | |
| - # "Generate Edit" reuses the JSON box when it isn't empty, so anything that makes it stale clears it. | |
| - for slot in (edit_source, edit_ref2, edit_ref3, edit_ref4, edit_instruction, edit_mask): | |
| - slot.change(fn=invalidate_edit_json, inputs=None, outputs=[edit_json]) | |
| - | |
| - # The mask must match the source, so seed the editor from it. | |
| - edit_source.change(fn=lambda image: image, inputs=[edit_source], outputs=[edit_mask]) | |
| - | |
| - gr.HTML(""" | |
| - <div id="bria-footer"> | |
| - <p> | |
| - Edits run <b>Fibo 2.0 Edit Turbo</b> (4 guidance-free steps, up to 4 reference images). | |
| - JSON instructions by the Bria API (/v2/vgl/edit). Trained on licensed data.<br/> | |
| - <a href="https://huggingface.co/briaai/Fibo-2.0-edit-turbo" target="_blank">Model</a> · | |
| - <a href="https://arxiv.org/abs/2511.06876" target="_blank">arXiv</a> | |
| - </p> | |
| - </div> | |
| - """) | |
| - | |
| -demo.queue().launch(ssr_mode=False, show_error=True) | |
| +finally: | |
| + if _port_env is None: | |
| + del os.environ["GRADIO_SERVER_PORT"] | |
| + else: | |
| + os.environ["GRADIO_SERVER_PORT"] = _port_env | |
| + | |
| +try: | |
| + server.close() | |
| +except Exception: | |
| + pass | |
| + | |
| +if __name__ == "__main__": | |
| + uvicorn.run(server, host="0.0.0.0", port=7860) | |
| diff --git a/classic_app.py b/classic_app.py | |
| new file mode 100644 | |
| index 0000000..ffa8cbe | |
| --- /dev/null | |
| +++ b/classic_app.py | |
| +import os | |
| +import json | |
| +import base64 | |
| +import random | |
| +from contextlib import contextmanager | |
| + | |
| +import gradio as gr | |
| +import numpy as np | |
| +import spaces | |
| +import torch | |
| +from PIL import Image | |
| + | |
| +import bria_api | |
| +import pipelines | |
| +from pipelines import GUIDANCE_SCALE, MAX_STEPS, STEPS | |
| + | |
| +MAX_SEED = np.iinfo(np.int32).max | |
| +MAX_REFERENCES = bria_api.MAX_REFERENCES | |
| + | |
| +pipe = pipelines.load_pipeline() | |
| + | |
| +# A 4-step edit of one 1024 px image measured 7 s on an L40S; references add tokens. | |
| +GPU_DURATION = 60 | |
| + | |
| + | |
| +@spaces.GPU(duration=GPU_DURATION, size=pipelines.GPU_SIZE) | |
| +def render(json_prompt, seed, steps, references, mask=None): | |
| + """An edit of references[0], with the rest as extra references.""" | |
| + with torch.inference_mode(): | |
| + return pipe( | |
| + prompt=json_prompt, | |
| + image=references[0] if len(references) == 1 else references, | |
| + mask=mask, | |
| + num_inference_steps=int(steps), | |
| + guidance_scale=GUIDANCE_SCALE, | |
| + generator=torch.Generator(device="cuda").manual_seed(int(seed)), | |
| + ).images[0] | |
| + | |
| + | |
| +# --------------------------------------------------------------------------- helpers | |
| + | |
| + | |
| +@contextmanager | |
| +def user_errors(): | |
| + """Prompt-service failures surface as messages, not stack traces.""" | |
| + try: | |
| + yield | |
| + except bria_api.BriaAPIError as error: | |
| + raise gr.Error(str(error)) | |
| + | |
| + | |
| +def resolve_seed(seed, randomize): | |
| + # The /v2/vgl endpoints reject seed 0 (they take 1..int32 max). | |
| + return random.randint(1, MAX_SEED) if randomize else max(int(seed), 1) | |
| + | |
| + | |
| +def pretty(json_text): | |
| + """Format for the code box, leaving invalid text untouched.""" | |
| + try: | |
| + return json.dumps(json.loads(json_text), indent=4) | |
| + except (TypeError, ValueError): | |
| + return json_text or "" | |
| + | |
| + | |
| +def as_prompt(json_value): | |
| + """The code box returns a string; a fresh API response may already be a dict.""" | |
| + if isinstance(json_value, (dict, list)): | |
| + return json.dumps(json_value, indent=4) | |
| + text = str(json_value or "") | |
| + try: | |
| + if isinstance(json.loads(text), dict): | |
| + return text | |
| + except ValueError: | |
| + pass | |
| + raise gr.Error("The JSON instruction is not a valid JSON object. Fix it, or clear it to regenerate it.") | |
| + | |
| + | |
| +def collect_references(*images): | |
| + """Gap-free reference list; slot order is what <image_1>..<image_N> mean.""" | |
| + return [image.convert("RGB") for image in images if image is not None] | |
| + | |
| + | |
| +def mask_from_editor(editor_value, source): | |
| + """Brush layer -> 'L' mask sized to the source. White means repaint here.""" | |
| + if not editor_value: | |
| + return None | |
| + layers = editor_value.get("layers") or [] | |
| + if not layers: | |
| + return None | |
| + painted = layers[0].convert("RGBA") | |
| + mask = painted.getchannel("A").point(lambda value: 255 if value > 8 else 0) | |
| + if mask.getbbox() is None: | |
| + return None | |
| + return mask.resize(source.size, Image.NEAREST) | |
| + | |
| + | |
| +def busy(): | |
| + return gr.update(interactive=False) | |
| + | |
| + | |
| +def idle(): | |
| + return gr.update(interactive=True) | |
| + | |
| + | |
| +# ------------------------------------------------------------------------------- edit | |
| + | |
| + | |
| +def check_edit_inputs(references, mask): | |
| + if not references: | |
| + raise gr.Error("Upload a source image first.") | |
| + if mask is not None and len(references) != 1: | |
| + raise gr.Error( | |
| + "A mask only works with a single reference image. Clear the extra references, " | |
| + "or clear the mask." | |
| + ) | |
| + | |
| + | |
| +def run_edit(instruction, source, ref2, ref3, ref4, mask_editor, json_prompt, seed, randomize, | |
| + steps, progress=gr.Progress(track_tqdm=True)): | |
| + seed = resolve_seed(seed, randomize) | |
| + references = collect_references(source, ref2, ref3, ref4) | |
| + mask = mask_from_editor(mask_editor, source) if source is not None else None | |
| + check_edit_inputs(references, mask) | |
| + | |
| + if not json_prompt: | |
| + with user_errors(): | |
| + json_prompt = bria_api.vgl_edit( | |
| + instruction=instruction, images=references, mask=mask, seed=seed | |
| + ) | |
| + json_prompt = as_prompt(json_prompt) | |
| + | |
| + image = render(json_prompt, seed, steps, references, mask=mask) | |
| + return image, seed, pretty(json_prompt) | |
| + | |
| + | |
| +def invalidate_edit_json(): | |
| + """A changed image, instruction or mask makes the generated JSON instruction stale.""" | |
| + return "" | |
| + | |
| + | |
| +# --------------------------------------------------------------------------------- ui | |
| + | |
| +def _load_logo(): | |
| + """Embed the bundled logo as a data URI so the header never depends on an external CDN.""" | |
| + try: | |
| + with open(os.path.join(os.path.dirname(__file__), "bria_logo.png"), "rb") as f: | |
| + return "data:image/png;base64," + base64.b64encode(f.read()).decode("utf-8") | |
| + except Exception: | |
| + return "https://cdn.sanity.io/images/zppcnj0l/production/d0f4aef10abdcc4bacaebeade789ea8c3e453882-210x132.png" | |
| + | |
| + | |
| +BRIA_LOGO = _load_logo() | |
| + | |
| +# Bria brand theme (purple #5300C9, magenta accent). | |
| +bria_theme = gr.themes.Soft( | |
| + primary_hue=gr.themes.Color( | |
| + c50="#f3ebff", c100="#e4d3ff", c200="#c9a6ff", c300="#a86bff", | |
| + c400="#8a3dff", c500="#5300C9", c600="#4700ab", c700="#37008a", | |
| + c800="#28006b", c900="#1c0050", c950="#120038", | |
| + ), | |
| + secondary_hue=gr.themes.Color( | |
| + c50="#ffe9f4", c100="#ffd0e6", c200="#ffa1cd", c300="#ff6fb2", | |
| + c400="#f5399a", c500="#e2138a", c600="#c00b74", c700="#99095c", | |
| + c800="#730745", c900="#520531", c950="#340120", | |
| + ), | |
| + neutral_hue="slate", | |
| + font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"], | |
| + radius_size=gr.themes.sizes.radius_lg, | |
| +).set( | |
| + body_background_fill="#f6f5fb", | |
| + body_background_fill_dark="#0f0b1a", | |
| + block_background_fill="white", | |
| + block_background_fill_dark="#1a1330", | |
| + block_border_width="1px", | |
| + block_shadow="0 1px 3px rgba(28,0,80,0.06), 0 8px 24px rgba(28,0,80,0.05)", | |
| + button_primary_background_fill="linear-gradient(90deg, #5300C9 0%, #8a3dff 100%)", | |
| + button_primary_background_fill_hover="linear-gradient(90deg, #4700ab 0%, #7a2dff 100%)", | |
| + button_primary_text_color="white", | |
| + button_large_radius="12px", | |
| +) | |
| + | |
| +css = """ | |
| +#col-container { margin: 0 auto; max-width: 1000px; } | |
| +#json_instruction { max-height: 800px; } | |
| + | |
| +#bria-header, #bria-footer { max-width: 1000px; margin: 0 auto; } | |
| +#bria-header { | |
| + display: flex; align-items: center; gap: 18px; | |
| + padding: 22px 26px; margin-bottom: 10px; | |
| + background: linear-gradient(120deg, #ffffff 0%, #f3ecff 100%); | |
| + border: 1px solid rgba(83, 0, 201, 0.12); border-radius: 18px; | |
| +} | |
| +.dark #bria-header { background: linear-gradient(120deg, #1a1330 0%, #241640 100%); border-color: rgba(138, 61, 255, 0.25); } | |
| +#bria-header img { height: 52px; width: auto; } | |
| +#bria-header .title-block h1 { margin: 0; font-size: 1.7rem; font-weight: 800; letter-spacing: -0.02em; color: #1c0050; } | |
| +.dark #bria-header .title-block h1 { color: #f0eafc; } | |
| +#bria-header .title-block p { margin: 4px 0 0 0; font-size: 0.95rem; color: #6b6580; } | |
| +.dark #bria-header .title-block p { color: #b3abc9; } | |
| +#bria-header .pill { | |
| + display: inline-block; margin-top: 8px; padding: 3px 12px; | |
| + font-size: 0.72rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; | |
| + color: #5300C9; background: rgba(83, 0, 201, 0.09); border-radius: 999px; | |
| +} | |
| +.dark #bria-header .pill { color: #c9a6ff; background: rgba(138, 61, 255, 0.15); } | |
| + | |
| +#bria-footer { | |
| + margin-top: 24px; padding-top: 16px; border-top: 1px solid rgba(83, 0, 201, 0.12); | |
| + text-align: center; font-size: 0.88rem; color: #6b6580; line-height: 1.7; | |
| +} | |
| +#bria-footer a { color: #5300C9; text-decoration: none; font-weight: 600; } | |
| +#bria-footer a:hover { text-decoration: underline; } | |
| +.dark #bria-footer { color: #b3abc9; border-color: rgba(138, 61, 255, 0.22); } | |
| +.dark #bria-footer a { color: #c9a6ff; } | |
| +""" | |
| + | |
| +with gr.Blocks(css=css, theme=bria_theme, title="BRIA FIBO 2.0 Edit Turbo") as demo: | |
| + | |
| + gr.HTML(f""" | |
| + <div id="bria-header"> | |
| + <img src="{BRIA_LOGO}" alt="BRIA logo" /> | |
| + <div class="title-block"> | |
| + <h1>FIBO 2.0 Edit Turbo</h1> | |
| + <p>8B image editing in 4 steps, driven by structured JSON instructions — with references and masks.</p> | |
| + <span class="pill">Open model · 8B · Edit Turbo</span> | |
| + </div> | |
| + </div> | |
| + """) | |
| + | |
| + with gr.Row(elem_id="col-container"): | |
| + with gr.Column(scale=1): | |
| + with gr.Tabs(): | |
| + | |
| + # ---------------------------------------------------------- edit | |
| + with gr.Tab("Edit", id="edit_tab"): | |
| + gr.Markdown( | |
| + "Upload a source image and describe the edit. Add up to three more " | |
| + "references — the instruction refers to them in slot order as " | |
| + "`<image_1>` … `<image_4>`." | |
| + ) | |
| + with gr.Row(): | |
| + edit_source = gr.Image(label="1 · Source (required)", type="pil") | |
| + edit_ref2 = gr.Image(label="2 · Reference", type="pil") | |
| + with gr.Row(): | |
| + edit_ref3 = gr.Image(label="3 · Reference", type="pil") | |
| + edit_ref4 = gr.Image(label="4 · Reference", type="pil") | |
| + | |
| + with gr.Accordion( | |
| + "Mask — paint the area to repaint (single reference only)", open=False | |
| + ): | |
| + edit_mask = gr.ImageEditor( | |
| + label="Brush over the region you want changed", | |
| + type="pil", | |
| + brush=gr.Brush(colors=["#ffffff"], color_mode="fixed"), | |
| + ) | |
| + | |
| + edit_instruction = gr.Textbox( | |
| + label="Edit instruction", | |
| + placeholder="make the sky dramatic and stormy", lines=2, | |
| + ) | |
| + edit_json = gr.Code( | |
| + label="JSON Instruction", language="json", wrap_lines=True, | |
| + lines=10, elem_id="json_instruction", | |
| + ) | |
| + with gr.Accordion("Advanced Settings", open=False): | |
| + with gr.Row(): | |
| + edit_seed = gr.Slider( | |
| + label="Seed", minimum=1, maximum=MAX_SEED, step=1, value=1 | |
| + ) | |
| + edit_randomize_seed = gr.Checkbox(label="Randomize seed", value=True) | |
| + edit_steps = gr.Slider( | |
| + label="Steps", minimum=1, maximum=MAX_STEPS, step=1, value=STEPS, | |
| + info="Fibo 2.0 Edit Turbo is distilled for 4 guidance-free steps.", | |
| + ) | |
| + edit_run_btn = gr.Button("Generate Edit", variant="primary") | |
| + | |
| + gr.Examples( | |
| + examples=[ | |
| + ["examples/bicycle_wall.jpg", "Change the bicycle color to bright red"], | |
| + ["examples/businessman_suit.jpg", "Add round glasses"], | |
| + ["examples/cat_tabby.jpg", "Put a small party hat on the cat"], | |
| + ], | |
| + inputs=[edit_source, edit_instruction], | |
| + label="Example edits", | |
| + ) | |
| + | |
| + with gr.Column(scale=1): | |
| + result = gr.Image(label="output") | |
| + | |
| + # -------------------------------------------------------------------- wiring | |
| + | |
| + | |
| + edit_run_btn.click( | |
| + fn=busy, inputs=None, outputs=edit_run_btn, | |
| + ).then( | |
| + fn=run_edit, | |
| + inputs=[edit_instruction, edit_source, edit_ref2, edit_ref3, edit_ref4, edit_mask, | |
| + edit_json, edit_seed, edit_randomize_seed, edit_steps], | |
| + outputs=[result, edit_seed, edit_json], | |
| + ).then( | |
| + fn=idle, inputs=None, outputs=edit_run_btn, | |
| + ) | |
| + | |
| + # "Generate Edit" reuses the JSON box when it isn't empty, so anything that makes it stale clears it. | |
| + for slot in (edit_source, edit_ref2, edit_ref3, edit_ref4, edit_instruction, edit_mask): | |
| + slot.change(fn=invalidate_edit_json, inputs=None, outputs=[edit_json]) | |
| + | |
| + # The mask must match the source, so seed the editor from it. | |
| + edit_source.change(fn=lambda image: image, inputs=[edit_source], outputs=[edit_mask]) | |
| + | |
| + gr.HTML(""" | |
| + <div id="bria-footer"> | |
| + <p> | |
| + Edits run <b>Fibo 2.0 Edit Turbo</b> (4 guidance-free steps, up to 4 reference images). | |
| + JSON instructions by the Bria API (/v2/vgl/edit). Trained on licensed data.<br/> | |
| + <a href="https://huggingface.co/briaai/Fibo-2.0-edit-turbo" target="_blank">Model</a> · | |
| + <a href="https://arxiv.org/abs/2511.06876" target="_blank">arXiv</a> | |
| + </p> | |
| + </div> | |
| + """) | |
| + | |
| diff --git a/studio.html b/studio.html | |
| new file mode 100644 | |
| index 0000000..892c465 | |
| --- /dev/null | |
| +++ b/studio.html | |
| +<!DOCTYPE html> | |
| +<html lang="en"> | |
| +<head> | |
| +<meta charset="utf-8" /> | |
| +<meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| +<title>FIBO 2.0 Edit Turbo Studio</title> | |
| +<style> | |
| + :root { | |
| + --bg: #0e0e10; --panel: #16171a; --panel2: #101114; --raised: #1e2024; | |
| + --border: rgba(255,255,255,0.07); | |
| + --text: rgba(255,255,255,0.87); --muted: rgba(255,255,255,0.5); --dim: rgba(255,255,255,0.35); | |
| + --accent: #7c5cff; --accent2: #8a3dff; --accent-soft: rgba(124,92,255,0.14); | |
| + --radius: 10px; | |
| + } | |
| + * { margin: 0; padding: 0; box-sizing: border-box; } | |
| + [hidden] { display: none !important; } | |
| + body { | |
| + font-family: "Inter", ui-sans-serif, system-ui, sans-serif; | |
| + background: var(--bg); color: var(--text); height: 100vh; | |
| + display: flex; flex-direction: column; overflow: hidden; | |
| + font-size: 13px; -webkit-font-smoothing: antialiased; | |
| + } | |
| + .topbar { | |
| + display: flex; align-items: center; gap: 16px; padding: 8px 16px; | |
| + background: var(--panel); border-bottom: 1px solid var(--border); flex-shrink: 0; | |
| + } | |
| + .brand { font-weight: 700; font-size: 0.92rem; white-space: nowrap; letter-spacing: -0.01em; } | |
| + .mode-tabs { display: flex; gap: 2px; background: var(--panel2); border: 1px solid var(--border); border-radius: 9px; padding: 3px; } | |
| + .mode-tabs button { | |
| + border: none; border-radius: 7px; padding: 6px 16px; font-weight: 550; font-size: 0.82rem; | |
| + background: transparent; color: var(--muted); cursor: pointer; transition: color 0.15s, background 0.15s; | |
| + } | |
| + .mode-tabs button:hover { color: var(--text); } | |
| + .mode-tabs button.active { background: var(--raised); color: var(--text); box-shadow: 0 1px 3px rgba(0,0,0,0.45); } | |
| + .spacer { flex: 1; } | |
| + .ml-badge { color: var(--muted); text-decoration: none; font-size: 0.8rem; font-weight: 500; transition: color 0.15s; white-space: nowrap; } | |
| + .ml-badge:hover { color: var(--text); } | |
| + .ml-cta { | |
| + color: var(--accent); text-decoration: none; font-size: 0.8rem; font-weight: 600; | |
| + padding: 5px 13px; border: 1px solid var(--accent); border-radius: 999px; | |
| + background: var(--accent-soft); transition: filter 0.15s; white-space: nowrap; | |
| + } | |
| + .ml-cta:hover { filter: brightness(1.2); } | |
| + .main { display: flex; flex: 1; min-height: 0; } | |
| + .rail { | |
| + width: 52px; flex-shrink: 0; display: flex; flex-direction: column; align-items: center; | |
| + gap: 6px; padding: 10px 0; background: var(--panel); border-right: 1px solid var(--border); | |
| + } | |
| + .rail button { | |
| + width: 38px; height: 38px; border: 1px solid transparent; border-radius: 8px; | |
| + background: transparent; color: var(--muted); cursor: pointer; | |
| + display: flex; align-items: center; justify-content: center; | |
| + transition: color 0.15s, background 0.15s; | |
| + } | |
| + .rail button:hover { background: var(--panel2); color: var(--text); } | |
| + .rail button.active { color: var(--accent); background: var(--accent-soft); } | |
| + .viewport { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: auto; position: relative; } | |
| + .workflow-view { position: absolute; inset: 0; padding: 12px; } | |
| + .workflow-view iframe { width: 100%; height: 100%; border: none; border-radius: 10px; background: #0c0d10; } | |
| + .doc-grid { flex: 1; min-height: 0; display: flex; gap: 16px; width: 100%; align-items: stretch; justify-content: center; padding: 20px 20px 10px; } | |
| + .doc { | |
| + flex: 1; min-width: 0; max-width: 640px; display: flex; flex-direction: column; | |
| + background: var(--panel); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; | |
| + } | |
| + .doc figcaption { | |
| + padding: 8px 12px; font-size: 0.62rem; font-weight: 650; letter-spacing: 0.12em; | |
| + color: var(--dim); border-bottom: 1px solid var(--border); text-transform: uppercase; | |
| + display: flex; align-items: center; | |
| + } | |
| + .doc .stage { | |
| + position: relative; flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; | |
| + overflow: hidden; background-color: #0b0b0d; | |
| + background-image: | |
| + linear-gradient(45deg, #141419 25%, transparent 25%), | |
| + linear-gradient(-45deg, #141419 25%, transparent 25%), | |
| + linear-gradient(45deg, transparent 75%, #141419 75%), | |
| + linear-gradient(-45deg, transparent 75%, #141419 75%); | |
| + background-size: 20px 20px; | |
| + background-position: 0 0, 0 10px, 10px -10px, -10px 0; | |
| + } | |
| + .doc img { max-width: 100%; max-height: 100%; object-fit: contain; display: block; } | |
| + .doc img.zoomed { max-width: none; max-height: none; } | |
| + .doc .stage.scrollable { overflow: auto; justify-content: flex-start; align-items: flex-start; } | |
| + #mask-canvas { position: absolute; inset: 0; cursor: crosshair; display: none; } | |
| + #mask-canvas.active { display: block; } | |
| + .drop-hint { color: var(--dim); font-size: 0.85rem; text-align: center; padding: 20px; } | |
| + .drop-hint b { color: var(--muted); display: block; margin-bottom: 6px; } | |
| + .zoom-btn { | |
| + margin-left: auto; border: 1px solid var(--border); background: transparent; color: var(--muted); | |
| + border-radius: 5px; font-size: 0.62rem; padding: 2px 8px; cursor: pointer; letter-spacing: 0.08em; | |
| + } | |
| + .zoom-btn:hover { color: var(--text); border-color: var(--accent); } | |
| + .doc-bar { | |
| + display: flex; align-items: center; gap: 10px; padding: 6px 12px; | |
| + border-top: 1px solid var(--border); background: var(--panel); | |
| + font-size: 0.72rem; color: var(--muted); flex-shrink: 0; | |
| + } | |
| + .doc-bar button { | |
| + border: 1px solid var(--border); background: transparent; color: var(--muted); | |
| + border-radius: 5px; padding: 3px 10px; font-size: 0.68rem; cursor: pointer; letter-spacing: 0.06em; | |
| + } | |
| + .doc-bar button:hover, .doc-bar button.active { color: var(--text); border-color: var(--accent); } | |
| + .doc-bar button.active { background: var(--accent-soft); color: var(--accent); } | |
| + .split-wrap { position: absolute; inset: 0; overflow: hidden; } | |
| + .split-wrap img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; } | |
| + .split-divider { | |
| + position: absolute; top: 0; bottom: 0; width: 3px; background: var(--accent); | |
| + cursor: ew-resize; z-index: 5; box-shadow: 0 0 8px rgba(124,92,255,0.6); | |
| + } | |
| + .split-divider::after { | |
| + content: "◂ ▸"; position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); | |
| + background: var(--accent); color: white; border-radius: 999px; padding: 4px 7px; | |
| + font-size: 0.6rem; white-space: nowrap; | |
| + } | |
| + .history-strip { | |
| + display: flex; gap: 8px; padding: 8px 12px; overflow-x: auto; flex-shrink: 0; | |
| + background: var(--panel); border-top: 1px solid var(--border); align-items: center; | |
| + } | |
| + .history-strip .hlabel { font-size: 0.62rem; font-weight: 650; letter-spacing: 0.14em; color: var(--dim); text-transform: uppercase; white-space: nowrap; } | |
| + .history-chip { | |
| + width: 52px; height: 52px; border-radius: 6px; border: 1px solid var(--border); | |
| + overflow: hidden; cursor: pointer; flex-shrink: 0; position: relative; | |
| + } | |
| + .history-chip:hover { border-color: var(--accent); } | |
| + .history-chip img { width: 100%; height: 100%; object-fit: cover; } | |
| + .history-chip .hseed { | |
| + position: absolute; bottom: 0; left: 0; right: 0; background: rgba(0,0,0,0.65); | |
| + color: #ddd; font-size: 0.52rem; padding: 1px 3px; text-align: center; | |
| + } | |
| + .panels { | |
| + width: 320px; flex-shrink: 0; display: flex; flex-direction: column; gap: 10px; | |
| + padding: 12px; overflow-y: auto; background: var(--panel); border-left: 1px solid var(--border); | |
| + } | |
| + .panel { background: var(--panel2); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; } | |
| + .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; } | |
| + .panel .panel-title::after { content: "▾"; font-size: 0.7rem; color: var(--dim); } | |
| + .panel.collapsed .panel-title::after { content: "▸"; } | |
| + .panel.collapsed .panel-body { display: none; } | |
| + .panel label { display: block; font-size: 0.72rem; font-weight: 550; color: var(--muted); margin: 10px 0 4px; } | |
| + .panel label:first-of-type { margin-top: 0; } | |
| + textarea, input[type="text"], input[type="number"] { | |
| + width: 100%; background: var(--raised); color: var(--text); border: 1px solid var(--border); | |
| + border-radius: 7px; padding: 8px 10px; font-family: inherit; font-size: 0.83rem; resize: vertical; | |
| + transition: border-color 0.15s, box-shadow 0.15s; | |
| + } | |
| + textarea:focus, input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } | |
| + textarea.code { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 0.75rem; } | |
| + .row { display: flex; gap: 8px; align-items: center; } | |
| + .row > * { flex: 1; } | |
| + input[type="range"] { | |
| + -webkit-appearance: none; appearance: none; width: 100%; height: 3px; | |
| + border-radius: 2px; background: var(--raised); outline: none; | |
| + } | |
| + input[type="range"]::-webkit-slider-thumb { | |
| + -webkit-appearance: none; appearance: none; width: 14px; height: 14px; border-radius: 50%; | |
| + background: #fff; border: none; box-shadow: 0 1px 4px rgba(0,0,0,0.55); cursor: pointer; | |
| + transition: transform 0.12s; | |
| + } | |
| + input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.2); } | |
| + input[type="range"]::-moz-range-thumb { | |
| + width: 14px; height: 14px; border-radius: 50%; background: #fff; border: none; | |
| + box-shadow: 0 1px 4px rgba(0,0,0,0.55); cursor: pointer; | |
| + } | |
| + .switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; } | |
| + .switch input { display: none; } | |
| + .switch .sw { | |
| + display: inline-block; | |
| + width: 34px; height: 20px; border-radius: 999px; background: var(--raised); | |
| + border: 1px solid var(--border); position: relative; flex-shrink: 0; transition: background 0.18s, border-color 0.18s; | |
| + } | |
| + .switch .sw::after { | |
| + content: ""; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; | |
| + border-radius: 50%; background: var(--muted); transition: transform 0.18s, background 0.18s; | |
| + } | |
| + .switch input:checked + .sw { background: var(--accent); border-color: var(--accent); } | |
| + .switch input:checked + .sw::after { transform: translateX(14px); background: #fff; } | |
| + .switch .sw-label { font-size: 0.75rem; color: var(--muted); } | |
| + .layer-row { | |
| + display: flex; align-items: center; gap: 10px; padding: 6px 8px; | |
| + border: 1px solid var(--border); border-radius: 6px; margin-top: 6px; | |
| + background: var(--raised); cursor: pointer; | |
| + } | |
| + .layer-row:hover { border-color: var(--accent); } | |
| + .layer-row .thumb { | |
| + width: 40px; height: 40px; border-radius: 4px; object-fit: cover; flex-shrink: 0; | |
| + background: #101014; border: 1px solid var(--border); | |
| + display: flex; align-items: center; justify-content: center; | |
| + color: var(--dim); font-size: 0.65rem; text-align: center; | |
| + } | |
| + .layer-row .thumb img { width: 100%; height: 100%; object-fit: cover; border-radius: 3px; } | |
| + .layer-row .lname { flex: 1; font-size: 0.78rem; color: var(--text); } | |
| + .layer-row .lsub { font-size: 0.62rem; color: var(--dim); display: block; } | |
| + .layer-row .ldel { | |
| + border: none; background: transparent; color: var(--dim); cursor: pointer; | |
| + font-size: 0.9rem; padding: 2px 6px; border-radius: 4px; | |
| + } | |
| + .layer-row .ldel:hover { color: #ff6f91; background: rgba(255,111,145,0.1); } | |
| + .layer-row.mask-layer .thumb { border-color: var(--accent); } | |
| + .ref-slot { | |
| + height: 72px; border: 1px dashed var(--border); border-radius: 6px; margin-top: 8px; | |
| + display: flex; align-items: center; justify-content: center; cursor: pointer; overflow: hidden; | |
| + color: var(--dim); font-size: 0.72rem; background: var(--raised); | |
| + } | |
| + .ref-slot:hover { border-color: var(--accent); } | |
| + .ref-slot img { width: 100%; height: 100%; object-fit: cover; } | |
| + .hint { font-size: 0.68rem; color: var(--dim); margin-top: 8px; line-height: 1.4; } | |
| + .ex-row { display: flex; gap: 8px; overflow-x: auto; } | |
| + .ex-chip { | |
| + flex: 1; min-width: 76px; border: 1px solid var(--border); border-radius: 8px; | |
| + overflow: hidden; cursor: pointer; background: var(--raised); padding: 0; | |
| + text-align: left; transition: border-color 0.15s; | |
| + } | |
| + .ex-chip:hover { border-color: var(--accent); } | |
| + .ex-chip img { width: 100%; height: 64px; object-fit: cover; display: block; } | |
| + .ex-chip span { display: block; padding: 5px 7px; font-size: 0.62rem; color: var(--muted); line-height: 1.25; } | |
| + #run-btn { | |
| + margin-top: 4px; padding: 12px; border: none; border-radius: 8px; cursor: pointer; | |
| + background: var(--accent); color: white; | |
| + font-weight: 600; font-size: 0.9rem; letter-spacing: 0.01em; | |
| + transition: filter 0.15s, transform 0.05s; | |
| + } | |
| + #run-btn:hover { filter: brightness(1.12); } | |
| + #run-btn:active { transform: translateY(1px); } | |
| + #run-btn:disabled { opacity: 0.5; cursor: wait; } | |
| + .statusbar { | |
| + display: flex; align-items: center; gap: 16px; padding: 7px 16px; flex-shrink: 0; | |
| + background: var(--panel); border-top: 1px solid var(--border); | |
| + font-size: 0.75rem; color: var(--muted); | |
| + } | |
| + .statusbar .pulse { width: 8px; height: 8px; border-radius: 50%; background: #4fd1a5; } | |
| + .statusbar.busy .pulse { background: var(--accent); animation: pulse 1s infinite alternate; } | |
| + @keyframes pulse { from { opacity: 0.4; } to { opacity: 1; } } | |
| + #file-input { display: none; } | |
| + @media (max-width: 860px) { | |
| + html, body { height: auto !important; overflow: visible !important; } | |
| + body { display: block; } | |
| + .topbar { flex-wrap: wrap; gap: 8px; padding: 8px 12px; } | |
| + .brand { font-size: 0.88rem; } | |
| + .topbar .ml-badge { display: none; } | |
| + .main { display: block; height: auto; min-height: 0; } | |
| + .rail { | |
| + flex-direction: row; width: 100%; justify-content: center; gap: 10px; | |
| + padding: 6px 10px; border-right: none; border-bottom: 1px solid var(--border); | |
| + } | |
| + .rail button { width: 44px; height: 44px; } | |
| + .viewport { display: block; padding: 10px; overflow: visible; } | |
| + .workflow-view { position: static; height: 78vh; padding: 0 0 10px; } | |
| + .doc-grid { flex-direction: column; align-items: stretch; height: auto; } | |
| + .doc { max-width: 100%; width: 100%; } | |
| + .doc .stage { min-height: 220px; } | |
| + .panels { | |
| + width: 100%; border-left: none; border-top: 1px solid var(--border); | |
| + padding: 10px 12px; overflow-y: visible; | |
| + } | |
| + .mode-tabs button { padding: 6px 13px; font-size: 0.8rem; } | |
| + .statusbar { position: sticky; bottom: 0; } | |
| + } | |
| +</style> | |
| +</head> | |
| +<body> | |
| + <header class="topbar"> | |
| + <div class="brand">⚡ FIBO 2.0 Edit Turbo</div> | |
| + <div class="mode-tabs"> | |
| + <button id="mode-edit" class="active">Edit</button> | |
| + <button id="mode-workflow">Workflow</button> | |
| + </div> | |
| + <div class="spacer"></div> | |
| + <a class="ml-badge" href="https://huggingface.co/briaai/Fibo-2.0-edit-turbo" target="_blank" rel="noopener">Model</a> | |
| + <a class="ml-cta" href="/classic">Classic UI →</a> | |
| + </header> | |
| + | |
| + <div class="main"> | |
| + <div class="rail"> | |
| + <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> | |
| + <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> | |
| + <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> | |
| + <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> | |
| + </div> | |
| + | |
| + <div class="viewport"> | |
| + <div class="workflow-view" id="workflow-view" hidden> | |
| + <iframe src="/workflow/?ui=canvas" title="Workflow canvas"></iframe> | |
| + </div> | |
| + <div class="doc-grid"> | |
| + <figure class="doc" id="doc-before"> | |
| + <figcaption id="before-caption">Source</figcaption> | |
| + <div class="stage" id="before-stage"> | |
| + <img id="source-img" alt="" hidden /> | |
| + <canvas id="mask-canvas"></canvas> | |
| + <div class="drop-hint" id="source-hint"><b>No source image</b>Use the upload tool or drop a file here</div> | |
| + </div> | |
| + </figure> | |
| + <figure class="doc" id="doc-after"> | |
| + <figcaption id="after-caption">Result</figcaption> | |
| + <div class="stage" id="after-stage"> | |
| + <img id="result-img" alt="" hidden /> | |
| + <div class="split-wrap" id="split-wrap" hidden> | |
| + <img id="split-source" alt="" style="clip-path: inset(0 50% 0 0)" /> | |
| + <div class="split-divider" id="split-divider" style="left:50%"></div> | |
| + </div> | |
| + <div class="drop-hint" id="result-hint"><b>Nothing yet</b>Run to see the result</div> | |
| + </div> | |
| + <div class="doc-bar"> | |
| + <span id="result-meta">—</span> | |
| + <div class="spacer"></div> | |
| + <button id="split-toggle" hidden>SPLIT</button> | |
| + </div> | |
| + </figure> | |
| + </div> | |
| + <div class="history-strip" id="history-strip"><span class="hlabel">History</span></div> | |
| + </div> | |
| + | |
| + <aside class="panels"> | |
| + <section class="panel"> | |
| + <div class="panel-title">Examples</div> | |
| + <div class="ex-row"> | |
| + <button class="ex-chip" data-img="/examples/cat_tabby.jpg" data-instruction="Put a small party hat on the cat"> | |
| + <img src="/examples/cat_tabby.jpg" alt="" /><span>Party hat on the cat</span> | |
| + </button> | |
| + <button class="ex-chip" data-img="/examples/bicycle_wall.jpg" data-instruction="Change the bicycle color to bright red"> | |
| + <img src="/examples/bicycle_wall.jpg" alt="" /><span>Red bicycle</span> | |
| + </button> | |
| + <button class="ex-chip" data-img="/examples/businessman_suit.jpg" data-instruction="Add round glasses"> | |
| + <img src="/examples/businessman_suit.jpg" alt="" /><span>Round glasses</span> | |
| + </button> | |
| + </div> | |
| + </section> | |
| + <section class="panel"> | |
| + <div class="panel-title">Instruction</div> | |
| + <label for="instruction">What should change?</label> | |
| + <textarea id="instruction" rows="3" placeholder="make the sky dramatic and stormy"></textarea> | |
| + <label for="json-instruction">JSON override (optional; auto-filled by the VGL prompt writer)</label> | |
| + <textarea id="json-instruction" class="code" rows="5" placeholder='{"edit_instruction": "…"}'></textarea> | |
| + </section> | |
| + <section class="panel"> | |
| + <div class="panel-title">Layers</div> | |
| + <div class="layer-row" id="layer-source"> | |
| + <div class="thumb" id="layer-source-thumb">BG</div> | |
| + <div class="lname">Background<span class="lsub">source image</span></div> | |
| + </div> | |
| + <div class="layer-row mask-layer" id="layer-mask" hidden> | |
| + <div class="thumb" id="layer-mask-thumb"></div> | |
| + <div class="lname">Mask<span class="lsub">repaint region</span></div> | |
| + <button class="ldel" id="layer-mask-del" title="Clear mask">✕</button> | |
| + </div> | |
| + <div class="ref-slot" data-ref="2">+ Reference 2</div> | |
| + <div class="ref-slot" data-ref="3">+ Reference 3</div> | |
| + <div class="ref-slot" data-ref="4">+ Reference 4</div> | |
| + <div class="hint">Slots map to <image_1>…<image_4> in the instruction. A mask only works with a single reference.</div> | |
| + </section> | |
| + <section class="panel"> | |
| + <div class="panel-title">Advanced</div> | |
| + <div class="row"> | |
| + <div><label for="seed">Seed</label><input type="number" id="seed" value="42" min="1" /></div> | |
| + <label class="switch"><input type="checkbox" id="randomize" checked /><span class="sw"></span><span class="sw-label">Randomize</span></label> | |
| + </div> | |
| + <div class="row" style="margin-top:10px"> | |
| + <div><label>Steps <span id="steps-val">4</span></label><input type="range" id="steps" min="1" max="8" value="4" /></div> | |
| + </div> | |
| + <div class="hint">Edit Turbo is distilled for 4 guidance-free steps.</div> | |
| + </section> | |
| + <button id="run-btn">Generate Edit</button> | |
| + </aside> | |
| + </div> | |
| + | |
| + <footer class="statusbar" id="statusbar"> | |
| + <div class="pulse"></div> | |
| + <span id="status">Idle</span> | |
| + <div class="spacer"></div> | |
| + <span id="seed-out"></span> | |
| + </footer> | |
| + | |
| + <input type="file" id="file-input" accept="image/*" /> | |
| + | |
| +<script type="module"> | |
| + import { client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; | |
| + | |
| + const $ = (id) => document.getElementById(id); | |
| + const state = { mode: "edit", sourcePath: null, refPaths: { 2: null, 3: null, 4: null }, app: null }; | |
| + const docState = { source: null, result: null, history: [] }; | |
| + | |
| + async function getApp() { | |
| + if (!state.app) state.app = await client(location.origin); | |
| + return state.app; | |
| + } | |
| + | |
| + async function uploadFile(file) { | |
| + const form = new FormData(); | |
| + form.append("files", file); | |
| + const resp = await fetch("/gradio_api/upload", { method: "POST", body: form }); | |
| + if (!resp.ok) throw new Error(`Upload failed (${resp.status})`); | |
| + const paths = await resp.json(); | |
| + return Array.isArray(paths) ? paths[0] : paths; | |
| + } | |
| + | |
| + function setStatus(text, busy = false) { | |
| + $("status").textContent = text; | |
| + $("statusbar").classList.toggle("busy", busy); | |
| + $("run-btn").disabled = busy; | |
| + } | |
| + | |
| + function setMode(mode) { | |
| + state.mode = mode; | |
| + $("mode-edit").classList.toggle("active", mode === "edit"); | |
| + $("mode-workflow").classList.toggle("active", mode === "workflow"); | |
| + const workflow = mode === "workflow"; | |
| + $("workflow-view").hidden = !workflow; | |
| + document.querySelector(".doc-grid").style.display = workflow ? "none" : ""; | |
| + document.querySelector(".rail").style.display = workflow ? "none" : ""; | |
| + document.querySelector(".panels").style.display = workflow ? "none" : ""; | |
| + document.getElementById("history-strip").style.display = workflow ? "none" : ""; | |
| + } | |
| + $("mode-edit").onclick = () => setMode("edit"); | |
| + $("mode-workflow").onclick = () => setMode("workflow"); | |
| + | |
| + // ---------- uploads ---------- | |
| + let uploadTarget = "source"; | |
| + function pickFile(target) { | |
| + uploadTarget = target; | |
| + $("file-input").click(); | |
| + } | |
| + $("tool-upload").onclick = () => pickFile("source"); | |
| + document.querySelectorAll(".ref-slot").forEach((slot) => { | |
| + slot.onclick = () => pickFile(slot.dataset.ref); | |
| + }); | |
| + $("file-input").onchange = async (e) => { | |
| + const file = e.target.files[0]; | |
| + e.target.value = ""; | |
| + if (!file) return; | |
| + setStatus("Uploading…", true); | |
| + try { | |
| + const path = await uploadFile(file); | |
| + if (uploadTarget === "source") { | |
| + state.sourcePath = path; | |
| + showSource(file); | |
| + } else { | |
| + state.refPaths[uploadTarget] = path; | |
| + const slot = document.querySelector(`.ref-slot[data-ref="${uploadTarget}"]`); | |
| + slot.innerHTML = ""; | |
| + const img = document.createElement("img"); | |
| + img.src = URL.createObjectURL(file); | |
| + slot.appendChild(img); | |
| + } | |
| + setStatus("Idle"); | |
| + } catch (err) { | |
| + setStatus(String(err)); | |
| + } | |
| + }; | |
| + $("before-stage").addEventListener("dragover", (e) => e.preventDefault()); | |
| + $("before-stage").addEventListener("drop", async (e) => { | |
| + e.preventDefault(); | |
| + const file = e.dataTransfer.files[0]; | |
| + if (!file) return; | |
| + setStatus("Uploading…", true); | |
| + try { | |
| + state.sourcePath = await uploadFile(file); | |
| + showSource(file); | |
| + setStatus("Idle"); | |
| + } catch (err) { | |
| + setStatus(String(err)); | |
| + } | |
| + }); | |
| + | |
| + function showSource(file) { | |
| + const img = $("source-img"); | |
| + img.src = URL.createObjectURL(file); | |
| + img.hidden = false; | |
| + $("source-hint").style.display = "none"; | |
| + img.onload = () => sizeMaskCanvas(); | |
| + clearMask(); | |
| + docState.source = img.src; | |
| + const st = $("layer-source-thumb"); | |
| + st.innerHTML = ""; | |
| + const stImg = document.createElement("img"); | |
| + stImg.src = img.src; | |
| + st.appendChild(stImg); | |
| + } | |
| + | |
| + // ---------- mask brush ---------- | |
| + const maskCanvas = $("mask-canvas"); | |
| + const mctx = maskCanvas.getContext("2d"); | |
| + let strokes = []; | |
| + let drawing = false; | |
| + function sizeMaskCanvas() { | |
| + const stage = $("before-stage"); | |
| + const img = $("source-img"); | |
| + if (!img.src || img.hidden) return; | |
| + const box = img.getBoundingClientRect(); | |
| + const stageBox = stage.getBoundingClientRect(); | |
| + maskCanvas.width = box.width; | |
| + maskCanvas.height = box.height; | |
| + maskCanvas.style.left = box.left - stageBox.left + "px"; | |
| + maskCanvas.style.top = box.top - stageBox.top + "px"; | |
| + maskCanvas.style.width = box.width + "px"; | |
| + maskCanvas.style.height = box.height + "px"; | |
| + mctx.fillStyle = "#000"; | |
| + mctx.fillRect(0, 0, maskCanvas.width, maskCanvas.height); | |
| + } | |
| + function brushPos(e) { | |
| + const box = maskCanvas.getBoundingClientRect(); | |
| + return { x: e.clientX - box.left, y: e.clientY - box.top }; | |
| + } | |
| + maskCanvas.addEventListener("pointerdown", (e) => { drawing = true; strokes.push([brushPos(e)]); }); | |
| + maskCanvas.addEventListener("pointermove", (e) => { | |
| + if (!drawing) return; | |
| + strokes[strokes.length - 1].push(brushPos(e)); | |
| + redrawMask(); | |
| + }); | |
| + window.addEventListener("pointerup", () => { | |
| + drawing = false; | |
| + if (state.brushOn && strokes.length) { | |
| + $("layer-mask").hidden = false; | |
| + const mt = $("layer-mask-thumb"); | |
| + mt.innerHTML = ""; | |
| + const t = document.createElement("img"); | |
| + t.src = maskCanvas.toDataURL(); | |
| + mt.appendChild(t); | |
| + } | |
| + }); | |
| + function redrawMask() { | |
| + mctx.fillStyle = "#000"; | |
| + mctx.fillRect(0, 0, maskCanvas.width, maskCanvas.height); | |
| + mctx.strokeStyle = "#fff"; | |
| + mctx.lineWidth = Math.max(16, maskCanvas.width / 40); | |
| + mctx.lineCap = "round"; | |
| + mctx.lineJoin = "round"; | |
| + for (const stroke of strokes) { | |
| + mctx.beginPath(); | |
| + stroke.forEach((p, i) => (i ? mctx.lineTo(p.x, p.y) : mctx.moveTo(p.x, p.y))); | |
| + mctx.stroke(); | |
| + } | |
| + mctx.save(); | |
| + mctx.globalCompositeOperation = "source-atop"; | |
| + mctx.fillStyle = "rgba(124,92,255,0.35)"; | |
| + mctx.fillRect(0, 0, maskCanvas.width, maskCanvas.height); | |
| + mctx.restore(); | |
| + } | |
| + function clearMask() { | |
| + strokes = []; | |
| + maskCanvas.classList.remove("active"); | |
| + $("tool-brush").classList.remove("active"); | |
| + mctx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); | |
| + $("layer-mask").hidden = true; | |
| + } | |
| + state.brushOn = false; | |
| + $("tool-brush").onclick = () => { | |
| + if (state.sourcePath == null) { setStatus("Upload a source image first."); return; } | |
| + state.brushOn = !state.brushOn; | |
| + maskCanvas.classList.toggle("active", state.brushOn); | |
| + $("tool-brush").classList.toggle("active", state.brushOn); | |
| + if (state.brushOn) { sizeMaskCanvas(); redrawMask(); setStatus("Brush the area to repaint."); } | |
| + else setStatus("Idle"); | |
| + }; | |
| + $("tool-clear-mask").onclick = clearMask; | |
| + $("layer-mask-del").onclick = (e) => { e.stopPropagation(); clearMask(); }; | |
| + $("tool-clear-doc").onclick = () => { | |
| + state.sourcePath = null; | |
| + docState.source = null; | |
| + docState.result = null; | |
| + $("source-img").hidden = true; | |
| + $("source-hint").style.display = ""; | |
| + $("result-img").hidden = true; | |
| + $("result-hint").style.display = ""; | |
| + $("layer-source-thumb").textContent = "BG"; | |
| + clearMask(); | |
| + }; | |
| + | |
| + async function maskPath() { | |
| + if (!strokes.length) return null; | |
| + const img = $("source-img"); | |
| + const out = document.createElement("canvas"); | |
| + out.width = img.naturalWidth; | |
| + out.height = img.naturalHeight; | |
| + const octx = out.getContext("2d"); | |
| + octx.fillStyle = "#000"; | |
| + octx.fillRect(0, 0, out.width, out.height); | |
| + const sx = out.width / maskCanvas.width; | |
| + const sy = out.height / maskCanvas.height; | |
| + octx.strokeStyle = "#fff"; | |
| + octx.lineWidth = Math.max(16, maskCanvas.width / 40) * sx; | |
| + octx.lineCap = "round"; | |
| + octx.lineJoin = "round"; | |
| + for (const stroke of strokes) { | |
| + octx.beginPath(); | |
| + stroke.forEach((p, i) => (i ? octx.lineTo(p.x * sx, p.y * sy) : octx.moveTo(p.x * sx, p.y * sy))); | |
| + octx.stroke(); | |
| + } | |
| + const blob = await new Promise((r) => out.toBlob(r, "image/png")); | |
| + return uploadFile(blob); | |
| + } | |
| + | |
| + // ---------- history ---------- | |
| + function renderHistory() { | |
| + const strip = $("history-strip"); | |
| + strip.innerHTML = '<span class="hlabel">History</span>'; | |
| + docState.history.forEach((h) => { | |
| + const chip = document.createElement("div"); | |
| + chip.className = "history-chip"; | |
| + chip.title = h.instruction || ""; | |
| + const img = document.createElement("img"); | |
| + img.src = h.uri; | |
| + const tag = document.createElement("div"); | |
| + tag.className = "hseed"; | |
| + tag.textContent = h.seed ?? ""; | |
| + chip.appendChild(img); | |
| + chip.appendChild(tag); | |
| + chip.onclick = () => { | |
| + $("instruction").value = h.instruction; | |
| + $("seed").value = h.seed ?? 42; | |
| + $("randomize").checked = false; | |
| + docState.result = h.uri; | |
| + $("result-img").src = h.uri; | |
| + $("result-img").hidden = false; | |
| + $("result-hint").style.display = "none"; | |
| + $("split-toggle").hidden = false; | |
| + }; | |
| + strip.appendChild(chip); | |
| + }); | |
| + } | |
| + function pushHistory(uri, seed, instruction) { | |
| + docState.history.unshift({ uri, seed, instruction }); | |
| + if (docState.history.length > 8) docState.history.pop(); | |
| + renderHistory(); | |
| + } | |
| + | |
| + // ---------- before/after split view ---------- | |
| + let splitOn = false; | |
| + $("split-toggle").onclick = () => { | |
| + if ($("result-img").hidden) return; | |
| + splitOn = !splitOn; | |
| + $("split-toggle").classList.toggle("active", splitOn); | |
| + $("split-wrap").hidden = !splitOn; | |
| + if (splitOn && !$("source-img").hidden) $("split-source").src = $("source-img").src; | |
| + }; | |
| + (function () { | |
| + const divider = $("split-divider"); | |
| + let dragging = false; | |
| + divider.addEventListener("pointerdown", (e) => { dragging = true; e.preventDefault(); }); | |
| + window.addEventListener("pointermove", (e) => { | |
| + if (!dragging) return; | |
| + const stage = $("after-stage").getBoundingClientRect(); | |
| + const pct = Math.min(97, Math.max(3, ((e.clientX - stage.left) / stage.width) * 100)); | |
| + divider.style.left = pct + "%"; | |
| + $("split-source").style.clipPath = `inset(0 ${100 - pct}% 0 0)`; | |
| + }); | |
| + window.addEventListener("pointerup", () => { dragging = false; }); | |
| + })(); | |
| + | |
| + // ---------- zoom ---------- | |
| + document.querySelectorAll(".doc figcaption").forEach((caption) => { | |
| + const btn = document.createElement("button"); | |
| + btn.className = "zoom-btn"; | |
| + btn.textContent = "FIT"; | |
| + btn.onclick = () => { | |
| + const img = caption.parentElement.querySelector("img"); | |
| + const stage = caption.parentElement.querySelector(".stage"); | |
| + const zoomed = img.classList.toggle("zoomed"); | |
| + stage.classList.toggle("scrollable", zoomed); | |
| + btn.textContent = zoomed ? "1:1" : "FIT"; | |
| + }; | |
| + caption.appendChild(btn); | |
| + }); | |
| + | |
| + // ---------- collapsible panels ---------- | |
| + document.querySelectorAll(".panel .panel-title").forEach((title) => { | |
| + const body = document.createElement("div"); | |
| + body.className = "panel-body"; | |
| + [...title.parentElement.children].forEach((child) => { | |
| + if (child !== title) body.appendChild(child); | |
| + }); | |
| + title.parentElement.appendChild(body); | |
| + title.onclick = () => title.parentElement.classList.toggle("collapsed"); | |
| + }); | |
| + | |
| + // ---------- examples ---------- | |
| + document.querySelectorAll(".ex-chip").forEach((chip) => { | |
| + chip.onclick = async () => { | |
| + setStatus("Loading example…", true); | |
| + try { | |
| + const blob = await (await fetch(chip.dataset.img)).blob(); | |
| + const file = new File([blob], "example.jpg", { type: "image/jpeg" }); | |
| + state.sourcePath = await uploadFile(file); | |
| + showSource(file); | |
| + $("instruction").value = chip.dataset.instruction; | |
| + setMode("edit"); | |
| + setStatus("Example loaded — hit Generate Edit"); | |
| + } catch (err) { | |
| + setStatus(String(err)); | |
| + } | |
| + }; | |
| + }); | |
| + | |
| + // ---------- result handling ---------- | |
| + function unwrap(result) { | |
| + const data = Array.isArray(result?.data) ? result.data[0] : result?.data; | |
| + return data ?? {}; | |
| + } | |
| + function showResult(result) { | |
| + const payload = unwrap(result); | |
| + const url = typeof payload.image_data === "string" ? payload.image_data : null; | |
| + if (!url) { setStatus("Run finished but no image came back (see console)."); return false; } | |
| + const img = $("result-img"); | |
| + img.src = url; | |
| + img.hidden = false; | |
| + $("result-hint").style.display = "none"; | |
| + docState.result = url; | |
| + if (payload.seed != null) $("seed-out").textContent = "seed " + payload.seed; | |
| + $("split-toggle").hidden = false; | |
| + $("result-meta").textContent = "Edited"; | |
| + const instruction = ($("json-instruction").value.trim() || $("instruction").value); | |
| + pushHistory(url, payload.seed, instruction); | |
| + return true; | |
| + } | |
| + | |
| + $("steps").oninput = () => ($("steps-val").textContent = $("steps").value); | |
| + | |
| + $("run-btn").onclick = async () => { | |
| + const app = await getApp(); | |
| + if (!state.sourcePath) { setStatus("Upload a source image first."); return; } | |
| + const seed = parseInt($("seed").value, 10) || 42; | |
| + const randomize = $("randomize").checked; | |
| + const steps = parseInt($("steps").value, 10); | |
| + try { | |
| + setStatus("Preparing mask…", true); | |
| + const mask = await maskPath(); | |
| + setStatus($("json-instruction").value.trim() ? "Editing…" : "Writing the instruction (VGL)…", true); | |
| + const result = await app.predict("/edit", { | |
| + source: state.sourcePath, | |
| + instruction: $("instruction").value, | |
| + json_instruction: $("json-instruction").value, | |
| + ref2: state.refPaths[2], | |
| + ref3: state.refPaths[3], | |
| + ref4: state.refPaths[4], | |
| + mask, | |
| + seed, randomize, steps, | |
| + }); | |
| + const payload = unwrap(result); | |
| + if (payload.json_instruction) $("json-instruction").value = payload.json_instruction; | |
| + if (showResult({ data: [payload] })) setStatus("Done"); | |
| + } catch (err) { | |
| + setStatus(err?.message || String(err)); | |
| + } | |
| + }; | |
| +</script> | |
| +</body> | |
| +</html> | |
| diff --git a/workflow.json b/workflow.json | |
| new file mode 100644 | |
| index 0000000..3a13639 | |
| --- /dev/null | |
| +++ b/workflow.json | |
| +{ | |
| + "schema_version": "2", | |
| + "name": "FIBO 2.0 Edit Turbo Workflow", | |
| + "references": [ | |
| + { | |
| + "id": "ref_source", | |
| + "label": "Source Image", | |
| + "role": "reference", | |
| + "asset_type": "image", | |
| + "inputs": [{"id": "in", "label": "Image", "type": "image"}], | |
| + "outputs": [{"id": "out", "label": "Image", "type": "image"}], | |
| + "x": 40, | |
| + "y": 40, | |
| + "width": 220, | |
| + "height": 130, | |
| + "data": {} | |
| + }, | |
| + { | |
| + "id": "ref_instruction", | |
| + "label": "Edit Instruction (JSON)", | |
| + "role": "reference", | |
| + "asset_type": "text", | |
| + "inputs": [{"id": "in", "label": "Text", "type": "text"}], | |
| + "outputs": [{"id": "out", "label": "Text", "type": "text"}], | |
| + "x": 40, | |
| + "y": 200, | |
| + "width": 280, | |
| + "height": 140, | |
| + "data": { | |
| + "out": "{\n \"edit_instruction\": \"make the sky dramatic and stormy\"\n}" | |
| + } | |
| + }, | |
| + { | |
| + "id": "ref_seed", | |
| + "label": "Seed", | |
| + "role": "reference", | |
| + "asset_type": "text", | |
| + "inputs": [{"id": "in", "label": "Text", "type": "text"}], | |
| + "outputs": [{"id": "out", "label": "Text", "type": "text"}], | |
| + "x": 340, | |
| + "y": 200, | |
| + "width": 130, | |
| + "height": 90, | |
| + "data": {"out": "42"} | |
| + }, | |
| + { | |
| + "id": "ref_steps", | |
| + "label": "Steps", | |
| + "role": "reference", | |
| + "asset_type": "text", | |
| + "inputs": [{"id": "in", "label": "Text", "type": "text"}], | |
| + "outputs": [{"id": "out", "label": "Text", "type": "text"}], | |
| + "x": 340, | |
| + "y": 310, | |
| + "width": 130, | |
| + "height": 90, | |
| + "data": {"out": "4"} | |
| + }, | |
| + { | |
| + "id": "ref_instruction_2", | |
| + "label": "Second Instruction (JSON)", | |
| + "role": "reference", | |
| + "asset_type": "text", | |
| + "inputs": [{"id": "in", "label": "Text", "type": "text"}], | |
| + "outputs": [{"id": "out", "label": "Text", "type": "text"}], | |
| + "x": 640, | |
| + "y": 40, | |
| + "width": 280, | |
| + "height": 130, | |
| + "data": { | |
| + "out": "{\n \"edit_instruction\": \"turn the scene into night with warm window lights\"\n}" | |
| + } | |
| + } | |
| + ], | |
| + "operators": [ | |
| + { | |
| + "id": "op_edit", | |
| + "label": "FIBO Edit", | |
| + "role": "operator", | |
| + "kind": "fn", | |
| + "fn": "fibo_edit", | |
| + "inputs": [ | |
| + {"id": "image", "label": "Image", "type": "image", "required": true}, | |
| + {"id": "json_instruction", "label": "JSON Instruction", "type": "text", "required": true}, | |
| + {"id": "seed", "label": "Seed", "type": "text"}, | |
| + {"id": "steps", "label": "Steps", "type": "text"} | |
| + ], | |
| + "outputs": [{"id": "out_0", "label": "Image", "type": "image", "output_index": 0}], | |
| + "x": 520, | |
| + "y": 120, | |
| + "width": 230, | |
| + "height": 160, | |
| + "data": {} | |
| + }, | |
| + { | |
| + "id": "op_edit_2", | |
| + "label": "FIBO Edit (chained)", | |
| + "role": "operator", | |
| + "kind": "fn", | |
| + "fn": "fibo_edit", | |
| + "inputs": [ | |
| + {"id": "image", "label": "Image", "type": "image", "required": true}, | |
| + {"id": "json_instruction", "label": "JSON Instruction", "type": "text", "required": true}, | |
| + {"id": "seed", "label": "Seed", "type": "text"}, | |
| + {"id": "steps", "label": "Steps", "type": "text"} | |
| + ], | |
| + "outputs": [{"id": "out_0", "label": "Image", "type": "image", "output_index": 0}], | |
| + "x": 960, | |
| + "y": 120, | |
| + "width": 230, | |
| + "height": 160, | |
| + "data": {} | |
| + } | |
| + ], | |
| + "subjects": [ | |
| + { | |
| + "id": "sub_edited", | |
| + "label": "Edited Image", | |
| + "role": "subject", | |
| + "asset_type": "image", | |
| + "inputs": [{"id": "in", "label": "Image", "type": "image"}], | |
| + "outputs": [{"id": "out", "label": "Image", "type": "image"}], | |
| + "x": 780, | |
| + "y": 340, | |
| + "width": 230, | |
| + "height": 110, | |
| + "data": {} | |
| + }, | |
| + { | |
| + "id": "sub_chained", | |
| + "label": "Final Image", | |
| + "role": "subject", | |
| + "asset_type": "image", | |
| + "inputs": [{"id": "in", "label": "Image", "type": "image"}], | |
| + "outputs": [{"id": "out", "label": "Image", "type": "image"}], | |
| + "x": 1240, | |
| + "y": 120, | |
| + "width": 230, | |
| + "height": 110, | |
| + "data": {} | |
| + } | |
| + ], | |
| + "edges": [ | |
| + {"id": "e_source", "from_node_id": "ref_source", "from_port_id": "out", "to_node_id": "op_edit", "to_port_id": "image", "type": "image"}, | |
| + {"id": "e_instruction", "from_node_id": "ref_instruction", "from_port_id": "out", "to_node_id": "op_edit", "to_port_id": "json_instruction", "type": "text"}, | |
| + {"id": "e_seed", "from_node_id": "ref_seed", "from_port_id": "out", "to_node_id": "op_edit", "to_port_id": "seed", "type": "text"}, | |
| + {"id": "e_steps", "from_node_id": "ref_steps", "from_port_id": "out", "to_node_id": "op_edit", "to_port_id": "steps", "type": "text"}, | |
| + {"id": "e_out", "from_node_id": "op_edit", "from_port_id": "out_0", "to_node_id": "sub_edited", "to_port_id": "in", "type": "image"}, | |
| + {"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"}, | |
| + {"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"}, | |
| + {"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"}, | |
| + {"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"}, | |
| + {"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"} | |
| + ] | |
| +} | |
| -- | |
| 2.47.3 | |