Spaces:
Running on Zero
Running on Zero
Upload app.py
Browse files
app.py
CHANGED
|
@@ -1456,6 +1456,124 @@ def identify_loras(*references):
|
|
| 1456 |
return " \n".join(lines)
|
| 1457 |
|
| 1458 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1459 |
def _fill_lora_slots(files, *current):
|
| 1460 |
"""Drop `.safetensors` files on the uploader and their paths land in the first free slots, so a local adapter
|
| 1461 |
needs no typing at all."""
|
|
@@ -1653,7 +1771,7 @@ CSS = """
|
|
| 1653 |
border-radius: 14px !important;
|
| 1654 |
box-shadow: 0 8px 22px rgba(29, 78, 216, .25);
|
| 1655 |
}
|
| 1656 |
-
#lora-identify, #profile-refresh, #seed-dice, #turbo-btn, #profile-save, #profile-load, #profile-delete {
|
| 1657 |
min-height: 42px !important; border-radius: 12px !important; font-weight: 600 !important;
|
| 1658 |
}
|
| 1659 |
|
|
@@ -1684,7 +1802,7 @@ THEME = gr.themes.Soft(primary_hue="blue", secondary_hue="cyan", neutral_hue="sl
|
|
| 1684 |
|
| 1685 |
HERO = """
|
| 1686 |
<div id="h3-hero">
|
| 1687 |
-
<h1>MiniMax-H3 · reference → video + soundtrack · Custom lora + CivitAI
|
| 1688 |
<p>33B model generating video and a fully synchronized soundtrack (ambience, foley, speech) from your own subject,
|
| 1689 |
voice or camera move.
|
| 1690 |
<a href="https://huggingface.co/MiniMaxAI/MiniMax-H3" target="_blank" rel="noopener">model</a> ·
|
|
@@ -1692,7 +1810,7 @@ HERO = """
|
|
| 1692 |
<a href="https://huggingface.co/spaces/multimodalart/minimax-h3" target="_blank" rel="noopener">text / image to video</a></p>
|
| 1693 |
<div class="pills">
|
| 1694 |
<span>33B</span><span>joint video + audio</span><span>Turbo lora: 4–8 steps</span>
|
| 1695 |
-
<span>ComfyUI lora accepted</span><span>up to 9 references</span><span>5 custom lora slots</span><span>named profiles</span><span>CivitAI links</span><span>kohya + LoKr auto-convert</span><span>GPU cost estimate</span><span>clip stitching with audio</span>
|
| 1696 |
</div>
|
| 1697 |
</div>
|
| 1698 |
"""
|
|
@@ -1807,6 +1925,28 @@ with gr.Blocks(title="MiniMax-H3 - Custom lora + CivitAI, GPU cost, profiles, cl
|
|
| 1807 |
with gr.Tabs():
|
| 1808 |
with gr.Tab(f"⭐ Custom lora ({LORA_SLOTS} slots)"):
|
| 1809 |
gr.Markdown(LORA_HELP)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1810 |
lora_references, lora_scales = [], []
|
| 1811 |
for slot in range(LORA_SLOTS):
|
| 1812 |
with gr.Row():
|
|
@@ -1889,6 +2029,14 @@ with gr.Blocks(title="MiniMax-H3 - Custom lora + CivitAI, GPU cost, profiles, cl
|
|
| 1889 |
with gr.Column(scale=6):
|
| 1890 |
with gr.Group(elem_classes="panel"):
|
| 1891 |
result = gr.Video(label="🎞️ Video + soundtrack", height=560)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1892 |
# An output, so it can be revealed only for a request that asked for a rewrite.
|
| 1893 |
with gr.Accordion("Upsampled prompt", open=False, visible=False) as upsampled_panel:
|
| 1894 |
upsampled = gr.Textbox(show_label=False, lines=8, interactive=False)
|
|
@@ -1974,6 +2122,38 @@ with gr.Blocks(title="MiniMax-H3 - Custom lora + CivitAI, GPU cost, profiles, cl
|
|
| 1974 |
show_progress="hidden", api_name=False,
|
| 1975 |
)
|
| 1976 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1977 |
merge_add_btn.click(
|
| 1978 |
add_to_queue, [result, merge_queue, merge_name],
|
| 1979 |
[merged_video, merged_file, merge_queue, merge_status], api_name=False,
|
|
|
|
| 1456 |
return " \n".join(lines)
|
| 1457 |
|
| 1458 |
|
| 1459 |
+
# ------------------------------------------------------------------------------------------------------------------
|
| 1460 |
+
# Continuing a scene, and searching CivitAI
|
| 1461 |
+
# ------------------------------------------------------------------------------------------------------------------
|
| 1462 |
+
|
| 1463 |
+
|
| 1464 |
+
def last_frame_of(video_path) -> str:
|
| 1465 |
+
"""Write the final frame of a clip to a PNG and return its path, so it can be dropped straight into an image
|
| 1466 |
+
slot. A few frames back from the very end on purpose: the last frame is often a duplicate or half-written, and a
|
| 1467 |
+
soft still starts the next clip badly."""
|
| 1468 |
+
if not video_path or not os.path.exists(str(video_path)):
|
| 1469 |
+
raise gr.Error("Generate a video first - there is nothing to continue from.")
|
| 1470 |
+
|
| 1471 |
+
import av
|
| 1472 |
+
from PIL import Image
|
| 1473 |
+
|
| 1474 |
+
frames = []
|
| 1475 |
+
with av.open(str(video_path)) as container:
|
| 1476 |
+
stream = container.streams.video[0]
|
| 1477 |
+
stream.thread_type = "AUTO"
|
| 1478 |
+
for frame in container.decode(stream):
|
| 1479 |
+
frames.append(frame)
|
| 1480 |
+
if len(frames) > 4:
|
| 1481 |
+
frames.pop(0)
|
| 1482 |
+
|
| 1483 |
+
if not frames:
|
| 1484 |
+
raise gr.Error("That video has no readable frames.")
|
| 1485 |
+
|
| 1486 |
+
chosen = frames[max(0, len(frames) - 3)]
|
| 1487 |
+
directory = os.path.join(tempfile.gettempdir(), "continuations")
|
| 1488 |
+
os.makedirs(directory, exist_ok=True)
|
| 1489 |
+
path = os.path.join(directory, f"frame_{int(time.time() * 1000)}.png")
|
| 1490 |
+
Image.fromarray(chosen.to_ndarray(format="rgb24")).save(path)
|
| 1491 |
+
return path
|
| 1492 |
+
|
| 1493 |
+
|
| 1494 |
+
def stage_extension(video_path, queue, name_hint):
|
| 1495 |
+
"""Before a continuation runs: park the finished clip in the merge queue and hand its last frame back as the new
|
| 1496 |
+
first reference."""
|
| 1497 |
+
frame = last_frame_of(video_path)
|
| 1498 |
+
merged, merged_file, queue, status = add_to_queue(video_path, queue, name_hint)
|
| 1499 |
+
return frame, None, merged, merged_file, queue, status
|
| 1500 |
+
|
| 1501 |
+
|
| 1502 |
+
H3_BASE_MODELS = ["MiniMax H3", "(any base model)"]
|
| 1503 |
+
|
| 1504 |
+
|
| 1505 |
+
def civitai_search(query, base_model, want_nsfw, limit=20):
|
| 1506 |
+
"""Search CivitAI for lora. `/api/v1/models` embeds each model's versions, files and trigger words, so one call
|
| 1507 |
+
gives everything a slot needs. Returns `(readable list, dropdown update, {label: url})`."""
|
| 1508 |
+
import requests
|
| 1509 |
+
|
| 1510 |
+
query = (query or "").strip()
|
| 1511 |
+
if not query:
|
| 1512 |
+
return "Type something to search for.", gr.update(choices=[], value=None), {}
|
| 1513 |
+
|
| 1514 |
+
params = {"query": query, "types": "LORA", "limit": int(limit), "sort": "Most Downloaded"}
|
| 1515 |
+
if base_model and base_model != "(any base model)":
|
| 1516 |
+
params["baseModels"] = base_model
|
| 1517 |
+
if want_nsfw:
|
| 1518 |
+
params["nsfw"] = "true"
|
| 1519 |
+
|
| 1520 |
+
headers = {"User-Agent": "Mozilla/5.0"}
|
| 1521 |
+
token = os.environ.get("CIVITAI_TOKEN", "").strip()
|
| 1522 |
+
if token:
|
| 1523 |
+
headers["Authorization"] = f"Bearer {token}"
|
| 1524 |
+
|
| 1525 |
+
try:
|
| 1526 |
+
response = requests.get("https://civitai.com/api/v1/models", params=params, headers=headers, timeout=30)
|
| 1527 |
+
response.raise_for_status()
|
| 1528 |
+
items = response.json().get("items") or []
|
| 1529 |
+
except Exception as error: # noqa: BLE001
|
| 1530 |
+
return f"Search failed: `{type(error).__name__}: {error}`", gr.update(choices=[], value=None), {}
|
| 1531 |
+
|
| 1532 |
+
if not items:
|
| 1533 |
+
return ("Nothing found. Try fewer words, or set the base model to *(any base model)*.",
|
| 1534 |
+
gr.update(choices=[], value=None), {})
|
| 1535 |
+
|
| 1536 |
+
mapping, lines, choices = {}, [], []
|
| 1537 |
+
for item in items:
|
| 1538 |
+
model_name = item.get("name") or "?"
|
| 1539 |
+
creator = (item.get("creator") or {}).get("username") or "?"
|
| 1540 |
+
downloads = (item.get("stats") or {}).get("downloadCount") or 0
|
| 1541 |
+
|
| 1542 |
+
for version in (item.get("modelVersions") or [])[:3]:
|
| 1543 |
+
version_name = version.get("name") or ""
|
| 1544 |
+
words = [w for w in (version.get("trainedWords") or []) if w]
|
| 1545 |
+
for entry in (version.get("files") or []):
|
| 1546 |
+
name = entry.get("name") or ""
|
| 1547 |
+
if not name.lower().endswith(".safetensors"):
|
| 1548 |
+
continue
|
| 1549 |
+
url = f"https://civitai.com/api/download/models/{version.get('id')}?fileId={entry.get('id')}"
|
| 1550 |
+
size = float(entry.get("sizeKB") or 0) / 1024
|
| 1551 |
+
label = f"{model_name} · {version_name} · {name}"[:150]
|
| 1552 |
+
mapping[label] = url
|
| 1553 |
+
choices.append(label)
|
| 1554 |
+
line = f"**{model_name}** · {version_name} · {name} \n<sub>{size:.0f} MB · {downloads} downloads · by {creator}"
|
| 1555 |
+
if words:
|
| 1556 |
+
line += f" · triggers: {', '.join(words[:5])}"
|
| 1557 |
+
lines.append(line + "</sub>")
|
| 1558 |
+
|
| 1559 |
+
if not choices:
|
| 1560 |
+
return "Found models, but none with a `.safetensors` file.", gr.update(choices=[], value=None), {}
|
| 1561 |
+
|
| 1562 |
+
return " \n".join(lines[:40]), gr.update(choices=choices, value=choices[0]), mapping
|
| 1563 |
+
|
| 1564 |
+
|
| 1565 |
+
def put_in_slot(label, mapping, slot):
|
| 1566 |
+
"""Write the chosen result's download link into one lora slot, leaving the others alone."""
|
| 1567 |
+
updates = [gr.update() for _ in range(LORA_SLOTS)]
|
| 1568 |
+
url = (mapping or {}).get(label)
|
| 1569 |
+
if not url:
|
| 1570 |
+
return [*updates, "Search and pick a file first."]
|
| 1571 |
+
|
| 1572 |
+
index = int(str(slot).split()[-1]) - 1
|
| 1573 |
+
updates[index] = gr.update(value=url)
|
| 1574 |
+
return [*updates, f"**Put into {slot}** — {label}"]
|
| 1575 |
+
|
| 1576 |
+
|
| 1577 |
def _fill_lora_slots(files, *current):
|
| 1578 |
"""Drop `.safetensors` files on the uploader and their paths land in the first free slots, so a local adapter
|
| 1579 |
needs no typing at all."""
|
|
|
|
| 1771 |
border-radius: 14px !important;
|
| 1772 |
box-shadow: 0 8px 22px rgba(29, 78, 216, .25);
|
| 1773 |
}
|
| 1774 |
+
#search-btn, #search-put, #extend-btn, #lora-identify, #profile-refresh, #seed-dice, #turbo-btn, #profile-save, #profile-load, #profile-delete {
|
| 1775 |
min-height: 42px !important; border-radius: 12px !important; font-weight: 600 !important;
|
| 1776 |
}
|
| 1777 |
|
|
|
|
| 1802 |
|
| 1803 |
HERO = """
|
| 1804 |
<div id="h3-hero">
|
| 1805 |
+
<h1>MiniMax-H3 · reference → video + soundtrack · Custom lora + CivitAI search, scene continuation, GPU cost, profiles, clip stitching</h1>
|
| 1806 |
<p>33B model generating video and a fully synchronized soundtrack (ambience, foley, speech) from your own subject,
|
| 1807 |
voice or camera move.
|
| 1808 |
<a href="https://huggingface.co/MiniMaxAI/MiniMax-H3" target="_blank" rel="noopener">model</a> ·
|
|
|
|
| 1810 |
<a href="https://huggingface.co/spaces/multimodalart/minimax-h3" target="_blank" rel="noopener">text / image to video</a></p>
|
| 1811 |
<div class="pills">
|
| 1812 |
<span>33B</span><span>joint video + audio</span><span>Turbo lora: 4–8 steps</span>
|
| 1813 |
+
<span>ComfyUI lora accepted</span><span>up to 9 references</span><span>5 custom lora slots</span><span>named profiles</span><span>CivitAI search + links</span><span>scene continuation</span><span>kohya + LoKr auto-convert</span><span>GPU cost estimate</span><span>clip stitching with audio</span>
|
| 1814 |
</div>
|
| 1815 |
</div>
|
| 1816 |
"""
|
|
|
|
| 1925 |
with gr.Tabs():
|
| 1926 |
with gr.Tab(f"⭐ Custom lora ({LORA_SLOTS} slots)"):
|
| 1927 |
gr.Markdown(LORA_HELP)
|
| 1928 |
+
with gr.Accordion("🔍 Search CivitAI", open=False):
|
| 1929 |
+
gr.Markdown(
|
| 1930 |
+
"Search CivitAI without leaving the Space, then drop a result straight into a slot. "
|
| 1931 |
+
"Each hit shows its size, downloads and trigger words."
|
| 1932 |
+
)
|
| 1933 |
+
with gr.Row():
|
| 1934 |
+
search_query = gr.Textbox(label="Search", placeholder="e.g. dance, rain, camera move",
|
| 1935 |
+
scale=3)
|
| 1936 |
+
search_base = gr.Dropdown(H3_BASE_MODELS, value=H3_BASE_MODELS[0], label="Base model",
|
| 1937 |
+
allow_custom_value=True, scale=2)
|
| 1938 |
+
with gr.Row():
|
| 1939 |
+
search_nsfw = gr.Checkbox(value=True, label="Include NSFW results")
|
| 1940 |
+
search_btn = gr.Button("🔍 Search", variant="secondary", elem_id="search-btn")
|
| 1941 |
+
search_results = gr.Markdown("No search yet.")
|
| 1942 |
+
search_pick = gr.Dropdown(choices=[], label="Pick a file")
|
| 1943 |
+
with gr.Row():
|
| 1944 |
+
search_slot = gr.Dropdown([f"lora {i}" for i in range(1, LORA_SLOTS + 1)],
|
| 1945 |
+
value="lora 1", label="Into slot", scale=2)
|
| 1946 |
+
search_put_btn = gr.Button("⬇️ Put it in", variant="primary", scale=1,
|
| 1947 |
+
elem_id="search-put")
|
| 1948 |
+
search_state = gr.State({})
|
| 1949 |
+
|
| 1950 |
lora_references, lora_scales = [], []
|
| 1951 |
for slot in range(LORA_SLOTS):
|
| 1952 |
with gr.Row():
|
|
|
|
| 2029 |
with gr.Column(scale=6):
|
| 2030 |
with gr.Group(elem_classes="panel"):
|
| 2031 |
result = gr.Video(label="🎞️ Video + soundtrack", height=560)
|
| 2032 |
+
extend_btn = gr.Button("➕ Continue the scene (one more clip, joined on)",
|
| 2033 |
+
variant="secondary", elem_id="extend-btn")
|
| 2034 |
+
gr.Markdown(
|
| 2035 |
+
"Takes the last frame of the clip above, makes it the first reference, generates again with "
|
| 2036 |
+
"the settings untouched, and joins the two into one file — soundtrack included. Press it again "
|
| 2037 |
+
"for a third clip. Each press costs one normal generation; the joining is free.",
|
| 2038 |
+
elem_classes="turbo-blurb",
|
| 2039 |
+
)
|
| 2040 |
# An output, so it can be revealed only for a request that asked for a rewrite.
|
| 2041 |
with gr.Accordion("Upsampled prompt", open=False, visible=False) as upsampled_panel:
|
| 2042 |
upsampled = gr.Textbox(show_label=False, lines=8, interactive=False)
|
|
|
|
| 2122 |
show_progress="hidden", api_name=False,
|
| 2123 |
)
|
| 2124 |
|
| 2125 |
+
# Continuing a scene: park the finished clip, hand its last frame to the first image slot, generate from it,
|
| 2126 |
+
# then join the new clip on. `request` starts with the prompt and the first image, so the second step reads
|
| 2127 |
+
# the still the first step just wrote.
|
| 2128 |
+
extend_btn.click(
|
| 2129 |
+
fn=stage_extension,
|
| 2130 |
+
inputs=[result, merge_queue, merge_name],
|
| 2131 |
+
outputs=[images[0], result, merged_video, merged_file, merge_queue, merge_status],
|
| 2132 |
+
api_name=False,
|
| 2133 |
+
).then(
|
| 2134 |
+
roll_seed, [randomize_seed, seed], seed, show_progress="hidden", api_name=False,
|
| 2135 |
+
).then(
|
| 2136 |
+
generate, request, [result, upsampled, upsampled_panel], api_name=False,
|
| 2137 |
+
).then(
|
| 2138 |
+
auto_queue, [result, auto_merge, merge_queue, merge_name],
|
| 2139 |
+
[merged_video, merged_file, merge_queue, merge_status],
|
| 2140 |
+
show_progress="hidden", api_name=False,
|
| 2141 |
+
)
|
| 2142 |
+
|
| 2143 |
+
# Searching CivitAI, and dropping a result into a slot.
|
| 2144 |
+
search_btn.click(
|
| 2145 |
+
civitai_search, [search_query, search_base, search_nsfw],
|
| 2146 |
+
[search_results, search_pick, search_state], api_name=False,
|
| 2147 |
+
)
|
| 2148 |
+
search_query.submit(
|
| 2149 |
+
civitai_search, [search_query, search_base, search_nsfw],
|
| 2150 |
+
[search_results, search_pick, search_state], api_name=False,
|
| 2151 |
+
)
|
| 2152 |
+
search_put_btn.click(
|
| 2153 |
+
put_in_slot, [search_pick, search_state, search_slot],
|
| 2154 |
+
[*lora_references, search_results], api_name=False,
|
| 2155 |
+
)
|
| 2156 |
+
|
| 2157 |
merge_add_btn.click(
|
| 2158 |
add_to_queue, [result, merge_queue, merge_name],
|
| 2159 |
[merged_video, merged_file, merge_queue, merge_status], api_name=False,
|