Spaces:
Running on Zero
Running on Zero
Download app.py from diffusers-modular/flux3-image-klein-bbox: direct link, hf CLI and curl.
- Browser
- Download file 60.4 kB
-
https://huggingface.co/spaces/diffusers-modular/flux3-image-klein-bbox/resolve/main/app.py
- Command line
-
hf download hf://spaces/diffusers-modular/flux3-image-klein-bbox/app.py
-
curl -L -o app.py https://huggingface.co/spaces/diffusers-modular/flux3-image-klein-bbox/resolve/main/app.py
60.4 kB
| """FLUX.3 image-small β layout-control canvas (private ZeroGPU demo, diffusers modular). | |
| A custom HTML/JS canvas on top of the diffusers-native FLUX.3 modular pipeline | |
| (PR #29, vendored `diffusers/`), running the **step-distilled** flow-map model | |
| (`Flux3DistilledAutoBlocks`, 16 steps, no guider / negative prompt), with its | |
| transformer block stacks **AOT-compiled via aokit** for ZeroGPU (`aokit.load` + | |
| a per-block eager fallback; set `F3_AOTI=0` to run eager). | |
| You drag bounding boxes on an aspect-ratio artboard, give each box an id + a | |
| description, write a one-line scene prompt, and FLUX.3 renders the scene with | |
| every element placed inside its box. | |
| Layout control uses FLUX.3's region format (see `integration-notes/flux3_examples.py`): | |
| the final prompt is the scene caption followed by a JSON list of regions, each | |
| `{"id", "bbox": [y0, x0, y1, x1] (normalized 0-1000), "desc"}`. | |
| """ | |
| import importlib.util | |
| import os | |
| os.environ.setdefault("TORCH_ALLOW_TF32_CUBLAS_OVERRIDE", "1") | |
| spaces = None | |
| if importlib.util.find_spec("spaces"): | |
| import spaces # before torch so ZeroGPU can patch torch | |
| import base64 | |
| import hashlib | |
| import io | |
| import json | |
| import random | |
| from pathlib import Path | |
| import gradio as gr | |
| import torch | |
| from PIL import Image | |
| from huggingface_hub import hf_hub_download, InferenceClient | |
| from diffusers import ModularPipeline | |
| # Step-distilled FLUX.3 image model + its private AOTI artifact. | |
| REPO = os.environ.get("F3_REPO", "diffusers-internal-dev/f3-small-image-distilled") | |
| AOTI_REPO = os.environ.get("F3_AOTI_REPO", "diffusers-modular/f3-small-image-distilled-aoti-sm120") | |
| AOTI_ENABLED = os.environ.get("F3_AOTI", "1") != "0" | |
| HF_TOKEN = os.environ.get("HF_TOKEN") | |
| # Header "button" links (klein-style pills). Point at the model cards; override via env | |
| # when switching to the public klein repos. | |
| LINK_DISTILLED = os.environ.get("F3_LINK_DISTILLED", "https://huggingface.co/black-forest-labs/flux-3-image-klein") | |
| LINK_BASE = os.environ.get("F3_LINK_BASE", "https://huggingface.co/black-forest-labs/flux-3-image-klein-base") | |
| LINK_CODE = os.environ.get("F3_LINK_CODE", "https://github.com/black-forest-labs/flux-3-image-klein") | |
| LINK_BOXES = os.environ.get("F3_LINK_BOXES", "https://docs.bfl.ai/flux_3/flux3_image_bounding_boxes") | |
| MAX_SEED = 2**63 - 1 | |
| DEFAULT_STEPS = int(os.environ.get("F3_STEPS", "16")) # distilled default | |
| torch.backends.cuda.matmul.allow_tf32 = True | |
| torch.backends.cudnn.allow_tf32 = True | |
| # Aspect ratio -> pixel dims (multiples of 16, ~1 MP, the model's default area). | |
| RATIOS = { | |
| "1:1": (1024, 1024), | |
| "3:2": (1216, 816), | |
| "2:3": (816, 1216), | |
| "4:3": (1152, 864), | |
| "3:4": (864, 1152), | |
| "16:9": (1344, 768), | |
| "9:16": (768, 1344), | |
| } | |
| def _eager_fallback(compiled, eager): | |
| """Call the AOTI graph; fall back to the eager forward on any shape it can't | |
| handle (keeps odd resolutions / layouts working instead of crashing).""" | |
| def forward(*args, **kwargs): | |
| try: | |
| return compiled(*args, **kwargs) | |
| except Exception: | |
| return eager(*args, **kwargs) | |
| return forward | |
| def _load_aoti(transformer, repo, token): | |
| """aokit.load the compiled stacks, then wrap each block with an eager fallback. | |
| The artifact's torch version is asserted against the runtime.""" | |
| import aokit | |
| cfg = json.loads(Path(hf_hub_download(repo, "config.json", token=token)).read_text()) | |
| if cfg.get("torch") != torch.__version__: | |
| raise RuntimeError( | |
| f"AOTI artifact built for torch {cfg.get('torch')}, runtime has {torch.__version__}" | |
| ) | |
| stack_names = list(cfg.get("stacks", {})) or [ | |
| "mode_blocks.image", "mode_blocks.image_cond", "txt_mode_blocks", "single_transformer_blocks", | |
| ] | |
| def _blocks(name): | |
| try: | |
| stack = transformer.get_submodule(name) | |
| except AttributeError: | |
| return [] | |
| return list(stack) if isinstance(stack, torch.nn.ModuleList) else [stack] | |
| eager = {name: [blk.forward for blk in _blocks(name)] for name in stack_names} | |
| aokit.load(transformer, repo) # patches block.forward -> compiled (weights stay live) | |
| wrapped = 0 | |
| for name in stack_names: | |
| for blk, eager_fwd in zip(_blocks(name), eager[name]): | |
| blk.forward = _eager_fallback(blk.forward, eager_fwd) | |
| wrapped += 1 | |
| print(f"[app] AOTI loaded from {repo}: {len(stack_names)} stacks, {wrapped} blocks wrapped " | |
| f"(torch {cfg.get('torch')}, {cfg.get('arch')})", flush=True) | |
| return cfg | |
| # ---- load the pipeline on CPU at import; place on cuda + AOTI under ZeroGPU ---- | |
| print(f"[app] loading distilled modular pipeline from {REPO} ...", flush=True) | |
| PIPE = ModularPipeline.from_pretrained(REPO, token=HF_TOKEN) | |
| PIPE.load_components(dtype={"vae": torch.float32, "default": torch.bfloat16}, token=HF_TOKEN) | |
| print("[app] components loaded on CPU.", flush=True) | |
| AOTI_LOADED = False | |
| if spaces is not None: | |
| PIPE.to("cuda") | |
| if AOTI_ENABLED: | |
| try: | |
| _load_aoti(PIPE.transformer, AOTI_REPO, HF_TOKEN) | |
| AOTI_LOADED = True | |
| except Exception as exc: # fall back to eager if the artifact is unavailable | |
| print(f"[app] AOTI unavailable, running eager: {exc!r}", flush=True) | |
| print(f"[app] pipeline on cuda; ready (aoti={AOTI_LOADED}).", flush=True) | |
| def _build_prompt(scene: str, elements: list) -> str: | |
| """Scene caption + JSON region list, FLUX.3's layout-control format.""" | |
| scene = (scene or "").strip() | |
| regions = [] | |
| for e in elements: | |
| desc = (e.get("desc") or "").strip() | |
| if not desc: | |
| continue | |
| bbox = e.get("bbox") | |
| if not (isinstance(bbox, list) and len(bbox) == 4): | |
| continue | |
| regions.append({ | |
| "id": e.get("id") or "element", | |
| "bbox": [int(round(v)) for v in bbox], | |
| "desc": desc, | |
| }) | |
| if not regions: | |
| return scene | |
| return (scene + " " if scene else "") + json.dumps(regions) | |
| REF_MAX_SIDE = 1024 # cap the reference's long side so the VAE encode stays bounded | |
| def _decode_ref(data_uri): | |
| """Decode a `data:image/...;base64,...` reference from the canvas into a | |
| capped RGB PIL image, or None.""" | |
| if not data_uri or not isinstance(data_uri, str) or "," not in data_uri: | |
| return None | |
| try: | |
| raw = base64.b64decode(data_uri.split(",", 1)[1]) | |
| img = Image.open(io.BytesIO(raw)).convert("RGB") | |
| except Exception: | |
| return None | |
| w, h = img.size | |
| s = REF_MAX_SIDE / max(w, h) | |
| if s < 1.0: | |
| img = img.resize((max(16, round(w * s)), max(16, round(h * s))), Image.LANCZOS) | |
| return img | |
| # ---- optional VLM prompt upsampling (klein-style) --------------------------- | |
| VLM_MODEL = os.environ.get("F3_VLM_MODEL", "Qwen/Qwen3-VL-235B-A22B-Instruct") | |
| _hf_client = InferenceClient(api_key=os.environ.get("INFERENCE_TOKEN") or HF_TOKEN) | |
| SYSTEM_PROMPT_TEXT_ONLY = """You are an expert prompt engineer for FLUX.3 by Black Forest Labs. Rewrite user prompts into a full scene caption while strictly preserving their core subject and intent. | |
| Guidelines: | |
| 1. Structure: Keep structured inputs structured (enhance within fields). Convert natural language to detailed paragraphs. | |
| 2. Details: Describe the subject, composition, lighting (quality, direction, color), atmosphere, textures, materials, spatial relationships, and environmental context. | |
| 3. Text in Images: Put ALL text in quotation marks, matching the prompt's language. Always provide explicit quoted text for objects that would contain text in reality (signs, labels, screens, etc.) - without it, the model generates gibberish. | |
| Output only the revised prompt and nothing else.""" | |
| SYSTEM_PROMPT_WITH_IMAGES = """You are FLUX.3 by Black Forest Labs, an image-editing expert. You turn an editing request into a complete caption of the target image: describe the reference image(s) in full with the requested edit folded in (60-120 words). | |
| Rules: | |
| - One caption only, no commentary | |
| - Use clear, analytical language (avoid "whimsical," "cascading," etc.) | |
| - Keep everything the request does not change (faces, lighting, composition) and describe it as it is | |
| - Refer to the references as <ref_image_0>, <ref_image_1>, ... in the order given ("the person from <ref_image_0>") | |
| - Make abstractions concrete ("futuristic" -> "glowing cyan neon, metallic panels") | |
| - Keep content PG-13 | |
| Output only the final caption in plain text and nothing else.""" | |
| def _img_data_uri(img): | |
| buf = io.BytesIO() | |
| img.save(buf, format="PNG") | |
| return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode() | |
| def _upsample_prompt(prompt, image_list=None): | |
| """Rewrite the prompt into a full scene caption via a VLM; returns the original | |
| prompt on any failure so generation always proceeds.""" | |
| try: | |
| if image_list: | |
| content = [{"type": "text", "text": prompt}] | |
| for img in image_list: | |
| content.append({"type": "image_url", "image_url": {"url": _img_data_uri(img)}}) | |
| messages = [{"role": "system", "content": SYSTEM_PROMPT_WITH_IMAGES}, | |
| {"role": "user", "content": content}] | |
| else: | |
| messages = [{"role": "system", "content": SYSTEM_PROMPT_TEXT_ONLY}, | |
| {"role": "user", "content": prompt}] | |
| r = _hf_client.chat.completions.create(model=VLM_MODEL, messages=messages, max_tokens=1024) | |
| out = (r.choices[0].message.content or "").strip() | |
| return out or prompt | |
| except Exception as e: | |
| print(f"[app] prompt upsampling failed, using raw prompt: {e!r}", flush=True) | |
| return prompt | |
| def _duration(state_json, *_): | |
| try: | |
| st = json.loads(state_json) if state_json else {} | |
| w, h = RATIOS.get(st.get("aspect", "1:1"), (1024, 1024)) | |
| ref = 12 if st.get("ref_image") else 0 # i2i adds a VAE encode | |
| except Exception: | |
| w, h, ref = 1024, 1024, 0 | |
| px = w * h | |
| # Distilled flow-map model: single forward per step (no CFG), so cheaper/step. | |
| return int(min(300, 40 + ref + DEFAULT_STEPS * 1.6 * max(1.0, px / (1024 * 1024)))) | |
| def _generate(state_json): | |
| try: | |
| st = json.loads(state_json) if state_json else {} | |
| except Exception as e: | |
| raise gr.Error(f"Bad canvas state: {e}") | |
| elements = st.get("elements") or [] | |
| scene = st.get("scene") or "" | |
| aspect = st.get("aspect") or "1:1" | |
| # `generate` may have pre-upsampled the prompt (off-GPU); use it if present. | |
| full_prompt = st.get("_final_prompt") or _build_prompt(scene, elements) | |
| if not full_prompt.strip(): | |
| raise gr.Error("Add at least a scene prompt or one described element.") | |
| seed = st.get("seed") | |
| seed = int(seed) if isinstance(seed, (int, float)) and seed >= 0 else random.randint(0, MAX_SEED) | |
| ref = _decode_ref(st.get("ref_image")) | |
| # Distilled flow-map model has no guider / guidance. | |
| kwargs = dict( | |
| prompt=full_prompt, | |
| num_inference_steps=DEFAULT_STEPS, | |
| generator=torch.Generator().manual_seed(seed), | |
| output="images", | |
| ) | |
| if ref is not None: | |
| # i2i: pass the reference; the model keeps its aspect (like the PR examples), | |
| # so don't force height/width. | |
| kwargs["image"] = ref | |
| w, h = ref.size | |
| else: | |
| w, h = RATIOS.get(aspect, (1024, 1024)) | |
| kwargs["height"], kwargs["width"] = h, w | |
| image = PIPE(**kwargs)[0] | |
| if torch.cuda.is_available(): | |
| torch.cuda.synchronize() | |
| w, h = image.size | |
| buf = io.BytesIO() | |
| image.save(buf, format="WEBP", quality=92) | |
| data_uri = "data:image/webp;base64," + base64.b64encode(buf.getvalue()).decode() | |
| print(f"[app] generate {w}x{h} steps={DEFAULT_STEPS} seed={seed} i2i={ref is not None} " | |
| f"elements={len(elements)} prompt_len={len(full_prompt)}", flush=True) | |
| return json.dumps({ | |
| "image": data_uri, | |
| "aspect": aspect, | |
| "w": w, "h": h, | |
| "seed": seed, | |
| "i2i": ref is not None, | |
| "elements": [{"id": e.get("id"), "bbox": e.get("bbox"), "color": e.get("color")} | |
| for e in elements if e.get("bbox")], | |
| "prompt": full_prompt, | |
| }) | |
| _generate_gpu = spaces.GPU(_generate, duration=_duration) if spaces is not None else _generate | |
| # ---- lazy example cache (gradio cache_examples="lazy" style) ----------------- | |
| # A deterministic request (fixed seed, no reference image) is cached to disk on | |
| # the first generation, so later identical requests - i.e. any user opening the | |
| # same example - are served from the cache with no GPU. Custom/edited generations | |
| # use a random seed (seed < 0) and are never cached. | |
| CACHE_DIR = os.environ.get( | |
| "F3_EXAMPLE_CACHE", os.path.join(os.path.dirname(os.path.abspath(__file__)), "example_cache")) | |
| os.makedirs(CACHE_DIR, exist_ok=True) | |
| _CACHE_MEM = {} | |
| def _has_boxes(st): | |
| return any((e.get("desc") or "").strip() for e in (st.get("elements") or [])) | |
| def _cache_key(st): | |
| if st.get("ref_image"): | |
| return None # i2i is not a fixed example | |
| if st.get("prompt_upsampling") and not _has_boxes(st): | |
| return None # VLM upsampling is non-deterministic -> don't cache | |
| seed = st.get("seed") | |
| if not (isinstance(seed, (int, float)) and seed >= 0): | |
| return None # random seed -> not a reproducible example | |
| full_prompt = _build_prompt(st.get("scene") or "", st.get("elements") or []) | |
| if not full_prompt.strip(): | |
| return None | |
| sig = f"{REPO}|{DEFAULT_STEPS}|{st.get('aspect') or '1:1'}|{int(seed)}|{full_prompt}" | |
| return hashlib.sha256(sig.encode("utf-8")).hexdigest()[:24] | |
| def _cache_get(key): | |
| if key in _CACHE_MEM: | |
| return _CACHE_MEM[key] | |
| fp = os.path.join(CACHE_DIR, key + ".json") | |
| if os.path.exists(fp): | |
| try: | |
| data = Path(fp).read_text() | |
| _CACHE_MEM[key] = data | |
| return data | |
| except Exception: | |
| return None | |
| return None | |
| def _cache_put(key, result): | |
| _CACHE_MEM[key] = result | |
| try: | |
| tmp = os.path.join(CACHE_DIR, key + ".json.tmp") | |
| Path(tmp).write_text(result) | |
| os.replace(tmp, os.path.join(CACHE_DIR, key + ".json")) | |
| except Exception as e: | |
| print(f"[app] example cache write failed: {e!r}", flush=True) | |
| def generate(state_json): | |
| try: | |
| st = json.loads(state_json) if state_json else {} | |
| except Exception: | |
| st = {} | |
| # Optional VLM prompt upsampling, done OFF-GPU before allocation. A prompt that | |
| # carries a box table (layout) is sent as-is, matching the klein space. | |
| if st.get("prompt_upsampling") and not _has_boxes(st): | |
| base = _build_prompt(st.get("scene") or "", st.get("elements") or []) | |
| if base.strip(): | |
| ref = _decode_ref(st.get("ref_image")) | |
| up = _upsample_prompt(base, [ref] if ref is not None else None) | |
| if up and up.strip(): | |
| st["_final_prompt"] = up | |
| state_json = json.dumps(st) | |
| print(f"[app] upsampled prompt -> {up[:90]!r}", flush=True) | |
| key = _cache_key(st) | |
| if key: | |
| cached = _cache_get(key) | |
| if cached is not None: | |
| print(f"[app] example cache HIT {key}", flush=True) | |
| return cached | |
| result = _generate_gpu(state_json) | |
| if key: | |
| _cache_put(key, result) | |
| print(f"[app] example cache STORE {key}", flush=True) | |
| return result | |
| # --------------------------------------------------------------------------- | |
| # Preset scenes (the top tabs), reconstructed from BFL's FLUX.3 layout examples. | |
| # bbox is [y0, x0, y1, x1] normalized 0-1000. | |
| # --------------------------------------------------------------------------- | |
| PRESETS = { | |
| "Le Festival du Soleil": { | |
| "aspect": "3:4", | |
| "scene": "A glowing concrete dome rises from a twilight bay, a crowd on the beach before it.", | |
| "elements": [ | |
| {"id": "Fr_Text_1", "bbox": [90, 230, 230, 780], | |
| "desc": "\"LE FESTIVAL DU SOLEIL\" written in a thin, elegant, serif typeface in a light cream color."}, | |
| {"id": "dome_1", "bbox": [250, 150, 650, 850], "desc": "a massive, smooth parabolic dome of pale concrete"}, | |
| {"id": "town_1", "bbox": [250, 640, 430, 900], "desc": "faint lights and small buildings at the foot of the hills"}, | |
| {"id": "swimmers_1", "bbox": [580, 200, 720, 800], | |
| "desc": "dozens of small, silhouetted figures scattered in the dark water, wading"}, | |
| {"id": "crowd_1", "bbox": [740, 0, 1000, 1000], | |
| "desc": "a large crowd of people seated on the beach in casual, light-colored summer attire"}, | |
| ], | |
| }, | |
| "Five frames of winter": { | |
| "aspect": "16:9", | |
| "scene": "Five vertical frames of a quiet winter day, left to right from dawn to dusk.", | |
| "elements": [ | |
| {"id": "frame_1", "bbox": [0, 0, 1000, 190], "desc": "a bare birch forest at pale blue dawn, frost on every branch"}, | |
| {"id": "frame_2", "bbox": [0, 200, 1000, 390], "desc": "a frozen lake under flat grey light, a lone figure skating"}, | |
| {"id": "frame_3", "bbox": [0, 400, 1000, 590], "desc": "heavy snowfall over a red wooden cabin with a lit window"}, | |
| {"id": "frame_4", "bbox": [0, 600, 1000, 790], "desc": "a snowy pine ridge at golden hour, long blue shadows"}, | |
| {"id": "frame_5", "bbox": [0, 800, 1000, 1000], "desc": "the same valley at dusk, deep indigo sky and the first stars"}, | |
| ], | |
| }, | |
| "Knitted iceberg village": { | |
| "aspect": "4:3", | |
| "scene": "A knitted diorama: three cable-knit icebergs with tiny houses, a banner and a magenta whale tail.", | |
| "elements": [ | |
| {"id": "En_Text_1", "bbox": [130, 220, 220, 780], | |
| "desc": "The exact words \"KNITTED ICEBERG VILLAGE\" embroidered in thick, raised navy-blue yarn across a banner."}, | |
| {"id": "iceberg_3", "bbox": [200, 300, 650, 700], | |
| "desc": "The largest central iceberg, a bulky mound of white and pale grey ribbed knitting, dominating the midground with thick, vertical columns of stitches."}, | |
| {"id": "iceberg_1", "bbox": [230, 60, 650, 320], | |
| "desc": "A conical soft sculpture made of stark white, chunky roving yarn in a thick cable-knit pattern. The left side is brightly lit, while the right side falls into deep, fuzzy shadow."}, | |
| {"id": "iceberg_2", "bbox": [230, 680, 640, 960], | |
| "desc": "A tall, asymmetrical peak of white cable-knit yarn, featuring a few visibly dropped stitches near the base and slightly uneven tension in the cables."}, | |
| {"id": "whale_tail_1", "bbox": [500, 380, 850, 620], | |
| "desc": "A giant whale tail knitted from bright magenta and coral-pink yarn, bursting vertically from the teal sea."}, | |
| ], | |
| }, | |
| "Up from the subway": { | |
| "aspect": "3:2", | |
| "scene": "Looking up a subway staircase at dusk, a blurred figure climbing toward the skyline.", | |
| "elements": [ | |
| {"id": "building_1", "bbox": [50, 120, 630, 470], | |
| "desc": "a modern, red-brick high-rise skyscraper stretching into the sky"}, | |
| {"id": "building_2", "bbox": [0, 450, 650, 850], | |
| "desc": "a tall, classical white stone skyscraper with a tiered, set-back top"}, | |
| {"id": "person_1", "bbox": [300, 380, 820, 640], | |
| "desc": "a heavily blurred, dark shape of a person seen from behind"}, | |
| {"id": "stairs_1", "bbox": [600, 200, 1000, 800], | |
| "desc": "a flight of concrete subway stairs filling the bottom center of the frame"}, | |
| {"id": "En_Text_1", "bbox": [300, 760, 640, 1000], | |
| "desc": "a partially visible, glowing green and white sign on the building"}, | |
| ], | |
| }, | |
| } | |
| # A fixed seed per example, so its lazily-cached render is deterministic and the | |
| # cache key is stable across restarts. Order is stable -> seeds are stable. | |
| for _i, _name in enumerate(PRESETS): | |
| PRESETS[_name].setdefault("seed", 1000 + _i) | |
| RATIO_LIST = list(RATIOS.keys()) | |
| CONFIG_JS = ( | |
| "<script>window.F3_PRESETS = " + json.dumps(PRESETS) + ";" | |
| "window.F3_RATIOS = " + json.dumps(RATIO_LIST) + ";" | |
| "window.F3_LINKS = " + json.dumps({"distilled": LINK_DISTILLED, "base": LINK_BASE, | |
| "code": LINK_CODE, "boxes": LINK_BOXES}) + ";" | |
| "window.F3_PRESET_ORDER = " + json.dumps(list(PRESETS.keys())) + ";</script>" | |
| ) | |
| # Styles + behavior live in <head>; the component mounts into #f3-root (gr.HTML). | |
| STYLE = """ | |
| <style> | |
| #f3-root, #f3-root * { box-sizing: border-box; } | |
| #f3-root { | |
| --bg:#09090b; --panel:#101013; --panel2:#0c0c0f; --line:#26262b; --line2:#1b1b1f; | |
| --txt:#e7e7ea; --dim:#8a8a93; --dim2:#5f5f68; --violet:#8b7cf6; --violetbg:rgba(139,124,246,.14); | |
| --green:#3fd19b; --mono:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; | |
| font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,Roboto,sans-serif; | |
| color:var(--txt); width:100%; | |
| } | |
| #f3-root .tabs{display:flex;gap:4px;background:var(--panel);border:1px solid var(--line2); | |
| border-radius:12px;padding:5px;width:max-content;max-width:100%;overflow:auto;margin-bottom:14px;} | |
| #f3-root .tab{padding:9px 16px;border-radius:8px;color:var(--dim);font-size:14px;cursor:pointer; | |
| white-space:nowrap;transition:.15s;} | |
| #f3-root .tab:hover{color:var(--txt);} | |
| #f3-root .tab.active{background:#222228;color:var(--txt);} | |
| #f3-root .main{display:grid;grid-template-columns:1fr 390px;gap:16px;align-items:start;} | |
| @media (max-width:980px){#f3-root .main{grid-template-columns:1fr;}} | |
| #f3-root .canvas-wrap{background:var(--panel2);border:1px solid var(--line2);border-radius:14px; | |
| padding:22px;position:relative;min-height:560px; | |
| background-image:radial-gradient(var(--line) 1px,transparent 1px);background-size:17px 17px;} | |
| #f3-root .board{position:relative;margin:0 auto;background-color:#0b0b0e;border:1px solid #34343c; | |
| box-shadow:0 0 0 1px #000 inset; user-select:none; touch-action:none;} | |
| #f3-root .board.empty{background-color:#0b0b0e;} | |
| #f3-root .board img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;border-radius:1px;} | |
| #f3-root .ratio-tag{position:absolute;left:4px;bottom:-24px;color:var(--dim);font:12px/1 var(--mono);} | |
| #f3-root .board-tools{position:absolute;right:22px;bottom:22px;display:flex;gap:8px;align-items:center;z-index:30;} | |
| #f3-root .btn{display:inline-flex;align-items:center;gap:7px;background:var(--panel);border:1px solid var(--line); | |
| color:var(--dim);border-radius:9px;padding:8px 13px;font-size:13px;cursor:pointer;transition:.15s;} | |
| #f3-root .btn:hover{color:var(--txt);border-color:#3a3a42;} | |
| #f3-root .btn.on{color:var(--txt);} | |
| #f3-root .toggle{width:34px;height:19px;border-radius:12px;background:#2a2a30;position:relative;transition:.15s;flex:none;} | |
| #f3-root .toggle::after{content:'';position:absolute;top:2px;left:2px;width:15px;height:15px;border-radius:50%; | |
| background:#777;transition:.15s;} | |
| #f3-root .btn.on .toggle{background:var(--violet);} | |
| #f3-root .btn.on .toggle::after{left:17px;background:#fff;} | |
| #f3-root .box{position:absolute;border:1.5px solid var(--violet);background:var(--violetbg);cursor:move;} | |
| #f3-root .box.sel{box-shadow:0 0 0 1px var(--violet);} | |
| #f3-root .box .tag{position:absolute;left:-1px;top:-22px;display:flex;gap:7px;align-items:center; | |
| background:var(--violet);color:#0a0a0c;font:600 11px/1.4 var(--mono);padding:3px 7px;border-radius:4px; | |
| white-space:nowrap;max-width:420px;} | |
| #f3-root .box .tag .id{outline:none;} | |
| #f3-root .box .tag .coords{color:rgba(10,10,12,.62);font-weight:500;} | |
| #f3-root .box .desc{position:absolute;inset:7px;overflow:auto;color:var(--txt);font-size:12.5px;line-height:1.35; | |
| outline:none;background:transparent;} | |
| #f3-root .box .desc:empty::before{content:'describe this elementβ¦';color:var(--dim2);} | |
| #f3-root .box .rm{position:absolute;right:-9px;top:-9px;width:18px;height:18px;border-radius:50%;background:#222; | |
| border:1px solid var(--line);color:var(--dim);font-size:12px;line-height:16px;text-align:center;cursor:pointer; | |
| display:none;} | |
| #f3-root .box:hover .rm{display:block;} | |
| #f3-root .box .h{position:absolute;width:10px;height:10px;background:#0b0b0e;border:1.5px solid var(--violet);border-radius:2px;} | |
| #f3-root .box .h.se{right:-6px;bottom:-6px;cursor:se-resize;} | |
| #f3-root .box .h.ne{right:-6px;top:-6px;cursor:ne-resize;} | |
| #f3-root .box .h.sw{left:-6px;bottom:-6px;cursor:sw-resize;} | |
| #f3-root .box .h.nw{left:-6px;top:-6px;cursor:nw-resize;} | |
| #f3-root .board.rendered .box{background:transparent;} | |
| #f3-root .board.rendered .box .desc{display:none;} | |
| #f3-root .board.hideboxes .box{display:none;} | |
| #f3-root .board .hint{position:absolute;inset:0;display:flex;align-items:center;justify-content:center; | |
| color:var(--dim2);font-size:13px;text-align:center;padding:30px;pointer-events:none;} | |
| #f3-root .board .spin{position:absolute;inset:0;display:none;flex-direction:column;gap:15px; | |
| align-items:center;justify-content:center;background:rgba(7,7,10,.86);backdrop-filter:blur(4px); | |
| -webkit-backdrop-filter:blur(4px);color:var(--txt);z-index:40;} | |
| #f3-root .board.busy .spin{display:flex;} | |
| #f3-root .board .spin .spinner{width:38px;height:38px;border-radius:50%; | |
| border:3px solid rgba(255,255,255,.16);border-top-color:var(--violet);animation:f3spin .8s linear infinite;} | |
| #f3-root .board .spin .spinlabel{font-size:15.5px;font-weight:650;letter-spacing:.01em; | |
| animation:f3pulse 1.3s ease-in-out infinite;} | |
| @keyframes f3spin{to{transform:rotate(360deg);}} | |
| @keyframes f3pulse{0%,100%{opacity:.7;}50%{opacity:1;}} | |
| #f3-root .side{background:var(--panel);border:1px solid var(--line2);border-radius:14px;overflow:hidden;} | |
| #f3-root .step{padding:15px 18px;border-bottom:1px solid var(--line2);cursor:pointer;} | |
| #f3-root .step:last-child{border-bottom:none;} | |
| #f3-root .step .row{display:flex;gap:14px;align-items:center;} | |
| #f3-root .step .n{font:12px/1 var(--mono);color:var(--dim2);} | |
| #f3-root .step .t{font-size:15px;color:var(--dim);} | |
| #f3-root .step.active{background:#17171b;} | |
| #f3-root .step.active .t{color:var(--txt);} | |
| #f3-root .step .hint{margin:9px 0 0 26px;color:var(--dim);font-size:13px;line-height:1.45;display:none;} | |
| #f3-root .step.active .hint{display:block;} | |
| #f3-root .summary{border-top:1px solid var(--line2);padding:16px 18px;max-height:230px;overflow:auto;} | |
| #f3-root .summary .e{margin-bottom:13px;} | |
| #f3-root .summary .e .h{font:600 12.5px/1.5 var(--mono);color:var(--violet);} | |
| #f3-root .summary .e .h .c{color:var(--dim2);font-weight:500;} | |
| #f3-root .summary .e .d{color:var(--dim);font-size:13px;line-height:1.4;} | |
| #f3-root .summary .empty{color:var(--dim2);font-size:13px;} | |
| #f3-root .scene{border-top:1px solid var(--line2);padding:16px 18px;} | |
| #f3-root .scene label{display:block;font:11px/1 var(--mono);letter-spacing:.08em;color:var(--dim2);margin-bottom:9px;} | |
| #f3-root .scene textarea{width:100%;background:transparent;border:none;color:var(--txt);font-size:15.5px; | |
| line-height:1.4;resize:none;outline:none;min-height:46px;font-family:inherit;} | |
| #f3-root .scene textarea::placeholder{color:var(--dim2);} | |
| #f3-root .ratios{display:flex;flex-wrap:wrap;gap:7px;margin:11px 0 2px 26px;} | |
| #f3-root .chip{padding:6px 11px;border:1px solid var(--line);border-radius:7px;color:var(--dim); | |
| font:12px/1 var(--mono);cursor:pointer;} | |
| #f3-root .chip.sel{border-color:var(--violet);color:var(--txt);background:var(--violetbg);} | |
| #f3-root .gen{margin:12px 18px 18px;} | |
| #f3-root .genbtn{width:100%;background:var(--violet);color:#0a0a0c;border:none;border-radius:10px; | |
| padding:13px;font-size:14.5px;font-weight:600;cursor:pointer;transition:.15s;} | |
| #f3-root .genbtn:hover{filter:brightness(1.08);} | |
| #f3-root .genbtn[disabled]{opacity:.55;cursor:default;} | |
| #f3-root .table-bar{margin-top:16px;background:var(--panel);border:1px solid var(--line2);border-radius:12px; | |
| padding:16px 18px;cursor:pointer;display:flex;justify-content:space-between;align-items:center;} | |
| #f3-root .table-bar .lab{font:12px/1 var(--mono);letter-spacing:.06em;color:var(--dim);} | |
| #f3-root .table-bar .cnt{font:12px/1 var(--mono);color:var(--dim2);} | |
| #f3-root .etable{margin-top:8px;background:var(--panel);border:1px solid var(--line2);border-radius:12px; | |
| overflow:auto;display:none;} | |
| #f3-root .etable.open{display:block;} | |
| #f3-root table{width:100%;border-collapse:collapse;font-size:13px;} | |
| #f3-root th,#f3-root td{text-align:left;padding:10px 14px;border-bottom:1px solid var(--line2);vertical-align:top;} | |
| #f3-root th{font:11px/1 var(--mono);letter-spacing:.06em;color:var(--dim2);text-transform:uppercase;} | |
| #f3-root td.id{font:600 12.5px/1.4 var(--mono);color:var(--violet);white-space:nowrap;} | |
| #f3-root td.bb{font:12px/1.4 var(--mono);color:var(--dim);white-space:nowrap;} | |
| #f3-root td.d{color:var(--txt);} | |
| </style> | |
| """ | |
| # Selection accent (green) + small extras, appended to the head styles. | |
| STYLE2 = """ | |
| <style> | |
| #f3-root .box.sel{border-color:var(--green);} | |
| #f3-root .box.sel .tag{background:var(--green);} | |
| #f3-root .box.sel .tag .coords{color:rgba(10,12,11,.6);} | |
| #f3-root .box.sel .h{border-color:var(--green);} | |
| /* title */ | |
| #f3-root .titlebar{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap;margin:0 0 16px;} | |
| #f3-root .titlebar .tb-text{min-width:0;} | |
| #f3-root .titlebar h1{margin:0 0 4px;font-size:22px;font-weight:650;color:var(--txt);letter-spacing:-.01em;} | |
| #f3-root .titlebar p{margin:0;color:var(--dim);font-size:13.5px;max-width:760px;line-height:1.5;} | |
| /* klein-style button links */ | |
| #f3-root .f3-links{display:flex;gap:8px;flex-wrap:wrap;} | |
| #f3-root .f3-links a,#f3-root .f3-links a:visited{text-decoration:none;font-size:12.5px;padding:6px 12px;border-radius:8px; | |
| border:1px solid var(--line2);color:var(--txt);background:var(--panel);white-space:nowrap;transition:border-color .12s,color .12s;} | |
| #f3-root .f3-links a:hover{border-color:var(--violet);color:var(--violet);} | |
| /* prompt-upsampling toggle (under the prompt in the input bar) */ | |
| #f3-root .ib-up{margin-top:9px;} | |
| #f3-root .ib-up .btn{font-size:12px;color:var(--dim);} | |
| #f3-root .ib-up .btn.on{color:var(--txt);} | |
| #f3-root .ib-up .btn .hintq{color:var(--dim2);font-size:11px;} | |
| /* reference image block (i2i) */ | |
| #f3-root .refblock{padding:14px 18px;border-bottom:1px solid var(--line2);} | |
| #f3-root .refblock>label{display:block;font:600 11px/1.4 var(--mono);letter-spacing:.06em;color:var(--dim2);margin-bottom:9px;} | |
| #f3-root .refblock .opt{color:var(--dim);font-weight:500;letter-spacing:0;} | |
| #f3-root .refbox{display:flex;gap:10px;align-items:center;} | |
| #f3-root .refthumb{width:52px;height:52px;flex:none;border-radius:8px;border:1px dashed #3a3a42;background:#0b0b0e center/cover no-repeat;} | |
| #f3-root .refbox.has .refthumb{border-style:solid;border-color:var(--violet);} | |
| #f3-root .refcta{display:flex;flex-direction:column;gap:6px;} | |
| #f3-root .refbtn,#f3-root .refclear{text-align:left;background:var(--panel2);border:1px solid var(--line);color:var(--dim);border-radius:7px;padding:6px 10px;font-size:12.5px;cursor:pointer;} | |
| #f3-root .refbtn:hover,#f3-root .refclear:hover{color:var(--txt);border-color:#3a3a42;} | |
| #f3-root .refclear{display:none;} | |
| #f3-root .refbox.has .refclear{display:block;} | |
| /* board shows the reference while editing β contain (not cover) so the WHOLE image | |
| is resized to fit the canvas, never cropped. */ | |
| #f3-root .board{background-size:contain;background-position:center;background-repeat:no-repeat;} | |
| /* left column stacks the board + the examples row directly under it; | |
| the gap clears the ratio tag that sits just below the board. */ | |
| #f3-root .leftcol{display:flex;flex-direction:column;gap:40px;min-width:0;} | |
| /* examples row (directly below the canvas), a clean 2x2 of the presets */ | |
| #f3-root .examples{margin-top:0;} | |
| #f3-root .ex-head{font:600 11px/1.4 var(--mono);letter-spacing:.06em;color:var(--dim2);margin-bottom:10px;} | |
| #f3-root .ex-row{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;} | |
| @media (max-width:560px){#f3-root .ex-row{grid-template-columns:1fr;}} | |
| #f3-root .excard{background:var(--panel);border:1px solid var(--line2);border-radius:10px;padding:12px 14px;cursor:pointer;transition:border-color .12s,background .12s;} | |
| #f3-root .excard:hover{border-color:#3a3a42;background:#17171b;} | |
| #f3-root .excard.active{border-color:var(--violet);} | |
| #f3-root .exname{color:var(--txt);font-size:13.5px;font-weight:550;margin-bottom:3px;} | |
| #f3-root .exmeta{color:var(--dim);font:11px/1.3 var(--mono);} | |
| /* ---- INPUT BAR (above the canvas): reference image + prompt + generate ---- | |
| Deliberately a different colour/material from the dark canvas so the "what do | |
| I type / drop here" controls read as a separate, obvious zone. */ | |
| #f3-root .inputbar{display:flex;gap:18px;align-items:stretch; | |
| background:linear-gradient(180deg,#1c1c28,#15151f); | |
| border:1px solid #33334e;border-left:3px solid var(--violet); | |
| border-radius:14px;padding:15px 17px;margin:0 0 20px;} | |
| #f3-root .ib-lab{display:block;font:600 11px/1 var(--mono);letter-spacing:.08em;color:#b9b2e6;margin-bottom:9px;} | |
| #f3-root .ib-lab .opt{color:var(--dim2);font-weight:500;letter-spacing:0;} | |
| #f3-root .ib-ref{flex:none;} | |
| #f3-root .ib-prompt{flex:1;min-width:0;display:flex;flex-direction:column;} | |
| #f3-root .ib-prompt textarea{width:100%;flex:1;background:#0e0e15;border:1px solid #30304a;border-radius:9px; | |
| color:var(--txt);font-size:15px;line-height:1.4;resize:none;outline:none;min-height:56px;padding:10px 12px; | |
| font-family:inherit;transition:border-color .12s;} | |
| #f3-root .ib-prompt textarea:focus{border-color:var(--violet);} | |
| #f3-root .ib-prompt textarea::placeholder{color:var(--dim2);} | |
| #f3-root .ib-gen{display:flex;flex-direction:column;justify-content:flex-end;gap:8px;flex:none;} | |
| #f3-root .ib-gen .genbtn{width:auto;padding:0 26px;height:52px;white-space:nowrap;} | |
| #f3-root .ib-gen .clearBtn{width:auto;padding:9px 26px;background:transparent;border:1px solid var(--line); | |
| color:var(--dim);border-radius:10px;font-size:13px;font-weight:500;cursor:pointer;white-space:nowrap;transition:.15s;} | |
| #f3-root .ib-gen .clearBtn:hover{color:var(--txt);border-color:#3a3a42;} | |
| @media (max-width:760px){#f3-root .inputbar{flex-direction:column;align-items:stretch;} | |
| #f3-root .ib-gen .genbtn{width:100%;}} | |
| </style> | |
| """ | |
| SCRIPT = r""" | |
| <script> | |
| (function(){ | |
| const NS = window.F3 = window.F3 || {}; | |
| const RATIOS = {"1:1":[1024,1024],"3:2":[1216,816],"2:3":[816,1216],"4:3":[1152,864], | |
| "3:4":[864,1152],"16:9":[1344,768],"9:16":[768,1344]}; | |
| const STEP_HINTS = [ | |
| ["Aspect ratio","Pick the frame your scene lives in."], | |
| ["Elements","Drag out a box for every element that matters, then describe what goes in it."], | |
| ["Prompt","One line describing the whole scene β or the edit, if you added a reference image."], | |
| ]; | |
| let S = {aspect:"1:1", elements:[], scene:"", seed:-1, rendered:false, step:1, showBoxes:true, | |
| refImage:null, refW:0, refH:0, upsample:false}; | |
| let seq = 0, sel = null; | |
| function clamp(v,a,b){return Math.max(a,Math.min(b,v));} | |
| function uid(base){ base=(base||"element").replace(/[^A-Za-z0-9_]+/g,"_").replace(/^_+|_+$/g,"")||"element"; | |
| let id=base, n=1; const ids=new Set(S.elements.map(e=>e.id)); | |
| while(ids.has(id)){ id=base+"_"+(++n);} return id; } | |
| // gradio may put elem_id on the field/button itself OR on a wrapper that contains it. | |
| function fieldEl(id){ | |
| const w=document.getElementById(id); if(!w) return null; | |
| return w.matches('textarea,input') ? w : w.querySelector('textarea,input'); | |
| } | |
| function genButton(){ | |
| const g=document.getElementById('f3-gen'); if(!g) return null; | |
| return g.tagName==='BUTTON' ? g : g.querySelector('button'); | |
| } | |
| function setHidden(id,val){ | |
| const el=fieldEl(id); if(!el) return; | |
| const proto = el.tagName==='TEXTAREA'?window.HTMLTextAreaElement.prototype:window.HTMLInputElement.prototype; | |
| const setter=Object.getOwnPropertyDescriptor(proto,'value').set; | |
| setter.call(el,val); el.dispatchEvent(new Event('input',{bubbles:true})); | |
| } | |
| function syncState(){ | |
| setHidden('f3-state', JSON.stringify({ | |
| aspect:S.aspect, scene:S.scene, seed:S.seed, ref_image:S.refImage, prompt_upsampling:S.upsample, | |
| elements:S.elements.map(e=>({id:e.id,bbox:e.bbox,desc:e.desc,color:e.color})), | |
| })); | |
| } | |
| // ---------- board geometry ---------- | |
| // With a reference image the board (and output) follow the image's aspect; otherwise | |
| // the chosen ratio chip. | |
| function aspectWH(){ return S.refImage ? [S.refW||1, S.refH||1] : RATIOS[S.aspect]; } | |
| function boardSize(){ | |
| const wrap=NS.el.wrap; const availW=wrap.clientWidth-44; const maxH=600; | |
| const [W,H]=aspectWH(); let w=availW, h=w*H/W; | |
| if(h>maxH){ h=maxH; w=h*W/H; } if(w>availW){ w=availW; h=w*H/W; } | |
| return [Math.round(w),Math.round(h)]; | |
| } | |
| function layoutBoard(){ | |
| const [w,h]=boardSize(); const b=NS.el.board; b.style.width=w+'px'; b.style.height=h+'px'; | |
| NS.el.ratioTag.textContent = S.refImage ? ('ref '+S.refW+'Γ'+S.refH) : S.aspect; | |
| b.style.backgroundImage = (S.refImage && !S.rendered) ? ('url('+S.refImage+')') : ''; | |
| } | |
| // ---------- rendering ---------- | |
| function boxEl(e){ | |
| const d=document.createElement('div'); d.className='box'; d.dataset.id=e.id; | |
| d.innerHTML='<div class="tag"><span class="id" contenteditable="true" spellcheck="false"></span>' | |
| +'<span class="coords"></span></div>' | |
| +'<div class="desc" contenteditable="true" spellcheck="false"></div>' | |
| +'<div class="rm">Γ</div>' | |
| +'<div class="h nw"></div><div class="h ne"></div><div class="h sw"></div><div class="h se"></div>'; | |
| d.querySelector('.id').textContent=e.id; | |
| d.querySelector('.desc').textContent=e.desc||''; | |
| wireBox(d,e); return d; | |
| } | |
| function posBox(d,e){ | |
| const [y0,x0,y1,x1]=e.bbox; | |
| d.style.left=(x0/10)+'%'; d.style.top=(y0/10)+'%'; | |
| d.style.width=((x1-x0)/10)+'%'; d.style.height=((y1-y0)/10)+'%'; | |
| d.querySelector('.coords').textContent='['+[y0,x0,y1,x1].map(Math.round).join(', ')+']'; | |
| } | |
| function renderBoxes(){ | |
| const b=NS.el.board; | |
| b.querySelectorAll('.box').forEach(n=>n.remove()); | |
| S.elements.forEach(e=>{ const d=boxEl(e); posBox(d,e); if(e===sel)d.classList.add('sel'); b.appendChild(d); }); | |
| } | |
| function renderSummary(){ | |
| const s=NS.el.summary; | |
| if(!S.elements.length){ s.innerHTML='<div class="empty">No elements yet β drag a box on the canvas.</div>'; return; } | |
| s.innerHTML=S.elements.map(e=>'<div class="e"><div class="h">'+esc(e.id) | |
| +' <span class="c">['+e.bbox.map(Math.round).join(', ')+']</span></div>' | |
| +'<div class="d">'+esc(e.desc||'')+'</div></div>').join(''); | |
| } | |
| function renderTable(){ | |
| NS.el.cnt.textContent=S.elements.length+' ROW'+(S.elements.length===1?'':'S'); | |
| const body=S.elements.map(e=>'<tr><td class="id">'+esc(e.id)+'</td><td class="bb">[' | |
| +e.bbox.map(Math.round).join(', ')+']</td><td class="d">'+esc(e.desc||'')+'</td></tr>').join(''); | |
| NS.el.etable.innerHTML='<table><thead><tr><th>Element</th><th>bbox [y0,x0,y1,x1]</th><th>Description</th></tr></thead><tbody>' | |
| +(body||'<tr><td colspan="3" class="d" style="color:var(--dim2)">empty</td></tr>')+'</tbody></table>'; | |
| } | |
| function renderSteps(){ | |
| // i2i (a reference is loaded) takes the input image's aspect ratio, so the | |
| // aspect-ratio step/chips only make sense for t2i β hide them when editing. | |
| const i2i = !!S.refImage; | |
| if(NS.el.steps[0]) NS.el.steps[0].style.display = i2i ? 'none' : ''; | |
| NS.el.steps.forEach((st,i)=>st.classList.toggle('active', (i+1)===S.step)); | |
| NS.el.ratios.style.display = (!i2i && S.step===1)?'flex':'none'; | |
| } | |
| function renderAll(){ layoutBoard(); renderBoxes(); renderSummary(); renderTable(); renderSteps(); | |
| NS.el.ratioChips.forEach(c=>c.classList.toggle('sel', c.dataset.r===S.aspect)); | |
| const hint=NS.el.board.querySelector('.hint'); if(hint) hint.style.display=(!S.rendered && !S.refImage && S.elements.length===0)?'flex':'none'; | |
| syncState(); } | |
| function esc(s){return (s||'').replace(/[&<>]/g,m=>({'&':'&','<':'<','>':'>'}[m]));} | |
| function select(e){ sel=e; NS.el.board.querySelectorAll('.box').forEach(d=> | |
| d.classList.toggle('sel', d.dataset.id===(e&&e.id))); } | |
| // Editing the layout after a render drops the stale output image and returns the | |
| // board to edit mode, so the boxes are movable/addable and a re-generate is fresh. | |
| function invalidate(){ | |
| if(!S.rendered) return; | |
| S.rendered=false; S.seed=-1; | |
| const img=NS.el.board.querySelector('img'); if(img)img.remove(); | |
| NS.el.board.classList.remove('rendered','hideboxes'); | |
| if(NS.el.toggleBtn) NS.el.toggleBtn.style.display='none'; | |
| if(NS.el.reuseBtn) NS.el.reuseBtn.style.display='none'; | |
| } | |
| // ---------- box interactions ---------- | |
| function px2norm(ev){ | |
| const r=NS.el.board.getBoundingClientRect(); | |
| return [clamp((ev.clientY-r.top)/r.height*1000,0,1000), clamp((ev.clientX-r.left)/r.width*1000,0,1000)]; | |
| } | |
| function wireBox(d,e){ | |
| const idEl=d.querySelector('.id'), descEl=d.querySelector('.desc'); | |
| d.addEventListener('mousedown',ev=>{ | |
| if(ev.target.classList.contains('h')||ev.target.classList.contains('rm')||ev.target.classList.contains('id')) { select(e); return; } | |
| if(ev.target.classList.contains('desc') && document.activeElement===ev.target) return; // editing: allow caret | |
| select(e); ev.preventDefault(); | |
| const sx=ev.clientX, sy=ev.clientY; let moved=false; const isDesc=ev.target.classList.contains('desc'); | |
| const b=e.bbox.slice(); const p=px2norm(ev); const oy=p[0]-b[0], ox=p[1]-b[1]; | |
| const w=b[3]-b[1], h=b[2]-b[0]; | |
| function mv(ev2){ if(!moved && Math.abs(ev2.clientX-sx)+Math.abs(ev2.clientY-sy)>3) moved=true; | |
| if(moved){ const q=px2norm(ev2); let x0=clamp(q[1]-ox,0,1000-w), y0=clamp(q[0]-oy,0,1000-h); | |
| e.bbox=[y0,x0,y0+h,x0+w]; posBox(d,e); liveMeta(e); } } | |
| function up(){ document.removeEventListener('mousemove',mv); document.removeEventListener('mouseup',up); | |
| if(moved){ invalidate(); renderAll(); } else if(isDesc) descEl.focus(); } | |
| document.addEventListener('mousemove',mv); document.addEventListener('mouseup',up); | |
| }); | |
| d.querySelectorAll('.h').forEach(h=>h.addEventListener('mousedown',ev=>{ | |
| ev.preventDefault(); ev.stopPropagation(); select(e); | |
| startResize(ev,e,d,[...h.classList].find(c=>['nw','ne','sw','se'].includes(c))); | |
| })); | |
| d.querySelector('.rm').addEventListener('click',ev=>{ ev.stopPropagation(); | |
| S.elements=S.elements.filter(x=>x!==e); if(sel===e)sel=null; invalidate(); renderAll(); }); | |
| idEl.addEventListener('blur',()=>{ let v=idEl.textContent.trim().replace(/[^A-Za-z0-9_]+/g,'_'); | |
| if(!v){v=e.id;} if(v!==e.id){ const ids=new Set(S.elements.filter(x=>x!==e).map(x=>x.id)); | |
| let f=v,n=1; while(ids.has(f)){f=v+'_'+(++n);} e.id=f;} idEl.textContent=e.id; renderAll(); }); | |
| idEl.addEventListener('keydown',ev=>{ if(ev.key==='Enter'){ev.preventDefault();idEl.blur();} }); | |
| descEl.addEventListener('input',()=>{ e.desc=descEl.textContent; invalidate(); renderSummaryTable(); syncState(); }); | |
| descEl.addEventListener('focus',()=>select(e)); | |
| } | |
| function renderSummaryTable(){ renderSummary(); renderTable(); } | |
| function startMove(ev,e,d){ | |
| const [y,x]=px2norm(ev); const b=e.bbox.slice(); const oy=y-b[0], ox=x-b[1]; | |
| const w=b[3]-b[1], h=b[2]-b[0]; | |
| function mv(ev2){ const [ny,nx]=px2norm(ev2); let x0=clamp(nx-ox,0,1000-w), y0=clamp(ny-oy,0,1000-h); | |
| e.bbox=[y0,x0,y0+h,x0+w]; posBox(d,e); liveMeta(e); } | |
| function up(){ document.removeEventListener('mousemove',mv); document.removeEventListener('mouseup',up); invalidate(); renderAll(); } | |
| document.addEventListener('mousemove',mv); document.addEventListener('mouseup',up); | |
| } | |
| function startResize(ev,e,d,corner){ | |
| function mv(ev2){ const [ny,nx]=px2norm(ev2); let [y0,x0,y1,x1]=e.bbox; | |
| if(corner.includes('n'))y0=ny; if(corner.includes('s'))y1=ny; | |
| if(corner.includes('w'))x0=nx; if(corner.includes('e'))x1=nx; | |
| e.bbox=[Math.min(y0,y1),Math.min(x0,x1),Math.max(y0,y1),Math.max(x0,x1)]; posBox(d,e); liveMeta(e); } | |
| function up(){ document.removeEventListener('mousemove',mv); document.removeEventListener('mouseup',up); invalidate(); renderAll(); } | |
| document.addEventListener('mousemove',mv); document.addEventListener('mouseup',up); | |
| } | |
| function liveMeta(e){ renderSummary(); renderTable(); } | |
| function startDraw(ev){ | |
| // Draw on the empty board OR on top of a finished render (the result <img> | |
| // covers the board). Drawing over a render clears the stale output so you can | |
| // add more boxes to the layout and re-generate. | |
| const onBoard = (ev.target===NS.el.board) || (S.rendered && ev.target.tagName==='IMG'); | |
| if(!onBoard) return; | |
| invalidate(); | |
| ev.preventDefault(); | |
| const [sy,sx]=px2norm(ev); | |
| const e={id:uid('element'), color:'violet', bbox:[sy,sx,sy,sx], desc:''}; | |
| const d=boxEl(e); NS.el.board.appendChild(d); | |
| function mv(ev2){ const [ny,nx]=px2norm(ev2); | |
| e.bbox=[Math.min(sy,ny),Math.min(sx,nx),Math.max(sy,ny),Math.max(sx,nx)]; posBox(d,e); } | |
| function up(){ document.removeEventListener('mousemove',mv); document.removeEventListener('mouseup',up); | |
| if((e.bbox[2]-e.bbox[0])<30||(e.bbox[3]-e.bbox[1])<30){ d.remove(); return; } | |
| S.elements.push(e); select(e); S.step=2; renderAll(); | |
| const de=NS.el.board.querySelector('.box[data-id="'+e.id+'"] .desc'); if(de)de.focus(); | |
| } | |
| document.addEventListener('mousemove',mv); document.addEventListener('mouseup',up); | |
| } | |
| // ---------- reference image (i2i) ---------- | |
| function clearRef(){ S.refImage=null; S.refW=0; S.refH=0; S.step=1; // back to t2i: aspect step returns | |
| if(NS.el.refThumb){ NS.el.refThumb.style.backgroundImage=''; } | |
| if(NS.el.refBox) NS.el.refBox.classList.remove('has'); | |
| } | |
| function setRef(uri,w,h){ | |
| // Uploading a reference starts a clean edit: drop any preset scene/boxes so the | |
| // user draws fresh regions on their own image. | |
| S.refImage=uri; S.refW=w; S.refH=h; S.rendered=false; sel=null; | |
| S.elements=[]; S.scene=""; S.step=2; | |
| if(NS.el.scene) NS.el.scene.value=""; | |
| (NS.el.exCards||[]).forEach(t=>t.classList.remove('active')); | |
| const img=NS.el.board.querySelector('img'); if(img)img.remove(); | |
| NS.el.board.classList.remove('rendered','hideboxes'); NS.el.toggleBtn.style.display='none'; | |
| if(NS.el.refThumb) NS.el.refThumb.style.backgroundImage='url('+uri+')'; | |
| if(NS.el.refBox) NS.el.refBox.classList.add('has'); | |
| renderAll(); | |
| } | |
| // downscale so the data URI stays light for the state bridge (long side <= 1280), | |
| // and bake the real (orientation-corrected) pixels so the board aspect matches. | |
| function emitRef(srcW,srcH,drawable){ | |
| let w=srcW, h=srcH; const s=Math.min(1,1280/Math.max(w,h)); | |
| w=Math.max(16,Math.round(w*s)); h=Math.max(16,Math.round(h*s)); | |
| const cv=document.createElement('canvas'); cv.width=w; cv.height=h; | |
| cv.getContext('2d').drawImage(drawable,0,0,w,h); | |
| setRef(cv.toDataURL('image/webp',0.9), w, h); | |
| } | |
| function readRefViaImg(file){ | |
| const fr=new FileReader(); | |
| fr.onload=()=>{ const im=new Image(); | |
| im.onload=()=>emitRef(im.naturalWidth,im.naturalHeight,im); im.src=fr.result; }; | |
| fr.readAsDataURL(file); | |
| } | |
| function readRefFile(file){ | |
| if(!file || !/^image\//.test(file.type)) return; | |
| // createImageBitmap applies EXIF orientation, so phone photos don't come out | |
| // rotated/mis-proportioned (which looked like a weird crop under the old path). | |
| if(window.createImageBitmap){ | |
| createImageBitmap(file,{imageOrientation:'from-image'}) | |
| .then(bm=>{ emitRef(bm.width,bm.height,bm); if(bm.close)bm.close(); }) | |
| .catch(()=>readRefViaImg(file)); | |
| return; | |
| } | |
| readRefViaImg(file); | |
| } | |
| // ---------- presets (example row) ---------- | |
| function loadPreset(name){ | |
| const p=window.F3_PRESETS[name]; if(!p) return; | |
| clearRef(); | |
| // Fixed seed so this example's render is deterministic and lazily cacheable. | |
| S.aspect=p.aspect; S.scene=p.scene; S.seed=(p.seed>=0?p.seed:0); S.rendered=false; S.step=2; sel=null; | |
| S.elements=p.elements.map(e=>({id:e.id,color:'violet',bbox:e.bbox.slice(),desc:e.desc})); | |
| NS.el.board.classList.remove('rendered','hideboxes','busy'); | |
| const img=NS.el.board.querySelector('img'); if(img)img.remove(); | |
| NS.el.scene.value=S.scene; NS.el.active=name; | |
| (NS.el.exCards||[]).forEach(t=>t.classList.toggle('active', t.dataset.name===name)); | |
| NS.el.toggleBtn.style.display='none'; | |
| renderAll(); | |
| // Auto-show the example's render: cache HIT returns instantly (no GPU); the | |
| // first visitor to open it generates + caches it (gradio lazy-cache style). | |
| doGenerate(true); | |
| } | |
| // ---------- generate / render ---------- | |
| // fixedSeed=true keeps the current (example) seed so the result is cacheable; | |
| // otherwise re-roll a fresh random seed (custom / replay) and skip the cache. | |
| function doGenerate(fixedSeed){ | |
| if(!S.elements.some(e=>(e.desc||'').trim()) && !(S.scene||'').trim()){ | |
| NS.el.scene.focus(); return; | |
| } | |
| if(!fixedSeed) S.seed=-1; | |
| S.step=3; renderSteps(); syncState(); | |
| NS.el.board.classList.add('busy'); | |
| NS.el.genbtn.setAttribute('disabled',''); | |
| setTimeout(()=>{ const b=genButton(); if(b)b.click(); },70); | |
| clearTimeout(NS._to); NS._to=setTimeout(()=>{ NS.el.board.classList.remove('busy'); | |
| NS.el.genbtn.removeAttribute('disabled'); }, 300000); | |
| } | |
| window.f3_render=function(resStr){ | |
| clearTimeout(NS._to); | |
| NS.el.board.classList.remove('busy'); NS.el.genbtn.removeAttribute('disabled'); | |
| let r; try{ r=JSON.parse(resStr);}catch(e){ return; } | |
| if(!r||!r.image) return; | |
| layoutBoard(); | |
| let img=NS.el.board.querySelector('img'); if(!img){ img=document.createElement('img'); | |
| NS.el.board.insertBefore(img,NS.el.board.firstChild); } img.src=r.image; | |
| S.rendered=true; S.seed=r.seed; | |
| NS.lastResult={image:r.image, w:r.w||0, h:r.h||0}; // for "reuse as input" | |
| NS.el.board.classList.add('rendered'); NS.el.board.classList.remove('hideboxes'); | |
| S.showBoxes=true; updateToggle(); | |
| NS.el.toggleBtn.style.display='inline-flex'; | |
| if(NS.el.reuseBtn) NS.el.reuseBtn.style.display='inline-flex'; | |
| renderBoxes(); | |
| }; | |
| // Reuse the generated output as the i2i reference: it becomes the board image, | |
| // boxes + prompt are cleared (setRef does this), so you can edit it fresh. | |
| function reuseAsInput(){ | |
| if(!NS.lastResult || !NS.lastResult.image) return; | |
| setRef(NS.lastResult.image, NS.lastResult.w||1024, NS.lastResult.h||1024); | |
| if(NS.el.reuseBtn) NS.el.reuseBtn.style.display='none'; | |
| } | |
| // Clear the canvas back to an empty t2i start. | |
| function clearCanvas(){ | |
| clearRef(); | |
| S.elements=[]; S.scene=""; S.seed=-1; S.rendered=false; S.step=1; sel=null; | |
| if(NS.el.scene) NS.el.scene.value=""; | |
| const img=NS.el.board.querySelector('img'); if(img)img.remove(); | |
| NS.el.board.classList.remove('rendered','hideboxes','busy'); | |
| NS.el.toggleBtn.style.display='none'; | |
| if(NS.el.reuseBtn) NS.el.reuseBtn.style.display='none'; | |
| (NS.el.exCards||[]).forEach(t=>t.classList.remove('active')); | |
| renderAll(); | |
| } | |
| function updateToggle(){ | |
| NS.el.toggleBtn.classList.toggle('on',S.showBoxes); | |
| NS.el.board.classList.toggle('hideboxes',!S.showBoxes); | |
| } | |
| // ---------- build DOM ---------- | |
| function build(root){ | |
| const order=window.F3_PRESET_ORDER||Object.keys(window.F3_PRESETS||{}); | |
| root.innerHTML='' | |
| +'<div class="titlebar">' | |
| + '<div class="tb-text"><h1>π¦ FLUX.3 Layout Canvas</h1>' | |
| + '<p>Draw a box for every element, describe it, and the step-distilled FLUX.3 model renders your layout. ' | |
| + 'Upload a reference image to edit it with the same boxes.</p></div>' | |
| + '<div class="f3-links">' | |
| + '<a href="'+((window.F3_LINKS||{}).distilled||'#')+'" target="_blank" rel="noopener">Distilled weights β</a>' | |
| + '<a href="'+((window.F3_LINKS||{}).base||'#')+'" target="_blank" rel="noopener">Base weights β</a>' | |
| + '<a href="'+((window.F3_LINKS||{}).code||'#')+'" target="_blank" rel="noopener">Code β</a>' | |
| + '<a href="'+((window.F3_LINKS||{}).boxes||'#')+'" target="_blank" rel="noopener">Bounding boxes β</a>' | |
| + '</div>' | |
| +'</div>' | |
| // INPUT BAR β the type-here / drop-here controls, in their own distinct zone above the canvas | |
| +'<div class="inputbar">' | |
| + '<div class="ib-ref">' | |
| + '<label class="ib-lab">REFERENCE <span class="opt">optional</span></label>' | |
| + '<div class="refbox"><div class="refthumb"></div>' | |
| + '<div class="refcta"><button class="refbtn">Upload / drop</button>' | |
| + '<button class="refclear">Remove</button></div></div>' | |
| + '<input type="file" accept="image/*" class="reffile" style="display:none"></div>' | |
| + '<div class="ib-prompt"><label class="ib-lab">PROMPT</label>' | |
| + '<textarea rows="2" placeholder="Describe the whole scene in one line β or the edit, if you added a reference image."></textarea>' | |
| + '<div class="ib-up"><span class="btn upBtn"><span class="toggle"></span>Enhance prompt ' | |
| + '<span class="hintq">Β· VLM, text prompts only</span></span></div></div>' | |
| + '<div class="ib-gen"><label class="ib-lab" style="visibility:hidden">GO</label>' | |
| + '<button class="genbtn">Generate</button>' | |
| + '<button class="clearBtn">β Clear canvas</button></div>' | |
| +'</div>' | |
| +'<div class="main">' | |
| + '<div class="leftcol">' | |
| + '<div class="canvas-wrap"><div class="board empty"><div class="hint">Drag to draw your first element box, pick an example below, or add a reference image.</div><div class="spin"><div class="spinner"></div><div class="spinlabel">Rendering with FLUX.3β¦</div></div></div>' | |
| + '<div class="ratio-tag"></div>' | |
| + '<div class="board-tools">' | |
| + '<div class="btn toggleBtn on" style="display:none"><span class="toggle"></span>Bounding boxes</div>' | |
| + '<div class="btn reuseBtn" style="display:none">β³ Reuse as input</div>' | |
| + '<div class="btn replayBtn">β» Replay</div>' | |
| + '</div></div>' | |
| + '<div class="examples"><div class="ex-head">EXAMPLES β click to load, or build your own</div><div class="ex-row"></div></div>' | |
| + '</div>' | |
| + '<div class="side">' | |
| + '<div class="steps"></div>' | |
| + '<div class="summary"></div>' | |
| + '</div>' | |
| +'</div>' | |
| +'<div class="table-bar"><span class="lab">+ VIEW THE ELEMENT TABLE</span><span class="cnt">0 ROWS</span></div>' | |
| +'<div class="etable"></div>'; | |
| // example cards (below the canvas, replacing the old top tabs) | |
| const exRow=root.querySelector('.ex-row'); | |
| const exCards=order.map(name=>{ const p=window.F3_PRESETS[name]||{}; | |
| const c=document.createElement('div'); c.className='excard'; c.dataset.name=name; | |
| c.innerHTML='<div class="exname">'+name+'</div>' | |
| +'<div class="exmeta">'+(p.aspect||'')+' Β· '+((p.elements||[]).length)+' elements</div>'; | |
| c.addEventListener('click',()=>loadPreset(name)); exRow.appendChild(c); return c; }); | |
| // steps | |
| const stepsWrap=root.querySelector('.steps'); const steps=STEP_HINTS.map((h,i)=>{ | |
| const st=document.createElement('div'); st.className='step'; | |
| st.innerHTML='<div class="row"><span class="n">0'+(i+1)+'</span><span class="t">'+h[0]+'</span></div>' | |
| +'<div class="hint">'+h[1]+'</div>'+(i===0?'<div class="ratios"></div>':''); | |
| st.addEventListener('click',()=>{ S.step=i+1; renderSteps(); }); stepsWrap.appendChild(st); return st; }); | |
| // ratio chips in step 1 | |
| const ratiosEl=steps[0].querySelector('.ratios'); | |
| const ratioChips=(window.F3_RATIOS||Object.keys(RATIOS)).map(r=>{ const c=document.createElement('div'); | |
| c.className='chip'; c.dataset.r=r; c.textContent=r; | |
| c.addEventListener('click',ev=>{ ev.stopPropagation(); clearRef(); S.aspect=r; S.rendered=false; | |
| const img=NS.el.board.querySelector('img'); if(img)img.remove(); | |
| NS.el.board.classList.remove('rendered','hideboxes'); NS.el.toggleBtn.style.display='none'; | |
| renderAll(); }); ratiosEl.appendChild(c); return c; }); | |
| NS.el={ root, wrap:root.querySelector('.canvas-wrap'), board:root.querySelector('.board'), | |
| ratioTag:root.querySelector('.ratio-tag'), exCards, steps, ratios:ratiosEl, ratioChips, | |
| summary:root.querySelector('.summary'), scene:root.querySelector('.ib-prompt textarea'), | |
| genbtn:root.querySelector('.genbtn'), toggleBtn:root.querySelector('.toggleBtn'), | |
| replayBtn:root.querySelector('.replayBtn'), cnt:root.querySelector('.table-bar .cnt'), | |
| etable:root.querySelector('.etable'), | |
| reuseBtn:root.querySelector('.reuseBtn'), clearBtn:root.querySelector('.clearBtn'), | |
| upBtn:root.querySelector('.upBtn'), | |
| refBox:root.querySelector('.refbox'), refThumb:root.querySelector('.refthumb'), | |
| reffile:root.querySelector('.reffile') }; | |
| NS.el.board.addEventListener('mousedown',startDraw); | |
| NS.el.scene.addEventListener('input',()=>{ S.scene=NS.el.scene.value; S.step=3; invalidate(); syncState(); }); | |
| NS.el.genbtn.addEventListener('click',()=>doGenerate(false)); | |
| NS.el.replayBtn.addEventListener('click',()=>doGenerate(false)); | |
| NS.el.clearBtn.addEventListener('click',clearCanvas); | |
| NS.el.reuseBtn.addEventListener('click',reuseAsInput); | |
| NS.el.toggleBtn.addEventListener('click',()=>{ S.showBoxes=!S.showBoxes; updateToggle(); }); | |
| NS.el.upBtn.addEventListener('click',()=>{ S.upsample=!S.upsample; | |
| NS.el.upBtn.classList.toggle('on',S.upsample); syncState(); }); | |
| root.querySelector('.table-bar').addEventListener('click',()=>NS.el.etable.classList.toggle('open')); | |
| window.addEventListener('resize',()=>{ layoutBoard(); }); | |
| // reference image (i2i): upload button, remove, and drag-drop onto the canvas | |
| root.querySelector('.refbtn').addEventListener('click',()=>NS.el.reffile.click()); | |
| NS.el.reffile.addEventListener('change',ev=>{ const f=ev.target.files&&ev.target.files[0]; | |
| if(f) readRefFile(f); ev.target.value=''; }); | |
| root.querySelector('.refclear').addEventListener('click',()=>{ clearRef(); | |
| const img=NS.el.board.querySelector('img'); if(img)img.remove(); S.rendered=false; | |
| NS.el.board.classList.remove('rendered','hideboxes'); NS.el.toggleBtn.style.display='none'; | |
| renderAll(); }); | |
| NS.el.wrap.addEventListener('dragover',ev=>{ ev.preventDefault(); }); | |
| NS.el.wrap.addEventListener('drop',ev=>{ ev.preventDefault(); | |
| const f=ev.dataTransfer&&ev.dataTransfer.files&&ev.dataTransfer.files[0]; if(f) readRefFile(f); }); | |
| // Start EMPTY β no boxes. The canvas populates only when the user draws a box, | |
| // clicks an example below, or drops a reference image. | |
| S.step=1; renderAll(); | |
| } | |
| function init(){ | |
| const root=document.getElementById('f3-root'); | |
| if(!root || !fieldEl('f3-state') || !genButton()){ | |
| return false; | |
| } | |
| if(root.dataset.ready) return true; | |
| root.dataset.ready='1'; build(root); return true; | |
| } | |
| const iv=setInterval(()=>{ if(init()) clearInterval(iv); }, 120); | |
| })(); | |
| </script> | |
| """ | |
| HEAD = STYLE + STYLE2 + CONFIG_JS + SCRIPT | |
| with gr.Blocks(head=HEAD, title="FLUX.3 layout canvas", | |
| css="footer{display:none!important} .gradio-container{max-width:1240px!important}" | |
| " .f3-bridge{position:absolute!important;left:-9999px!important;top:0;" | |
| "width:1px!important;height:1px!important;overflow:hidden!important;" | |
| "opacity:0!important;pointer-events:none!important;}") as demo: | |
| # Bridge components: kept MOUNTED (not visible=False, which unmounts them from the | |
| # DOM in gradio 5.x) but hidden off-screen via CSS, so the canvas JS can find them. | |
| state_box = gr.Textbox(elem_id="f3-state", elem_classes=["f3-bridge"]) | |
| result_box = gr.Textbox(elem_id="f3-result", elem_classes=["f3-bridge"]) | |
| gen_btn = gr.Button("gen", elem_id="f3-gen", elem_classes=["f3-bridge"]) | |
| gr.HTML('<div id="f3-root"></div>') | |
| ev = gen_btn.click(generate, [state_box], [result_box], api_name="generate", | |
| show_progress="hidden") | |
| ev.then(None, [result_box], None, js="(r)=>{ if(window.f3_render) window.f3_render(r); }") | |
| if __name__ == "__main__": | |
| demo.queue(max_size=12).launch(show_error=True) | |