Spaces:
Running on Zero
Running on Zero
Download app.py from MiniMaxAI/minimax-h3-image: direct link, hf CLI and curl.
- Browser
- Download file 29.5 kB
-
https://huggingface.co/spaces/MiniMaxAI/minimax-h3-image/resolve/main/app.py
- Command line
-
hf download hf://spaces/MiniMaxAI/minimax-h3-image/app.py
-
curl -L -o app.py https://huggingface.co/spaces/MiniMaxAI/minimax-h3-image/resolve/main/app.py
29.5 kB
| import glob | |
| import os | |
| import subprocess | |
| import sys | |
| # diffusers from the MiniMax-H3-Image PR branch (private repo, so the wheel ships with the Space). | |
| for _wheel in glob.glob(os.path.join(os.path.dirname(os.path.abspath(__file__)), "diffusers-*.whl")): | |
| subprocess.check_call([sys.executable, "-m", "pip", "install", "--no-deps", "--force-reinstall", "--no-cache-dir", _wheel]) | |
| import spaces # before torch | |
| import json | |
| import math | |
| import os | |
| import random | |
| import tempfile | |
| import time | |
| from functools import cache | |
| import gradio as gr | |
| import torch | |
| from PIL import Image | |
| from safetensors.torch import load_file | |
| from diffusers import ModularPipeline | |
| REPO = "diffusers-internal-dev/minimax-h3-image" | |
| ENCODER_SPACE = os.environ.get("H3_IMAGE_ENCODER", "multimodalart/minimax-h3-image-encoder") | |
| MAX_IMAGES = 8 | |
| MAX_SEED = 2**31 - 1 | |
| MULTIPLE = 32 | |
| RATIOS = ["1:1", "5:4", "4:3", "3:2", "16:9", "21:9", "4:5", "3:4", "2:3", "9:16"] # MiniMax-H3-Image-PE's set | |
| MEGAPIXELS = {"4 MP": 4, "8 MP": 8, "16 MP": 16} | |
| AUTO = "auto" # PE's recommendation for text-to-image, Picture 1's ratio for edits, else 1:1 | |
| PE_SPACE = os.environ.get("H3_IMAGE_PE", "multimodalart/minimax-h3-image-pe") | |
| # Everything but the Qwen3-VL text encoder, which runs on ENCODER_SPACE: `prompt_embeds` + `text_token_tags` become | |
| # inputs of the pipeline. | |
| blocks = ModularPipeline.from_pretrained(REPO).blocks | |
| blocks.sub_blocks.pop("text_encoder") | |
| pipe = blocks.init_pipeline(REPO) | |
| pipe.load_components(dtype=torch.bfloat16) | |
| pipe.to("cuda") | |
| def encoder(): | |
| from gradio_client import Client | |
| return Client(ENCODER_SPACE, token=os.environ.get("HF_TOKEN"), httpx_kwargs={"timeout": 600}) | |
| def image_paths(images): | |
| return [item[0] if isinstance(item, (list, tuple)) else item for item in images or []] | |
| def size_at_area(width, height, pixels): | |
| scale = math.sqrt(pixels / (width * height)) | |
| return ( | |
| max(MULTIPLE, round(width * scale / MULTIPLE) * MULTIPLE), | |
| max(MULTIPLE, round(height * scale / MULTIPLE) * MULTIPLE), | |
| ) | |
| def resolve_size(paths, aspect, resolution): | |
| pixels = MEGAPIXELS.get(resolution, 4) * 1024 * 1024 | |
| if aspect not in RATIOS: | |
| if paths: | |
| width, height = Image.open(paths[0]).size | |
| return size_at_area(width, height, pixels) | |
| aspect = "1:1" | |
| rw, rh = (int(v) for v in aspect.split(":")) | |
| return size_at_area(rw, rh, pixels) | |
| def pe_client(): | |
| from gradio_client import Client | |
| return Client(PE_SPACE, token=os.environ.get("HF_TOKEN"), httpx_kwargs={"timeout": 900}) | |
| def enhance_stream(prompt, effort, seed): | |
| """Streams MiniMax-H3-Image-PE: yields (thinking, None, None) while it reasons, then (thinking, caption, ratio).""" | |
| job = pe_client().submit(prompt, effort, int(seed) % (2**31), api_name="/expand") | |
| shown = 0 | |
| for update in job: | |
| thinking = update[2] or "" | |
| if len(thinking) - shown > 200: | |
| shown = len(thinking) | |
| yield thinking, None, None | |
| caption, ratio, thinking, _ = job.result() | |
| yield thinking, caption, ratio | |
| def prepare(prompt, images, aspect, resolution, seed, randomize): | |
| """Validate the request, pick the size and seed, and encode the prompt on the encoder Space.""" | |
| if not prompt or not prompt.strip(): | |
| raise gr.Error("Enter a prompt.") | |
| paths = image_paths(images) | |
| if len(paths) > MAX_IMAGES: | |
| raise gr.Error(f"At most {MAX_IMAGES} images.") | |
| width, height = resolve_size(paths, aspect, resolution) | |
| if randomize: | |
| seed = random.randint(0, MAX_SEED) | |
| from gradio_client import handle_file | |
| embeds_path = encoder().predict(prompt.strip(), [{"image": handle_file(p), "caption": None} for p in paths], api_name="/encode") | |
| return embeds_path, int(seed), int(width), int(height) | |
| def _duration(embeds_path, images, width, height, seed): | |
| # The canvas is at least 2048x2048; references add their own latent rows. | |
| megapixels = max(int(width) * int(height), 2048 * 2048) / 1e6 | |
| for path in image_paths(images): | |
| w, h = Image.open(path).size | |
| megapixels += min(max(w * h / 1e6, 1.0), 4.2) | |
| return int(30 + 4 * megapixels + 0.15 * megapixels**2) | |
| def _run(embeds_path, images, width, height, seed): | |
| embeds = load_file(embeds_path) | |
| kwargs = dict( | |
| prompt_embeds=embeds["prompt_embeds"].to("cuda", torch.bfloat16), | |
| text_token_tags=embeds["text_token_tags"], | |
| width=int(width), | |
| height=int(height), | |
| generator=torch.Generator("cpu").manual_seed(int(seed)), | |
| output="images", | |
| ) | |
| paths = image_paths(images) | |
| if paths: | |
| kwargs["image"] = [Image.open(p).convert("RGB") for p in paths] | |
| image = pipe(**kwargs)[0] | |
| path = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name | |
| image.save(path) | |
| return path | |
| def _generate(embeds_path, images, width, height, seed): | |
| return _run(embeds_path, images, width, height, seed) | |
| def generate(prompt, images=None, aspect=AUTO, resolution="4 MP", seed=0, randomize=True, enhance=False, | |
| reasoning_effort="low", progress=gr.Progress(track_tqdm=True)): | |
| """Text-to-image, or an edit of up to 8 images referred to as `<Picture 1>`, `<Picture 2>`, ... in the prompt. | |
| `aspect`: one of 1:1, 5:4, 4:3, 3:2, 16:9, 21:9, 4:5, 3:4, 2:3, 9:16, or "auto". `enhance`: expand the prompt with | |
| MiniMax-H3-Image-PE first (text-to-image only). Returns (image, seed, prompt used). | |
| """ | |
| if not prompt or not prompt.strip(): | |
| raise gr.Error("Enter a prompt.") | |
| final = prompt.strip() | |
| if enhance and not image_paths(images): | |
| progress(0, desc="Enhancing the prompt ...") | |
| *_, (thinking, caption, ratio) = enhance_stream(final, reasoning_effort, seed) | |
| final = caption or final | |
| if aspect == AUTO and ratio: | |
| aspect = ratio | |
| progress(0.05, desc="Encoding the prompt ...") | |
| embeds_path, seed, width, height = prepare(final, images, aspect, resolution, seed, randomize) | |
| progress(0.1, desc=f"Generating {width}x{height} ...") | |
| return _generate(embeds_path, images, width, height, seed), seed, final | |
| def studio(state, seed, randomize, effort): | |
| """The composer's Generate, part 1: optional PE (streamed) and encoding. Returns the render plan.""" | |
| state = state or {} | |
| prompt = (state.get("prompt") or "").strip() | |
| aspect = state.get("aspect") or AUTO | |
| resolution = state.get("resolution") or "4 MP" | |
| if not prompt: | |
| raise gr.Error("Describe the image first.") | |
| paths = decode_pictures(state.get("images")) | |
| if randomize: | |
| seed = random.randint(0, MAX_SEED) | |
| final, thinking = prompt, "" | |
| if state.get("enhance") and paths: | |
| gr.Info("Prompt enhancement is text-to-image only for now; edits use your prompt as written.") | |
| elif state.get("enhance"): | |
| yield "✨ Enhancing the prompt with MiniMax-H3-Image-PE ...", gr.update(), "", seed, None | |
| t0 = time.time() | |
| caption = ratio = None | |
| for thinking, caption, ratio in enhance_stream(prompt, effort, seed): | |
| if caption is None: | |
| yield f"✨ Enhancing the prompt · thinking ({len(thinking.split())} words) ...", gr.update(), thinking, seed, None | |
| if caption: | |
| final = caption | |
| if aspect == AUTO and ratio: | |
| aspect = ratio | |
| yield f"✨ Prompt enhanced in {time.time() - t0:.0f}s · encoding ...", final, thinking, seed, None | |
| width, height = resolve_size(paths, aspect, resolution) | |
| yield f"Encoding the prompt · {width}×{height} ...", final, thinking, seed, None | |
| embeds_path, seed, width, height = prepare(final, paths, aspect, resolution, seed, False) | |
| plan = {"embeds": embeds_path, "images": paths, "width": width, "height": height, "seed": seed} | |
| yield f"Generating {width}×{height} ...", final, thinking, seed, plan | |
| def _render_duration(plan, progress=None): | |
| return _duration(plan["embeds"], plan["images"], plan["width"], plan["height"], plan["seed"]) | |
| def render(plan, progress=gr.Progress(track_tqdm=True)): | |
| """Part 2: the image, as its own event so the result panel shows the loading UI and the denoising steps.""" | |
| t0 = time.time() | |
| path = _run(plan["embeds"], plan["images"], plan["width"], plan["height"], plan["seed"]) | |
| return path, f"{plan['width']}×{plan['height']} · seed {plan['seed']} · {time.time() - t0:.0f}s" | |
| IDEAS = [ | |
| "a two-color risograph print of a fox reading a newspaper on a park bench, headline \"FOX NEWS\"", | |
| "a 1970s film frame of a woman waiting at a rainy Tokyo bus stop, lit by a vending machine", | |
| "isometric cutaway of a tiny underground bakery", | |
| "a poster for a jazz festival in Lisbon", | |
| ] | |
| COMPOSER_DEFAULTS = {"prompt": "", "aspect": AUTO, "resolution": "4 MP", "enhance": True, "images": []} | |
| def decode_pictures(urls): | |
| """The composer's pictures (data URLs, in <Picture i> order) as temp files.""" | |
| import base64 | |
| paths = [] | |
| for url in (urls or [])[:MAX_IMAGES]: | |
| header, _, data = str(url).partition(",") | |
| ext = {"image/png": ".png", "image/webp": ".webp", "image/gif": ".gif"}.get(header[5:].split(";")[0], ".jpg") | |
| path = tempfile.NamedTemporaryFile(suffix=ext, delete=False).name | |
| with open(path, "wb") as f: | |
| f.write(base64.b64decode(data)) | |
| paths.append(path) | |
| return paths | |
| COMPOSER_HTML = """ | |
| <div class="hx-card"> | |
| <div class="hx-head"> | |
| <div class="hx-seg" data-role="resolution" role="tablist"> | |
| <button type="button" class="hx-seg-btn" data-value="4 MP">4 MP</button> | |
| <button type="button" class="hx-seg-btn" data-value="8 MP">8 MP</button> | |
| <button type="button" class="hx-seg-btn" data-value="16 MP">16 MP</button> | |
| </div> | |
| <span class="hx-headhint">text-to-image · or edit up to 8 pictures</span> | |
| </div> | |
| <div class="hx-pics" data-role="pics"> | |
| <div class="hx-thumbs" data-role="thumbs"></div> | |
| <label class="hx-add" title="Optional: up to 8 pictures to edit; drop files here too"> | |
| <input type="file" accept="image/*" multiple data-role="files" hidden> | |
| <span class="hx-tile" aria-hidden="true"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"> | |
| <rect x="3" y="4" width="18" height="16" rx="2.5"/><circle cx="8.5" cy="9.5" r="1.7"/><path d="M3.5 17.5l5-5 4 4 2.5-2.5 5.5 5.5"/> | |
| </svg> | |
| <span class="hx-tile-plus">+</span> | |
| </span> | |
| <span class="hx-addtext"><span class="hx-addtitle">Pictures to edit</span><span class="hx-opt">optional · refer to them as <Picture 1>, <Picture 2>…</span></span> | |
| </label> | |
| </div> | |
| <div class="hx-pictags" data-role="pictags" hidden><span class="hx-pictags-label">Insert</span></div> | |
| <textarea class="hx-desc" data-role="prompt" rows="4" | |
| placeholder="Describe your image… e.g. a poster for a jazz festival in Lisbon, or an edit of the pictures above"></textarea> | |
| <div class="hx-chips" data-role="idea-chips"></div> | |
| <div class="hx-row"> | |
| <span class="hx-label">Aspect</span> | |
| <div class="hx-ratios" data-role="aspect"></div> | |
| </div> | |
| <div class="hx-foot"> | |
| <label class="hx-toggle" title="Expand the prompt into a detailed caption (and pick the ratio when Aspect is Auto) with MiniMax-H3-Image-PE"> | |
| <input type="checkbox" data-role="enhance">✨ Enhance prompt | |
| </label> | |
| <span class="hx-spacer"></span> | |
| <button type="button" class="hx-primary" data-role="generate">Generate</button> | |
| </div> | |
| </div> | |
| """ | |
| COMPOSER_CSS = """ | |
| .hx-card { background: var(--block-background-fill); border: var(--block-border-width, 1px) solid var(--block-border-color, var(--border-color-primary)); border-radius: var(--block-radius, 12px); box-shadow: var(--block-shadow, none); padding: 16px; display: flex; flex-direction: column; gap: 12px; } | |
| .hx-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; } | |
| .hx-seg { display: inline-flex; background: var(--background-fill-secondary); border: 1px solid var(--border-color-primary); border-radius: 999px; padding: 3px; gap: 2px; } | |
| .hx-seg-btn { border: none; background: transparent; color: var(--body-text-color-subdued); padding: 5px 14px; border-radius: 999px; font-family: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer; transition: background .15s, color .15s; } | |
| .hx-seg-btn[aria-selected="true"] { background: var(--button-primary-background-fill); color: var(--button-primary-text-color); } | |
| .hx-headhint { color: var(--body-text-color-subdued); font-size: 12.5px; } | |
| .hx-card textarea { width: 100%; box-sizing: border-box; background: var(--input-background-fill); border: var(--input-border-width, 1px) solid var(--input-border-color, var(--border-color-primary)); border-radius: var(--input-radius, 8px); padding: 10px 12px; color: var(--body-text-color); font-family: inherit; font-size: 16px; line-height: 1.5; min-height: 118px; resize: vertical; transition: border-color .15s; } | |
| .hx-card textarea::placeholder { color: var(--input-placeholder-color, var(--body-text-color-subdued)); } | |
| .hx-card textarea:focus { outline: none; border-color: var(--input-border-color-focus, var(--color-accent)); } | |
| .hx-pics { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-height: 72px; box-sizing: border-box; border: 1px dashed var(--border-color-primary); border-radius: var(--radius-lg, 8px); padding: 9px 10px; transition: border-color .15s, background .15s; } | |
| .hx-pics.hx-drag { border-color: var(--color-accent); background: var(--background-fill-secondary); } | |
| .hx-thumbs { display: flex; gap: 6px; flex-wrap: wrap; } | |
| .hx-thumbs:empty { display: none; } | |
| .hx-thumb { position: relative; width: 52px; height: 52px; border-radius: 6px; overflow: hidden; border: 1px solid var(--border-color-primary); } | |
| .hx-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } | |
| .hx-thumb .hx-n { position: absolute; left: 2px; bottom: 2px; background: rgba(0,0,0,.65); color: #fff; font-size: 10px; line-height: 1; padding: 2px 4px; border-radius: 4px; } | |
| .hx-thumb .hx-x { position: absolute; right: 1px; top: 1px; width: 16px; height: 16px; border: none; border-radius: 50%; background: rgba(0,0,0,.65); color: #fff; font-size: 11px; line-height: 16px; padding: 0; cursor: pointer; } | |
| .hx-add { display: inline-flex; align-items: center; gap: 10px; color: var(--body-text-color-subdued); cursor: pointer; user-select: none; } | |
| .hx-tile { position: relative; flex: none; width: 52px; height: 52px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 1px dashed var(--border-color-primary); border-radius: 8px; background: var(--background-fill-secondary); transition: color .15s, border-color .15s; } | |
| .hx-tile svg { width: 28px; height: 28px; } | |
| .hx-tile-plus { position: absolute; right: -6px; bottom: -6px; width: 18px; height: 18px; border-radius: 50%; background: var(--button-primary-background-fill); color: var(--button-primary-text-color); font-size: 14px; font-weight: 700; line-height: 18px; text-align: center; } | |
| .hx-add:hover .hx-tile { color: var(--color-accent); border-color: var(--color-accent); } | |
| .hx-addtext { display: flex; flex-direction: column; gap: 2px; } | |
| .hx-addtitle { color: var(--body-text-color); font-size: 13.5px; font-weight: 600; } | |
| .hx-add:hover .hx-addtitle { color: var(--color-accent); } | |
| .hx-opt { font-size: 11.5px; opacity: .85; } | |
| .hx-pictags { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; margin-top: -4px; } | |
| .hx-pictags[hidden] { display: none; } | |
| .hx-pictags-label { color: var(--body-text-color-subdued); font-size: 12px; margin-right: 2px; } | |
| .hx-pictag { background: transparent; color: var(--body-text-color-subdued); border: 1px dashed var(--border-color-primary); border-radius: 6px; padding: 2px 8px; font-family: var(--font-mono, ui-monospace, monospace); font-size: 11.5px; cursor: pointer; transition: color .15s, border-color .15s; } | |
| .hx-pictag:hover { color: var(--color-accent); border-color: var(--color-accent); } | |
| .hx-thumb img { cursor: pointer; } | |
| .hx-chips { display: flex; flex-wrap: wrap; gap: 6px; } | |
| .hx-chip { background: var(--button-secondary-background-fill); color: var(--button-secondary-text-color); border: 1px solid var(--button-secondary-border-color, var(--border-color-primary)); border-radius: 999px; padding: 4px 12px; font-family: inherit; font-size: 12.5px; cursor: pointer; transition: border-color .15s; text-align: left; } | |
| .hx-chip:hover { border-color: var(--color-accent); } | |
| .hx-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } | |
| .hx-label { color: var(--block-title-text-color, var(--body-text-color)); font-size: 13px; font-weight: 600; } | |
| .hx-ratios { display: flex; flex-wrap: wrap; gap: 4px; } | |
| .hx-ratio { background: transparent; color: var(--body-text-color-subdued); border: 1px dashed var(--border-color-primary); border-radius: 6px; padding: 3px 9px; font-family: var(--font-mono, ui-monospace, monospace); font-size: 12px; cursor: pointer; transition: color .15s, border-color .15s, background .15s; } | |
| .hx-ratio:hover { color: var(--color-accent); border-color: var(--color-accent); } | |
| .hx-ratio[aria-selected="true"] { border-style: solid; border-color: var(--color-accent); color: var(--color-accent); background: var(--background-fill-secondary); } | |
| .hx-foot { display: flex; align-items: center; gap: 10px; } | |
| .hx-spacer { flex: 1; } | |
| .hx-toggle { display: inline-flex; align-items: center; gap: 7px; color: var(--body-text-color); font-size: 14px; cursor: pointer; user-select: none; } | |
| .hx-toggle input { width: 16px; height: 16px; accent-color: var(--color-accent); cursor: pointer; } | |
| .hx-primary { background: var(--button-primary-background-fill); color: var(--button-primary-text-color); border: var(--button-border-width, 1px) solid var(--button-primary-border-color, transparent); border-radius: var(--button-large-radius, var(--radius-lg, 8px)); padding: 10px 28px; font-family: inherit; font-size: var(--button-large-text-size, 16px); font-weight: 600; cursor: pointer; box-shadow: var(--button-primary-shadow, none); transition: background .15s, transform .05s; } | |
| .hx-primary:hover { background: var(--button-primary-background-fill-hover, var(--button-primary-background-fill)); } | |
| .hx-primary:active { transform: translateY(1px); } | |
| """ | |
| COMPOSER_JS = """ | |
| const $ = function(s) { return element.querySelector(s); }; | |
| const $$ = function(s) { return Array.from(element.querySelectorAll(s)); }; | |
| const IDEAS = __IDEAS__; | |
| const RATIOS = ['auto'].concat(__RATIOS__); | |
| const prompt = $('[data-role="prompt"]'); | |
| const enhance = $('[data-role="enhance"]'); | |
| let aspect = 'auto', resolution = '4 MP', lastPushed = '', pics = []; | |
| const MAX_PICS = 8; | |
| function insertTag(tag) { | |
| const v = prompt.value; | |
| const at = prompt.selectionStart == null ? v.length : prompt.selectionStart; | |
| const end = prompt.selectionEnd == null ? at : prompt.selectionEnd; | |
| const before = v.slice(0, at), after = v.slice(end); | |
| const ins = (before && !/\\s$/.test(before) ? ' ' : '') + tag + (after && !/^\\s/.test(after) ? ' ' : (after ? '' : ' ')); | |
| prompt.value = before + ins + after; | |
| const pos = (before + ins).length; | |
| prompt.focus(); | |
| prompt.setSelectionRange(pos, pos); | |
| } | |
| function renderTags() { | |
| const row = $('[data-role="pictags"]'); | |
| Array.from(row.querySelectorAll('.hx-pictag')).forEach(function(b) { b.remove(); }); | |
| row.hidden = pics.length === 0; | |
| pics.forEach(function(_, i) { | |
| const b = document.createElement('button'); | |
| b.type = 'button'; b.className = 'hx-pictag'; b.textContent = '<Picture ' + (i + 1) + '>'; | |
| b.title = 'Insert into the prompt'; | |
| b.addEventListener('mousedown', function(e) { e.preventDefault(); }); // keep the prompt's cursor | |
| b.addEventListener('click', function() { insertTag('<Picture ' + (i + 1) + '>'); }); | |
| row.appendChild(b); | |
| }); | |
| } | |
| function renderPics() { | |
| renderTags(); | |
| const box = $('[data-role="thumbs"]'); | |
| box.innerHTML = ''; | |
| pics.forEach(function(url, i) { | |
| const t = document.createElement('div'); t.className = 'hx-thumb'; | |
| const img = document.createElement('img'); img.src = url; img.title = 'Insert <Picture ' + (i + 1) + '> into the prompt'; | |
| img.addEventListener('click', function() { insertTag('<Picture ' + (i + 1) + '>'); }); | |
| t.appendChild(img); | |
| const n = document.createElement('span'); n.className = 'hx-n'; n.textContent = i + 1; t.appendChild(n); | |
| const x = document.createElement('button'); x.type = 'button'; x.className = 'hx-x'; x.textContent = '\u00d7'; x.title = 'Remove'; | |
| x.addEventListener('click', function() { pics.splice(i, 1); renderPics(); }); | |
| t.appendChild(x); | |
| box.appendChild(t); | |
| }); | |
| } | |
| function addFiles(files) { | |
| Array.from(files || []).filter(function(f) { return f.type.indexOf('image/') === 0; }).forEach(function(f) { | |
| if (pics.length >= MAX_PICS) return; | |
| const r = new FileReader(); | |
| r.onload = function() { if (pics.length < MAX_PICS) { pics.push(r.result); renderPics(); } }; | |
| r.readAsDataURL(f); | |
| }); | |
| } | |
| const picsBox = $('[data-role="pics"]'); | |
| $('[data-role="files"]').addEventListener('change', function(e) { addFiles(e.target.files); e.target.value = ''; }); | |
| ['dragenter', 'dragover'].forEach(function(ev) { picsBox.addEventListener(ev, function(e) { e.preventDefault(); picsBox.classList.add('hx-drag'); }); }); | |
| ['dragleave', 'drop'].forEach(function(ev) { picsBox.addEventListener(ev, function(e) { e.preventDefault(); picsBox.classList.remove('hx-drag'); }); }); | |
| picsBox.addEventListener('drop', function(e) { addFiles(e.dataTransfer.files); }); | |
| RATIOS.forEach(function(r) { | |
| const b = document.createElement('button'); | |
| b.type = 'button'; b.className = 'hx-ratio'; b.dataset.value = r; | |
| b.textContent = r === 'auto' ? 'Auto' : r; | |
| if (r === 'auto') b.title = 'Enhanced: the ratio PE recommends. Editing: Picture 1\\'s ratio. Otherwise 1:1.'; | |
| b.addEventListener('click', function() { setAspect(r); }); | |
| $('[data-role="aspect"]').appendChild(b); | |
| }); | |
| IDEAS.forEach(function(t, i) { | |
| const b = document.createElement('button'); | |
| b.type = 'button'; b.className = 'hx-chip'; b.textContent = t; | |
| b.addEventListener('click', function() { prompt.value = t; gather({example_key: 'idea_' + i}); trigger('apply'); }); | |
| $('[data-role="idea-chips"]').appendChild(b); | |
| }); | |
| function setAspect(r) { aspect = r; $$('.hx-ratio').forEach(function(b) { b.setAttribute('aria-selected', String(b.dataset.value === r)); }); } | |
| function setResolution(r) { resolution = r; $$('.hx-seg-btn').forEach(function(b) { b.setAttribute('aria-selected', String(b.dataset.value === r)); }); } | |
| $$('.hx-seg-btn').forEach(function(b) { b.addEventListener('click', function() { setResolution(b.dataset.value); }); }); | |
| function readState() { return { prompt: prompt.value, aspect: aspect, resolution: resolution, enhance: enhance.checked, images: pics.slice() }; } | |
| function gather(extra) { const s = Object.assign(readState(), extra || {}); lastPushed = JSON.stringify(s); props.value = s; } | |
| function applyState(v) { | |
| if (!v) return; | |
| if (prompt.value !== (v.prompt || '')) prompt.value = v.prompt || ''; | |
| enhance.checked = !!v.enhance; | |
| setAspect(v.aspect || 'auto'); | |
| setResolution(v.resolution || '4 MP'); | |
| if (Array.isArray(v.images)) { pics = v.images.slice(0, MAX_PICS); renderPics(); } | |
| } | |
| $('[data-role="generate"]').addEventListener('click', function() { gather(); trigger('submit'); }); | |
| prompt.addEventListener('keydown', function(e) { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); gather(); trigger('submit'); } }); | |
| watch('value', function() { if (JSON.stringify(props.value) !== lastPushed) applyState(props.value); }); | |
| applyState(props.value); | |
| """.replace("__IDEAS__", json.dumps(IDEAS)).replace("__RATIOS__", json.dumps(RATIOS)) | |
| CSS = """ | |
| #col-container { max-width: 1300px; margin: 0 auto; } | |
| .dark .gradio-container { color: var(--body-text-color); } | |
| .html-container { padding: 0; } | |
| #hx-composer, #hx-composer > div, #hx-composer .html-container { padding: 0 !important; } | |
| .hx-brand { display: flex; align-items: center; justify-content: center; gap: 12px; margin: 12px auto 0; max-width: 320px; } | |
| .hx-brand img { height: 40px; width: auto; max-width: 140px; object-fit: contain; display: block; } | |
| .hx-logo-dark { display: none !important; } | |
| .dark .hx-logo-light { display: none !important; } | |
| .dark .hx-logo-dark { display: block !important; } | |
| .hx-word { font-size: 30px; font-weight: 400; line-height: 1; letter-spacing: -0.01em; color: var(--body-text-color); white-space: nowrap; } | |
| .hx-grad { background: linear-gradient(90deg, #e8197e, #ff6a3d); -webkit-background-clip: text; background-clip: text; color: transparent; font-weight: 500; } | |
| #hx-links { text-align: center; } | |
| """ | |
| import base64 | |
| HERE = os.path.dirname(os.path.abspath(__file__)) | |
| LOGO_LIGHT = base64.b64encode(open(os.path.join(HERE, "minimax_logo_light.png"), "rb").read()).decode() | |
| LOGO_DARK = base64.b64encode(open(os.path.join(HERE, "minimax_logo_dark.png"), "rb").read()).decode() | |
| EXAMPLES = {} | |
| if os.path.exists(os.path.join(HERE, "examples", "manifest.json")): | |
| EXAMPLES = json.load(open(os.path.join(HERE, "examples", "manifest.json"), encoding="utf-8")) | |
| def load_example(state): | |
| """An idea chip: show its precomputed result (PE at low effort, then H3-Image at PE's ratio, 4 MP).""" | |
| ex = EXAMPLES.get((state or {}).get("example_key") or "") | |
| if not ex: | |
| return gr.update(), gr.update(), gr.update(), gr.update() | |
| stats = f"cached example · enhanced prompt · {ex['aspect']} · {ex['size']} · seed {ex['seed']}" | |
| return os.path.join(HERE, ex["image"]), stats, ex["prompt_used"], ex["thinking"] | |
| with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="MiniMax H3 Image") as demo: | |
| with gr.Column(elem_id="col-container"): | |
| gr.HTML( | |
| '<div class="hx-brand">' | |
| f'<img src="data:image/png;base64,{LOGO_LIGHT}" class="hx-logo-light" alt="MiniMax">' | |
| f'<img src="data:image/png;base64,{LOGO_DARK}" class="hx-logo-dark" alt="MiniMax">' | |
| '<span class="hx-word"><span class="hx-grad">H3</span> Image</span></div>', | |
| container=False, padding=False, | |
| ) | |
| gr.Markdown( | |
| "Text-to-image and editing of up to 8 pictures in 6 steps, with optional prompt enhancement by " | |
| "MiniMax-H3-Image-PE " | |
| "[[model]](https://huggingface.co/MiniMaxAI/MiniMax-H3-Image) | " | |
| "[[prompt enhancer]](https://huggingface.co/MiniMaxAI/MiniMax-H3-Image-PE)", | |
| elem_id="hx-links", | |
| ) | |
| with gr.Row(): | |
| with gr.Column(): | |
| composer = gr.HTML( | |
| value=dict(COMPOSER_DEFAULTS), | |
| elem_id="hx-composer", | |
| html_template=COMPOSER_HTML, | |
| css_template=COMPOSER_CSS, | |
| js_on_load=COMPOSER_JS, | |
| container=False, | |
| padding=False, | |
| ) | |
| images = gr.Gallery(type="filepath", visible=False) # API input; the composer carries its own pictures | |
| with gr.Accordion("Advanced", open=False): | |
| effort = gr.Radio(["low", "medium", "xhigh"], value="low", label="Prompt enhancer reasoning effort", | |
| info="xhigh is the released default; low thinks briefly and is several times faster") | |
| with gr.Row(): | |
| seed = gr.Slider(label="Seed", minimum=0, maximum=MAX_SEED, step=1, value=0) | |
| randomize = gr.Checkbox(label="Randomize seed", value=True) | |
| with gr.Column(): | |
| output = gr.Image(label="Result", type="filepath", format="png", show_label=False, height=620) | |
| stats = gr.Markdown() | |
| with gr.Accordion("Prompt used", open=False): | |
| prompt_used = gr.Textbox(show_label=False, lines=8) | |
| with gr.Accordion("Enhancer thinking", open=False): | |
| thinking = gr.Textbox(show_label=False, lines=10) | |
| plan = gr.State() | |
| composer.submit( | |
| lambda: (None, None), None, [output, plan], show_progress="hidden", api_visibility="private", | |
| ).then( | |
| studio, [composer, seed, randomize, effort], [stats, prompt_used, thinking, seed, plan], | |
| show_progress="minimal", api_visibility="private", | |
| ).success( | |
| render, [plan], [output, stats], show_progress="full", show_progress_on=[output], api_visibility="private", | |
| ) | |
| composer.apply( | |
| load_example, [composer], [output, stats, prompt_used, thinking], | |
| show_progress="hidden", api_visibility="private", | |
| ) | |
| # Plain API endpoint (also what the composer runs, minus the streaming UI updates). | |
| api_prompt = gr.Textbox(visible=False) | |
| api_aspect = gr.Textbox(visible=False, value=AUTO) | |
| api_resolution = gr.Textbox(visible=False, value="4 MP") | |
| api_enhance = gr.Checkbox(visible=False, value=False) | |
| api_effort = gr.Textbox(visible=False, value="low") | |
| api_button = gr.Button(visible=False) | |
| api_button.click( | |
| generate, | |
| [api_prompt, images, api_aspect, api_resolution, seed, randomize, api_enhance, api_effort], | |
| [output, seed, prompt_used], | |
| api_name="generate", | |
| ) | |
| demo.launch() | |