amisima commited on
Commit
179ef37
·
verified ·
1 Parent(s): 20acce4

Upload 2 files

Browse files
Files changed (2) hide show
  1. README.md +31 -6
  2. app.py +467 -8
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: MiniMax-H3 · reference · Shared lora library + CivitAI + scene continuation + identity lock
3
  emoji: 🎭
4
  colorFrom: pink
5
  colorTo: purple
@@ -7,7 +7,7 @@ sdk: gradio
7
  sdk_version: 6.20.0
8
  app_file: app.py
9
  pinned: true
10
- short_description: Video + soundtrack, shared lora library, structured prompts
11
  suggested_hardware: zero-a10g
12
  tags:
13
  - not-for-all-audiences
@@ -20,10 +20,36 @@ tags:
20
  - zerogpu
21
  ---
22
 
23
- # MiniMax-H3 — omni-references, unquantized, split across two Spaces
24
 
25
- Joint video **and** soundtrack out of a single denoising pass, conditioned on an ordered list of image, video and
26
- audio references, at **bfloat16 with no quantization anywhere**.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  This Space is the denoising half of the `ref2va` task: the 61.73 GiB `transformer_ref` partition and the two
29
  autoencoders. The 62.14 GiB Qwen3-VL conditioner runs in
@@ -31,7 +57,6 @@ autoencoders. The 62.14 GiB Qwen3-VL conditioner runs in
31
  the gradio API for every request — the same conditioner Space, and the same resident weights, that the keyframe half
32
  [`minimax-h3`](https://huggingface.co/spaces/multimodalart/minimax-h3) uses.
33
 
34
- ## What this fork adds
35
 
36
  **A structured prompt builder.** H3 was trained on the output of H3-Context-IR, a preprocessor that rewrites a plain
37
  request into labelled sections, and MiniMax's own model card calls that structure *critical to the quality of the
 
1
  ---
2
+ title: MiniMax-H3 · reference · One button, photo to video
3
  emoji: 🎭
4
  colorFrom: pink
5
  colorTo: purple
 
7
  sdk_version: 6.20.0
8
  app_file: app.py
9
  pinned: true
10
+ short_description: One button it writes the prompt and picks the loras
11
  suggested_hardware: zero-a10g
12
  tags:
13
  - not-for-all-audiences
 
20
  - zerogpu
21
  ---
22
 
23
+ # MiniMax-H3 — one button, or every dial
24
 
25
+ **Picture in, a few words, one press.** The description is written from your first reference picture, wrapped in the
26
+ labelled sections H3 was actually trained on, and the shared library is searched for loras that match it. Everything
27
+ this Space can do is still here — one tick at the top swaps the whole page over to it.
28
+
29
+ Underneath: joint video **and** soundtrack out of a single denoising pass, conditioned on an ordered list of image,
30
+ video and audio references, at **bfloat16 with no quantization anywhere**.
31
+
32
+ ## 🟢 Simple — the button does the lot
33
+
34
+ One press, and:
35
+
36
+ * **The description is written from your first reference picture.** Not from your words alone — the picture is shown
37
+ to a chat Space of your choosing, so what comes back describes the subject and the setting actually in front of it.
38
+ * **The structure goes on around it.** The prose is wrapped in the labelled sections below, the part MiniMax call
39
+ *critical to the quality of the final output* — so the one thing that most changes the result is no longer
40
+ something you have to remember to press.
41
+ * **The loras are chosen.** The shared library is searched, the handful that match your words are shortlisted, and
42
+ the two that fit best go into the slots at their own strengths.
43
+ * **The trigger words go into the prompt.** An adapter whose token is missing does nothing at all, and that is the
44
+ commonest reason one seems to be ignored.
45
+
46
+ Whatever it picked is listed underneath with the rest of the shortlist. **Tick a different one and it swaps** — the
47
+ slots refill, the old trigger words come out and the new ones go in. Two at a time is the limit, because stacking
48
+ more only means each one shows less.
49
+
50
+ Leave the Space box empty and the button still builds the structured prompt out of your own words.
51
+
52
+ ## 🔧 Everything — what this fork adds
53
 
54
  This Space is the denoising half of the `ref2va` task: the 61.73 GiB `transformer_ref` partition and the two
55
  autoencoders. The 62.14 GiB Qwen3-VL conditioner runs in
 
57
  the gradio API for every request — the same conditioner Space, and the same resident weights, that the keyframe half
58
  [`minimax-h3`](https://huggingface.co/spaces/multimodalart/minimax-h3) uses.
59
 
 
60
 
61
  **A structured prompt builder.** H3 was trained on the output of H3-Context-IR, a preprocessor that rewrites a plain
62
  request into labelled sections, and MiniMax's own model card calls that structure *critical to the quality of the
app.py CHANGED
@@ -2525,9 +2525,340 @@ visit, so a saved path would come back as a dead file.
2525
 
2526
  # gradio 6.0 takes `theme` and `css` on launch(), not here - passing them to the constructor
2527
  # only earns a warning and the styling is dropped.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2528
  with gr.Blocks(title="MiniMax-H3 - Custom lora + CivitAI, structured prompts, GPU cost, profiles, stitching") as demo:
2529
  gr.HTML(HERO)
2530
 
 
 
 
 
 
 
2531
  with gr.Row(equal_height=False):
2532
  with gr.Column(scale=5):
2533
 
@@ -2538,8 +2869,35 @@ with gr.Blocks(title="MiniMax-H3 - Custom lora + CivitAI, structured prompts, GP
2538
  lines=3,
2539
  value="The character walks through a neon-lit street in the rain, humming to themselves",
2540
  )
2541
- upsample = gr.Checkbox(label="✨ Upsample prompt", value=False)
2542
- with gr.Accordion("🎬 Structured prompt builder (what H3 was trained on)", open=False):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2543
  gr.Markdown(
2544
  "H3 was trained on the output of a preprocessor that rewrites a request into labelled "
2545
  "sections, and MiniMax call that structure *critical to the quality of the final output*. "
@@ -2566,7 +2924,8 @@ with gr.Blocks(title="MiniMax-H3 - Custom lora + CivitAI, structured prompts, GP
2566
  ir_button = gr.Button("🎬 Build the structured prompt", variant="secondary",
2567
  elem_id="ir-btn")
2568
 
2569
- with gr.Accordion("💡 Quick tags — click to add", open=False):
 
2570
  with gr.Row(elem_classes="chip-row"):
2571
  chip_buttons_a = [gr.Button(text, size="sm", variant="secondary") for text in CHIPS[:4]]
2572
  with gr.Row(elem_classes="chip-row"):
@@ -2634,7 +2993,7 @@ with gr.Blocks(title="MiniMax-H3 - Custom lora + CivitAI, structured prompts, GP
2634
 
2635
  # ---------------- the rest, in tabs ----------------
2636
  with gr.Tabs():
2637
- with gr.Tab(f"⭐ Custom lora ({LORA_SLOTS} slots)"):
2638
  gr.Markdown(LORA_HELP)
2639
  with gr.Accordion("🔍 Search CivitAI", open=False):
2640
  gr.Markdown(
@@ -2700,17 +3059,19 @@ with gr.Blocks(title="MiniMax-H3 - Custom lora + CivitAI, structured prompts, GP
2700
  duration = gr.Slider(
2701
  label="Duration (s)", minimum=MIN_DURATION, maximum=MAX_UI_DURATION, step=1, value=5
2702
  )
2703
- steps = gr.Slider(label="Steps", minimum=MIN_STEPS, maximum=40, step=1, value=28)
2704
- with gr.Row():
 
2705
  seed = gr.Number(label="Seed", value=42, precision=0, scale=3)
2706
  seed_dice = gr.Button("🎲 roll", variant="secondary", scale=1, elem_id="seed-dice")
2707
  randomize_seed = gr.Checkbox(
2708
  label="🎲 Randomize seed on every run",
2709
  value=True,
 
2710
  info="A new seed is drawn each time Generate is pressed, and lands in the box above.",
2711
  )
2712
 
2713
- with gr.Tab("💾 Profiles"):
2714
  gr.Markdown(PROFILE_HELP)
2715
  with gr.Row():
2716
  profile_picker = gr.Dropdown(
@@ -2753,7 +3114,8 @@ with gr.Blocks(title="MiniMax-H3 - Custom lora + CivitAI, structured prompts, GP
2753
  2, 8, value=3, step=1, label="How many clips in a row",
2754
  info="3 clips of 5 s ≈ a 15 second video.",
2755
  )
2756
- with gr.Accordion("✍️ A prompt per clip (optional)", open=False):
 
2757
  scene_prompts = gr.Textbox(
2758
  label="One line per clip", lines=8, max_lines=8,
2759
  placeholder=("line 1 = clip 1, line 2 = clip 2, and so on\n"
@@ -2988,6 +3350,103 @@ with gr.Blocks(title="MiniMax-H3 - Custom lora + CivitAI, structured prompts, GP
2988
  api_name=False,
2989
  )
2990
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2991
  lora_identify_btn.click(identify_loras, lora_references, lora_names, api_name=False)
2992
  for _field in lora_references:
2993
  _field.submit(identify_loras, lora_references, lora_names, api_name=False)
 
2525
 
2526
  # gradio 6.0 takes `theme` and `css` on launch(), not here - passing them to the constructor
2527
  # only earns a warning and the styling is dropped.
2528
+ # ---------------------------------------------------------------------------
2529
+ # Simple mode: a borrowed Space writes the prompt, the library is searched for loras
2530
+ # ---------------------------------------------------------------------------
2531
+
2532
+ # Set the Space variable EXPANDER_SPACE to point this somewhere else.
2533
+ REMOTE_SPACE = os.environ.get("EXPANDER_SPACE",
2534
+ "amisima/Qwen3.8-27B-Uncensored-Demo").strip()
2535
+ _REMOTE_CLIENTS = {}
2536
+
2537
+
2538
+ def _remote_reply_text(result):
2539
+ """Pull the assistant's words out of whatever shape the Space hands back - a string,
2540
+ a message dict, a list of them, or history pairs. Thinking bubbles carry a metadata
2541
+ title and are dropped, the same way the Space itself drops them."""
2542
+ if result is None:
2543
+ return ""
2544
+ if isinstance(result, str):
2545
+ return result.strip()
2546
+ if isinstance(result, dict):
2547
+ if (result.get("metadata") or {}).get("title"):
2548
+ return ""
2549
+ return _remote_reply_text(result.get("content", result.get("text", "")))
2550
+ if isinstance(result, (list, tuple)):
2551
+ parts = [part for part in (_remote_reply_text(item) for item in result) if part]
2552
+ return "\n".join(parts[-2:]) if len(parts) > 2 else "\n".join(parts)
2553
+ return str(result).strip()
2554
+
2555
+
2556
+ def _make_remote_client(space_id):
2557
+ """`Client(src, hf_token=...)` is what the documentation says and what the installed
2558
+ client may no longer accept - the argument has been spelled three ways across
2559
+ versions. Ask the signature which name it wants, fall back to sending the token as a
2560
+ header, and connect anonymously rather than not at all."""
2561
+ import inspect
2562
+
2563
+ from gradio_client import Client
2564
+
2565
+ token = os.environ.get("HF_TOKEN") or None
2566
+ if not token:
2567
+ return Client(space_id)
2568
+ try:
2569
+ accepted = list(inspect.signature(Client.__init__).parameters)
2570
+ except (TypeError, ValueError): # a C-level or wrapped __init__
2571
+ accepted = []
2572
+ for name in ("hf_token", "token", "auth_token", "api_key"):
2573
+ if name in accepted:
2574
+ try:
2575
+ return Client(space_id, **{name: token})
2576
+ except TypeError:
2577
+ break
2578
+ for extra in ({"headers": {"Authorization": f"Bearer {token}"}}, {}):
2579
+ try:
2580
+ return Client(space_id, **extra)
2581
+ except TypeError:
2582
+ continue
2583
+ return Client(space_id)
2584
+
2585
+
2586
+ def _remote_plan(client, payload, message):
2587
+ """Ask the Space what it actually exposes, instead of guessing at `/chat`. Endpoint
2588
+ names change with the Gradio version and with how the interface was built, and the
2589
+ parameter list that comes back is the only honest description of the call."""
2590
+ info = None
2591
+ for kwargs in ({"return_format": "dict", "print_info": False}, {"return_format": "dict"}):
2592
+ try:
2593
+ info = client.view_api(**kwargs)
2594
+ break
2595
+ except Exception: # noqa: BLE001
2596
+ continue
2597
+ named = (info or {}).get("named_endpoints") or {} if isinstance(info, dict) else {}
2598
+ seen = list(named)
2599
+ print(f"[big-space] endpoints: {seen or 'none reported'}", flush=True)
2600
+
2601
+ def rank(name):
2602
+ low = name.lower()
2603
+ if "chat" in low:
2604
+ return 0
2605
+ if any(word in low for word in ("respond", "submit", "predict", "run", "generate")):
2606
+ return 1
2607
+ return 2
2608
+
2609
+ plan = []
2610
+ for name in sorted(named, key=lambda n: (rank(n), n)):
2611
+ params = (named[name] or {}).get("parameters") or []
2612
+ if not params:
2613
+ continue
2614
+ args = []
2615
+ for index, param in enumerate(params):
2616
+ if index == 0:
2617
+ component = str(param.get("component", "")).lower()
2618
+ python_type = str((param.get("python_type") or {}).get("type", "")).lower()
2619
+ # A multimodal box wants {"text": ..., "files": [...]}; a plain one a string.
2620
+ args.append(payload if ("multimodal" in component or "dict" in python_type)
2621
+ else message)
2622
+ elif param.get("parameter_has_default"):
2623
+ args.append(param.get("parameter_default"))
2624
+ else:
2625
+ args.append(param.get("example_input"))
2626
+ plan.append((tuple(args), {"api_name": name}))
2627
+ return plan, seen
2628
+
2629
+
2630
+ def _remote_ask(space_id, message, image_path=None, temperature=0.7):
2631
+ """One turn on a Gradio chat Space. `gradio_client` ships with gradio itself, so
2632
+ there is nothing to add to requirements."""
2633
+ from gradio_client import handle_file
2634
+ from PIL import Image
2635
+
2636
+ client = _REMOTE_CLIENTS.get(space_id)
2637
+ if client is None:
2638
+ client = _make_remote_client(space_id)
2639
+ _REMOTE_CLIENTS[space_id] = client
2640
+
2641
+ files = []
2642
+ if image_path:
2643
+ try:
2644
+ picture = Image.open(image_path).convert("RGB")
2645
+ picture.thumbnail((1024, 1024))
2646
+ handle = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
2647
+ picture.save(handle.name, format="JPEG", quality=88)
2648
+ files.append(handle_file(handle.name))
2649
+ except Exception: # noqa: BLE001
2650
+ files = []
2651
+
2652
+ payload = {"text": message, "files": files}
2653
+ plan, seen = _remote_plan(client, payload, message)
2654
+ attempts = tuple(plan) + (
2655
+ # Only reached when the Space reports nothing useful about itself: a multimodal
2656
+ # ChatInterface with the sampling knobs exposed, the same without them, then a
2657
+ # plain text-in text-out Space.
2658
+ ((payload, "off", temperature, 0.95, 20), {"api_name": "/chat"}),
2659
+ ((payload,), {"api_name": "/chat"}),
2660
+ ((message,), {"api_name": "/chat"}),
2661
+ ((payload, "off", temperature, 0.95, 20), {}),
2662
+ ((message,), {}),
2663
+ )
2664
+ problems = []
2665
+ for args, kwargs in attempts:
2666
+ try:
2667
+ return _remote_reply_text(client.predict(*args, **kwargs))
2668
+ except Exception as error: # noqa: BLE001
2669
+ problems.append(f"{type(error).__name__}: {error}")
2670
+ print(f"[big-space] attempt failed - {problems[-1]}", flush=True)
2671
+ raise RuntimeError((" || ".join(problems[:2]) + f" || endpoints seen: {seen}")[:600])
2672
+
2673
+
2674
+ # The borrowed Space is a small model. Handing it the whole library and asking it to
2675
+ # choose is the one job that size of model does badly - it answers by position, repeats
2676
+ # itself and invents names that are not on the list. So the long list is cut down here,
2677
+ # by plain word matching, and the model is only ever asked to choose between a handful.
2678
+
2679
+ _PICK_NOISE = {
2680
+ "ltx", "ltxv", "wan", "wan22", "wan2", "i2v", "t2v", "lora", "loras", "video",
2681
+ "model", "safetensors", "merge", "rank", "version", "experimental", "alpha",
2682
+ "beta", "final", "test", "general", "suite", "helper", "enhancer", "motion",
2683
+ "nsfw", "sfw", "the", "and", "for", "with", "all", "one", "two", "pack",
2684
+ "high", "low", "only", "generic", "slider", "extreme", "ultimate", "booster",
2685
+ "minimax", "mmh3", "h3", "turbo", "step", "steps", "distill", "distilled",
2686
+ }
2687
+ _PROMPT_NOISE = {
2688
+ "the", "and", "with", "that", "this", "from", "into", "over", "under", "very",
2689
+ "while", "their", "there", "then", "them", "she", "her", "his", "him", "they",
2690
+ "are", "was", "were", "for", "not", "but", "you", "your", "its", "has", "have",
2691
+ "had", "one", "two", "all", "any", "out", "off", "been", "being", "more", "most",
2692
+ "some", "such", "than", "too", "just", "like", "also", "only", "own", "same",
2693
+ "each", "other", "how", "what", "when", "where", "which", "who", "will", "would",
2694
+ "can", "could", "should", "shot", "video", "clip", "camera", "scene", "frame",
2695
+ "light", "lighting", "photorealistic", "realistic", "detailed", "quality",
2696
+ "natural", "smooth", "consistent", "anatomy", "texture", "slowly", "gently",
2697
+ "towards", "toward", "keeps", "keeping", "looking", "looks", "moves", "moving",
2698
+ "sound", "audio", "music", "voice", "dialogue", "speaks", "saying", "picture",
2699
+ # Scenery and plain motion words. These turn up in lora titles as often as they
2700
+ # turn up in prompts, and matching on them is how "a man walking down a rainy
2701
+ # street" ends up wearing a lora about walking with no clothes on.
2702
+ "walk", "walks", "walking", "run", "runs", "running", "stand", "stands",
2703
+ "standing", "sit", "sits", "sitting", "slow", "fast", "quick", "turn", "turns",
2704
+ "turning", "move", "head", "hand", "hands", "body", "woman", "women", "girl",
2705
+ "girls", "man", "men", "guy", "lady", "hair", "face", "eyes", "mouth", "skin",
2706
+ "night", "morning", "street", "city", "room", "bed", "rain", "rainy", "water",
2707
+ "wind", "dress", "shirt", "clothes", "black", "white", "close", "wide", "front",
2708
+ "back", "side", "down", "smile", "smiling", "breathing", "leans", "leaning",
2709
+ "holds", "holding", "takes", "gives", "position", "movement", "style", "character",
2710
+ }
2711
+
2712
+
2713
+ def _pick_words(text, drop):
2714
+ out = set()
2715
+ for word in re.findall(r"[a-z0-9]+", str(text or "").lower()):
2716
+ if len(word) >= 4 and word not in drop and not word.isdigit():
2717
+ out.add(word)
2718
+ return out
2719
+
2720
+
2721
+ def _library_pool():
2722
+ """Every usable library entry, nsfw and ordinary together."""
2723
+ try:
2724
+ data = lora_library.load_library()
2725
+ except Exception as error: # noqa: BLE001
2726
+ print(f"[simple] the library could not be read: {error}", flush=True)
2727
+ return []
2728
+ pool = []
2729
+ for section in ("nsfw", "normal"):
2730
+ for item in (data.get(section) or []):
2731
+ if isinstance(item, dict) and item.get("url") and item.get("name"):
2732
+ pool.append(item)
2733
+ return pool
2734
+
2735
+
2736
+ def _shortlist_loras(prompt_text, pool, limit=6):
2737
+ """The few library entries whose name or trigger words are actually in the prompt."""
2738
+ asked = _pick_words(prompt_text, _PROMPT_NOISE)
2739
+ if not asked:
2740
+ return []
2741
+ scored = []
2742
+ for item in pool:
2743
+ hits = len(asked & _pick_words(item.get("name"), _PICK_NOISE))
2744
+ # A trigger word written out in the prompt is a much stronger signal than a
2745
+ # word that happens to appear in a title, so it counts double.
2746
+ for trigger in str(item.get("trigger") or "").split(","):
2747
+ trigger = trigger.strip().lower()
2748
+ if trigger and trigger in str(prompt_text or "").lower():
2749
+ hits += 2
2750
+ if hits:
2751
+ scored.append((hits, item))
2752
+ scored.sort(key=lambda pair: -pair[0])
2753
+ return [item for _score, item in scored[:limit]]
2754
+
2755
+
2756
+ def _pick_label(index, item):
2757
+ text = f"{index + 1}. {item.get('name', '')}"
2758
+ if not str(item.get("trigger") or "").strip():
2759
+ text += " \u00b7 no trigger words saved"
2760
+ return text[:200]
2761
+
2762
+
2763
+ def _ask_which_loras(space_id, prompt_text, shortlist):
2764
+ """One small question: which of these few, by number. Nothing else is asked."""
2765
+ space = str(space_id or "").strip()
2766
+ if not space or not shortlist:
2767
+ return []
2768
+ lines = []
2769
+ for index, item in enumerate(shortlist, start=1):
2770
+ line = f"{index}. {item.get('name', '')}"
2771
+ trigger = str(item.get("trigger") or "").strip()
2772
+ if trigger:
2773
+ line += f" (words: {trigger})"
2774
+ lines.append(line)
2775
+ message = (
2776
+ "Below is a video prompt and a numbered list of lora files.\n\n"
2777
+ f"PROMPT: {prompt_text}\n\nLIST:\n" + "\n".join(lines) + "\n\n"
2778
+ "Reply with the numbers of at most two entries from the list that match what "
2779
+ "the prompt describes, separated by a comma. Reply with the word NONE if none "
2780
+ "of them match. Write nothing else - no explanation, no names, only numbers."
2781
+ )
2782
+ try:
2783
+ reply = _remote_ask(space, message)
2784
+ except Exception as error: # noqa: BLE001
2785
+ print(f"[simple] the Space did not answer the pick question: {error}", flush=True)
2786
+ return []
2787
+ if "none" in str(reply or "").lower() and not re.search(r"\d", str(reply or "")):
2788
+ return []
2789
+ picked = []
2790
+ for found in re.findall(r"\d+", str(reply or "")):
2791
+ index = int(found) - 1
2792
+ if 0 <= index < len(shortlist) and index not in picked:
2793
+ picked.append(index)
2794
+ return picked[:2]
2795
+
2796
+
2797
+ def _write_prompt(space_id, wanted, image_path):
2798
+ """The description H3 wants, written from the reference picture. Plain prose only -
2799
+ the structured sections are put around it afterwards by the builder this Space
2800
+ already has, which is the part MiniMax call critical to the result."""
2801
+ space = str(space_id or "").strip()
2802
+ if not space:
2803
+ return "", "no Space in the box, so your own words were kept."
2804
+ message = (
2805
+ "Write a single paragraph of about 120 words describing a short video clip, for "
2806
+ "a video model. Describe what is in the picture and what moves: the subject, "
2807
+ "the setting, the action, the light. Present tense, plain prose, no headings, "
2808
+ "no lists, no camera jargon, no preamble - only the paragraph itself.\n\n"
2809
+ f"What is wanted: {wanted}"
2810
+ )
2811
+ try:
2812
+ reply = _remote_ask(space, message, image_path)
2813
+ except Exception as error: # noqa: BLE001
2814
+ return "", f"{space} did not answer, so your own words were kept: {str(error)[:200]}"
2815
+ written = " ".join(str(reply or "").split())
2816
+ if len(written) < 40:
2817
+ return "", f"{space} answered with almost nothing, so your own words were kept."
2818
+ return written, f"written by {space} ({len(written.split())} words)"
2819
+
2820
+
2821
+ def _add_triggers(prompt_text, items):
2822
+ """A lora whose trigger word is missing from the prompt does nothing at all."""
2823
+ text = str(prompt_text or "")
2824
+ missing = []
2825
+ for item in items:
2826
+ # Only the first few. Some entries carry eight phrases and pasting all of them
2827
+ # in buries the sentence the model is meant to be following.
2828
+ for trigger in str(item.get("trigger") or "").split(",")[:3]:
2829
+ trigger = trigger.strip()
2830
+ if trigger and trigger.lower() not in text.lower() and trigger not in missing:
2831
+ missing.append(trigger)
2832
+ if not missing:
2833
+ return text
2834
+ return f"{text.rstrip().rstrip(',')}, {', '.join(missing)}".strip(" ,")
2835
+
2836
+
2837
+ def _retune_triggers(prompt_text, shortlist, picks):
2838
+ """Swapping a lora has to take its words back out of the prompt as well."""
2839
+ text = str(prompt_text or "")
2840
+ kept = {str(item.get("url")) for item in picks}
2841
+ for item in shortlist or []:
2842
+ if str(item.get("url")) in kept:
2843
+ continue
2844
+ for trigger in str(item.get("trigger") or "").split(","):
2845
+ trigger = trigger.strip()
2846
+ if trigger:
2847
+ text = re.sub(r"(,\s*)?" + re.escape(trigger) + r"(?=\s*(,|$))",
2848
+ "", text, flags=re.I)
2849
+ text = re.sub(r"\s*,\s*,", ",", text).strip(" ,")
2850
+ return _add_triggers(text, picks)
2851
+
2852
+
2853
  with gr.Blocks(title="MiniMax-H3 - Custom lora + CivitAI, structured prompts, GPU cost, profiles, stitching") as demo:
2854
  gr.HTML(HERO)
2855
 
2856
+ ui_mode = gr.Radio(
2857
+ [("🟢 Simple — one button writes the prompt and picks the loras", "simple"),
2858
+ ("🔧 Everything — every control this Space has", "pro")],
2859
+ value="simple", show_label=False,
2860
+ )
2861
+
2862
  with gr.Row(equal_height=False):
2863
  with gr.Column(scale=5):
2864
 
 
2869
  lines=3,
2870
  value="The character walks through a neon-lit street in the rain, humming to themselves",
2871
  )
2872
+ upsample = gr.Checkbox(label="✨ Upsample prompt", value=False, visible=False)
2873
+
2874
+ with gr.Group(elem_classes="panel") as basic_panel:
2875
+ gr.Markdown(
2876
+ "**Picture in, a few words above, one press.** The description is "
2877
+ "written from your first reference picture, wrapped in the labelled "
2878
+ "sections H3 was trained on, and the library is searched for loras "
2879
+ "that match it. What it picks is listed underneath and can be "
2880
+ "changed with a tick."
2881
+ )
2882
+ basic_space = gr.Textbox(
2883
+ value=REMOTE_SPACE, label="🛰️ Big model Space", lines=1,
2884
+ placeholder="owner/space-name",
2885
+ info="A chat Space of your own, shown your first picture. Empty it "
2886
+ "and only the structured builder runs, on your own words.",
2887
+ )
2888
+ basic_btn = gr.Button("✨ Do all of it", variant="primary",
2889
+ elem_id="ir-btn")
2890
+ basic_status = gr.Markdown("Nothing done yet.")
2891
+ basic_pick = gr.CheckboxGroup(
2892
+ choices=[], value=[], visible=False,
2893
+ label="Loras it chose — tick another one to swap",
2894
+ info="Two at a time is the limit. Every tick refills the slots and "
2895
+ "puts the trigger words into the prompt.",
2896
+ )
2897
+ basic_shortlist = gr.State([])
2898
+
2899
+ with gr.Accordion("🎬 Structured prompt builder (what H3 was trained on)",
2900
+ open=False, visible=False) as pro_builder:
2901
  gr.Markdown(
2902
  "H3 was trained on the output of a preprocessor that rewrites a request into labelled "
2903
  "sections, and MiniMax call that structure *critical to the quality of the final output*. "
 
2924
  ir_button = gr.Button("🎬 Build the structured prompt", variant="secondary",
2925
  elem_id="ir-btn")
2926
 
2927
+ with gr.Accordion("💡 Quick tags — click to add", open=False,
2928
+ visible=False) as pro_chips:
2929
  with gr.Row(elem_classes="chip-row"):
2930
  chip_buttons_a = [gr.Button(text, size="sm", variant="secondary") for text in CHIPS[:4]]
2931
  with gr.Row(elem_classes="chip-row"):
 
2993
 
2994
  # ---------------- the rest, in tabs ----------------
2995
  with gr.Tabs():
2996
+ with gr.Tab(f"⭐ Custom lora ({LORA_SLOTS} slots)", visible=False) as pro_loratab:
2997
  gr.Markdown(LORA_HELP)
2998
  with gr.Accordion("🔍 Search CivitAI", open=False):
2999
  gr.Markdown(
 
3059
  duration = gr.Slider(
3060
  label="Duration (s)", minimum=MIN_DURATION, maximum=MAX_UI_DURATION, step=1, value=5
3061
  )
3062
+ steps = gr.Slider(label="Steps", minimum=MIN_STEPS, maximum=40, step=1,
3063
+ value=28, visible=False)
3064
+ with gr.Row(visible=False) as pro_seed:
3065
  seed = gr.Number(label="Seed", value=42, precision=0, scale=3)
3066
  seed_dice = gr.Button("🎲 roll", variant="secondary", scale=1, elem_id="seed-dice")
3067
  randomize_seed = gr.Checkbox(
3068
  label="🎲 Randomize seed on every run",
3069
  value=True,
3070
+ visible=False,
3071
  info="A new seed is drawn each time Generate is pressed, and lands in the box above.",
3072
  )
3073
 
3074
+ with gr.Tab("💾 Profiles", visible=False) as pro_profiles:
3075
  gr.Markdown(PROFILE_HELP)
3076
  with gr.Row():
3077
  profile_picker = gr.Dropdown(
 
3114
  2, 8, value=3, step=1, label="How many clips in a row",
3115
  info="3 clips of 5 s ≈ a 15 second video.",
3116
  )
3117
+ with gr.Accordion("✍️ A prompt per clip (optional)", open=False,
3118
+ visible=False) as pro_perclip:
3119
  scene_prompts = gr.Textbox(
3120
  label="One line per clip", lines=8, max_lines=8,
3121
  placeholder=("line 1 = clip 1, line 2 = clip 2, and so on\n"
 
3350
  api_name=False,
3351
  )
3352
 
3353
+ # ---------------------------------------------------------- simple mode
3354
+
3355
+ def _slot_updates(items):
3356
+ """Fill the first slots with these entries and empty the rest of them.
3357
+
3358
+ Emptying matters - without it yesterday's pick stays loaded underneath today's.
3359
+ """
3360
+ updates = []
3361
+ for index in range(LORA_SLOTS):
3362
+ item = items[index] if index < len(items) else None
3363
+ if item is None:
3364
+ updates.append(gr.update(value=""))
3365
+ updates.append(gr.update())
3366
+ else:
3367
+ updates.append(gr.update(value=str(item.get("url") or "")))
3368
+ updates.append(gr.update(value=float(item.get("strength") or DEFAULT_LORA_SCALE)))
3369
+ return updates
3370
+
3371
+ def _basic_note(picks, extra=""):
3372
+ if not picks:
3373
+ return "No lora added — the prompt is written, that is all." + extra
3374
+ names = ", ".join(str(item.get("name", ""))[:60] for item in picks)
3375
+ blind = [item for item in picks if not str(item.get("trigger") or "").strip()]
3376
+ if blind:
3377
+ extra += (" One of them has no trigger words saved, so it may do nothing; "
3378
+ "tick a different one if the clip ignores it.")
3379
+ return f"✅ **Loras in:** {names}.{extra}"
3380
+
3381
+ def _basic_run(wanted, space_id, first_image, shot, camera, sound, music,
3382
+ speaker, dialogue, references, progress=gr.Progress()):
3383
+ progress(0.1, desc="asking the Space (a sleeping one takes a minute to wake)")
3384
+ written, note = _write_prompt(space_id, wanted, first_image)
3385
+ plain = written or str(wanted or "")
3386
+ # The labelled sections go on last, around whatever prose we ended up with.
3387
+ built = build_ir_prompt(plain, shot, camera, sound, music, speaker, dialogue,
3388
+ references)
3389
+ progress(0.6, desc="searching the library")
3390
+ shortlist = _shortlist_loras(built, _library_pool())
3391
+ if not shortlist:
3392
+ return tuple(
3393
+ [gr.update(value=built), gr.update(choices=[], value=[], visible=False), [],
3394
+ f"✅ Prompt built — {note}. Nothing in the library matched these words."]
3395
+ + _slot_updates([])
3396
+ )
3397
+ progress(0.8, desc="asking which loras fit")
3398
+ chosen = _ask_which_loras(space_id, built, shortlist) or [0]
3399
+ picks = [shortlist[index] for index in chosen]
3400
+ labels = [_pick_label(index, item) for index, item in enumerate(shortlist)]
3401
+ return tuple(
3402
+ [gr.update(value=_retune_triggers(built, shortlist, picks)),
3403
+ gr.update(choices=labels, value=[labels[i] for i in chosen], visible=True),
3404
+ shortlist,
3405
+ _basic_note(picks, f" Prompt {note}, picked out of {len(shortlist)} that matched.")]
3406
+ + _slot_updates(picks)
3407
+ )
3408
+
3409
+ def _basic_swap(ticked, shortlist, prompt_text):
3410
+ labels = [_pick_label(index, item) for index, item in enumerate(shortlist or [])]
3411
+ wanted = [labels.index(label) for label in (ticked or []) if label in labels]
3412
+ extra = " Two at a time is the limit, so the rest were left out." if len(wanted) > 2 else ""
3413
+ picks = [shortlist[index] for index in wanted[:2]]
3414
+ return tuple(
3415
+ [gr.update(value=_retune_triggers(prompt_text, shortlist, picks)),
3416
+ _basic_note(picks, extra)]
3417
+ + _slot_updates(picks)
3418
+ )
3419
+
3420
+ _BASIC_SLOTS = [field for pair in zip(lora_references, lora_scales) for field in pair]
3421
+
3422
+ basic_btn.click(
3423
+ _basic_run,
3424
+ [prompt, basic_space, images[0], ir_shot, ir_camera, ir_sound, ir_music,
3425
+ ir_speaker, ir_dialogue, ir_references],
3426
+ [prompt, basic_pick, basic_shortlist, basic_status] + _BASIC_SLOTS,
3427
+ api_name=False,
3428
+ )
3429
+
3430
+ # `.input` fires only when the person ticks something; older Gradio has only
3431
+ # `.change`, which also fires when the button above fills the box in. Either is
3432
+ # safe here - a second pass over the same ticks lands on the same answer.
3433
+ _tick_event = getattr(basic_pick, "input", None) or basic_pick.change
3434
+ _tick_event(
3435
+ _basic_swap,
3436
+ [basic_pick, basic_shortlist, prompt],
3437
+ [prompt, basic_status] + _BASIC_SLOTS,
3438
+ api_name=False,
3439
+ )
3440
+
3441
+ _PRO_ONLY = [pro_builder, pro_chips, upsample, pro_loratab, pro_profiles,
3442
+ steps, pro_seed, randomize_seed, pro_perclip]
3443
+
3444
+ def _switch_mode(mode):
3445
+ pro = str(mode) == "pro"
3446
+ return [gr.update(visible=pro) for _ in _PRO_ONLY] + [gr.update(visible=not pro)]
3447
+
3448
+ ui_mode.change(_switch_mode, [ui_mode], _PRO_ONLY + [basic_panel], api_name=False)
3449
+
3450
  lora_identify_btn.click(identify_loras, lora_references, lora_names, api_name=False)
3451
  for _field in lora_references:
3452
  _field.submit(identify_loras, lora_references, lora_names, api_name=False)