Spaces:
Running on Zero
Running on Zero
FastH3-Omni: Gradio 6 reference composer UI
#1
by multimodalart HF Staff - opened
- README.md +24 -10
- app.py +342 -148
- composer.css +83 -0
- composer.html +66 -0
- composer.js +554 -0
- fasth3_engine.py +2 -4
- requirements.txt +4 -4
README.md
CHANGED
|
@@ -1,24 +1,38 @@
|
|
| 1 |
---
|
| 2 |
-
title: FastH3
|
| 3 |
emoji: 🎬
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: indigo
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
app_file: app.py
|
| 9 |
python_version: "3.12"
|
| 10 |
pinned: false
|
| 11 |
-
short_description:
|
| 12 |
startup_duration_timeout: 1h
|
| 13 |
models:
|
| 14 |
-
- FastVideo/FastH3-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
- MiniMaxAI/MiniMax-H3
|
| 16 |
---
|
| 17 |
|
| 18 |
-
# FastH3
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: FastH3-Omni
|
| 3 |
emoji: 🎬
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: indigo
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 6.30.0
|
| 8 |
app_file: app.py
|
| 9 |
python_version: "3.12"
|
| 10 |
pinned: false
|
| 11 |
+
short_description: FastVideo 8-step video+audio from image/video/audio refs
|
| 12 |
startup_duration_timeout: 1h
|
| 13 |
models:
|
| 14 |
+
- FastVideo/FastVideo-FastH3-Omni-8-Step-V1
|
| 15 |
+
- FastVideo/FastH3-Omni-Comfy
|
| 16 |
+
- FastVideo/FastH3-Omni-FP8
|
| 17 |
+
- FastVideo/FastH3-Omni-NVFP4-QAD
|
| 18 |
+
- FastVideo/FastH3-NVFP4-VAE-Decoder
|
| 19 |
+
- FastVideo/FastH3-Omni-FP8-Comfy
|
| 20 |
+
- FastVideo/FastH3-Omni-NVFP4-QAD-Comfy
|
| 21 |
- MiniMaxAI/MiniMax-H3
|
| 22 |
---
|
| 23 |
|
| 24 |
+
# FastH3-Omni
|
| 25 |
|
| 26 |
+
[FastVideo's 8-step FastH3-Omni](https://huggingface.co/FastVideo/FastVideo-FastH3-Omni-8-Step-V1) student of MiniMax-H3 on ZeroGPU: ordered image, video and audio references plus
|
| 27 |
+
a prompt become a short clip with a matching soundtrack. Runs the FP8 W8A8 export (`FastVideo/FastH3-Omni-FP8`)
|
| 28 |
+
with the 128-token VSA tiles on the bf16 Triton emulator.
|
| 29 |
+
|
| 30 |
+
The prompt composer is a custom `gr.HTML` component (`composer.html` / `composer.css` / `composer.js`): references
|
| 31 |
+
get `<Picture n>` / `<Video n>` / `<Audio n>` tags as they are added, and the prompt is either assembled by the
|
| 32 |
+
six-section H3 builder or written by hand with those tags. "Upsample" rewrites it through
|
| 33 |
+
`hugging-apps/minimax-h3-prompt-rewriter`.
|
| 34 |
+
|
| 35 |
+
FastVideo is installed from a wheel of `hao-ai-lab/FastVideo@376e445f` whose unused `gradio==5.32.0` pin is relaxed,
|
| 36 |
+
so the Space runs Gradio 6.
|
| 37 |
+
|
| 38 |
+
All checkpoints: [FastH3-Omni collection](https://huggingface.co/collections/FastVideo/fasth3-omni-6ad501acfccdebd3c3b7445b).
|
app.py
CHANGED
|
@@ -1,17 +1,16 @@
|
|
| 1 |
-
"""FastH3
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
(subject_definitions / summary / retention_analysis / detailed_description /
|
| 9 |
-
overall_soundscape / non_diegetic_music), the form the checkpoint was trained on.
|
| 10 |
-
See fasth3_engine.py for the ZeroGPU + FastVideo integration notes.
|
| 11 |
"""
|
| 12 |
import os
|
| 13 |
import tempfile
|
| 14 |
import threading
|
|
|
|
|
|
|
| 15 |
|
| 16 |
import gradio as gr
|
| 17 |
from PIL import Image
|
|
@@ -33,64 +32,118 @@ except ImportError: # local / CI
|
|
| 33 |
|
| 34 |
from fasth3_engine import FastH3OmniRefEngine, MODEL_REPO, prefetch
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
"
|
| 41 |
-
"
|
| 42 |
-
"
|
| 43 |
-
"
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
"
|
| 49 |
-
"
|
| 50 |
-
"Long · ~8s (192f)": 192,
|
| 51 |
}
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
return (
|
| 69 |
-
f"subject_definitions:\n<Subject 1> is
|
| 70 |
-
f"summary:\
|
| 71 |
f"retention_analysis:\n<Subject 1>: fully_preserved - the reference subject's identity, "
|
| 72 |
f"appearance and details are kept.\n\n"
|
| 73 |
f"detailed_description:\nThe target video is in a realistic photographic style.\n"
|
| 74 |
-
f"[Shot 1]
|
| 75 |
f"overall_soundscape:\n{sound}.\n\n"
|
| 76 |
-
f"non_diegetic_music:\
|
| 77 |
)
|
| 78 |
|
| 79 |
|
| 80 |
-
# Structured examples (subject, scene, soundscape, music, image) — they inherit
|
| 81 |
-
# the recommended 768p resolution from DEFAULT_RES.
|
| 82 |
-
EXAMPLES = [
|
| 83 |
-
["a young woman with a dark curly afro and gold hoop earrings, wearing a white t-shirt",
|
| 84 |
-
"walks along a sunlit beach promenade at golden hour, smiling and glancing toward the camera, palm trees and the ocean behind her.",
|
| 85 |
-
"ocean waves, a gentle breeze and distant seagulls", "none", "examples/woman.jpg"],
|
| 86 |
-
["a pale cream, fluffy long-haired dog with floppy ears and a dark nose",
|
| 87 |
-
"bounds across a snowy field in bright winter sunlight, kicking up powder snow, then shakes itself off.",
|
| 88 |
-
"crunching snow, happy panting and a cold wind", "none", "examples/dog.jpg"],
|
| 89 |
-
["a whole chocolate layer cake with chocolate frosting, pastel sprinkles and chocolate shards on top",
|
| 90 |
-
"sits at the center of a festive birthday table in a warm, dim room as lit candles flicker and colorful confetti drifts down around it.",
|
| 91 |
-
"soft party chatter and a faint crackle", "cheerful, upbeat birthday music", "examples/cake.jpg"],
|
| 92 |
-
]
|
| 93 |
-
|
| 94 |
# --- engine (built lazily inside the first GPU window) ----------------------
|
| 95 |
_ENGINE: FastH3OmniRefEngine | None = None
|
| 96 |
_ENGINE_LOCK = threading.Lock()
|
|
@@ -116,11 +169,11 @@ def _warmup():
|
|
| 116 |
if the startup GPU isn't granted this raises and we fall back to lazy."""
|
| 117 |
eng = get_engine()
|
| 118 |
try:
|
| 119 |
-
|
| 120 |
-
img =
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
eng.generate(prompt,
|
| 124 |
print("[warmboot] warmup generation complete (FP4 JIT + shape warmed)", flush=True)
|
| 125 |
except Exception as e:
|
| 126 |
print(f"[warmboot] warmup generation skipped ({e!r}); JIT will hit first call", flush=True)
|
|
@@ -139,139 +192,280 @@ else:
|
|
| 139 |
threading.Thread(target=prefetch, daemon=True).start()
|
| 140 |
|
| 141 |
|
| 142 |
-
def
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
return tmp.name
|
| 151 |
|
| 152 |
|
| 153 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
# First call builds the generator (weights load + kernel JIT); later calls
|
| 155 |
-
# reuse it.
|
| 156 |
-
|
|
|
|
|
|
|
|
|
|
| 157 |
|
| 158 |
|
| 159 |
@spaces.GPU(duration=_gpu_duration, size="xlarge")
|
| 160 |
-
def
|
| 161 |
-
audio, resolution, length, seed, progress=gr.Progress(track_tqdm=True)):
|
| 162 |
-
images = [p for p in (_save_image(img1, 1), _save_image(img2, 2), _save_image(img3, 3)) if p]
|
| 163 |
-
if not images:
|
| 164 |
-
raise gr.Error("Add at least one reference image.")
|
| 165 |
-
raw_prompt = (raw_prompt or "").strip()
|
| 166 |
-
prompt = raw_prompt or build_h3_prompt(subject, scene, soundscape, music, len(images))
|
| 167 |
-
width, height = RESOLUTIONS.get(resolution, RESOLUTIONS[DEFAULT_RES])
|
| 168 |
-
num_frames = LENGTHS.get(length, LENGTHS[DEFAULT_LEN])
|
| 169 |
-
seed = int(seed) if seed is not None else 0
|
| 170 |
-
|
| 171 |
progress(0.02, desc="Loading model (first run only)…")
|
| 172 |
eng = get_engine()
|
| 173 |
progress(0.1, desc="Generating video + audio…")
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
|
| 178 |
# --- UI ---------------------------------------------------------------------
|
| 179 |
HEADER = """
|
| 180 |
<div class="fh-header">
|
| 181 |
-
<h1>FastH3
|
| 182 |
-
<p>FastH3
|
| 183 |
-
prompt into a short clip with a matching soundtrack.
|
| 184 |
-
|
| 185 |
<div class="fh-pills">
|
| 186 |
-
<a href="https://huggingface.co/FastVideo/FastH3-
|
|
|
|
| 187 |
<a href="https://github.com/hao-ai-lab/FastVideo" target="_blank">FastVideo</a>
|
| 188 |
<a href="https://huggingface.co/MiniMaxAI/MiniMax-H3" target="_blank">MiniMax-H3</a>
|
| 189 |
</div>
|
| 190 |
</div>
|
| 191 |
"""
|
| 192 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
CSS = """
|
| 194 |
-
.main.fillable { max-width:
|
| 195 |
.fh-header { padding: 6px 2px 12px; }
|
| 196 |
.fh-header h1 { font-size: 1.6rem; margin: 0 0 6px; font-weight: 700; }
|
| 197 |
-
.fh-header h1 .fh-accent { color:
|
| 198 |
.fh-header p { color: var(--body-text-color-subdued); margin: 0 0 12px; max-width: 76ch; }
|
| 199 |
.fh-pills { display: flex; gap: 8px; flex-wrap: wrap; }
|
| 200 |
.fh-pills a { font-size: .8rem; padding: 3px 11px; border-radius: 999px;
|
| 201 |
background: var(--button-secondary-background-fill); color: var(--body-text-color);
|
| 202 |
text-decoration: none; border: 1px solid var(--border-color-primary); }
|
| 203 |
-
|
|
|
|
|
|
|
| 204 |
"""
|
| 205 |
|
| 206 |
THEME = gr.themes.Base(
|
| 207 |
-
primary_hue=
|
| 208 |
-
secondary_hue=
|
| 209 |
-
neutral_hue="
|
| 210 |
font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
)
|
| 212 |
|
| 213 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
gr.HTML(HEADER)
|
| 215 |
|
| 216 |
with gr.Row():
|
| 217 |
with gr.Column(scale=1):
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
subject = gr.Textbox(
|
| 225 |
-
label="Subject — what the reference shows",
|
| 226 |
-
value=DEFAULT_SUBJECT,
|
| 227 |
-
placeholder="a woman with long hair in a light summer dress",
|
| 228 |
-
)
|
| 229 |
-
scene = gr.Textbox(
|
| 230 |
-
label="Scene — the action, setting and motion",
|
| 231 |
-
value=DEFAULT_SCENE,
|
| 232 |
-
lines=3,
|
| 233 |
-
placeholder="stands in a sunlit meadow and turns to smile at the camera…",
|
| 234 |
)
|
| 235 |
-
run = gr.Button("Generate
|
| 236 |
with gr.Accordion("Advanced options", open=False):
|
| 237 |
-
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
| 239 |
)
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
label="Resolution", choices=list(RESOLUTIONS), value=DEFAULT_RES
|
| 243 |
-
)
|
| 244 |
-
length = gr.Dropdown(
|
| 245 |
-
label="Clip length", choices=list(LENGTHS), value=DEFAULT_LEN
|
| 246 |
)
|
| 247 |
seed = gr.Number(label="Seed", value=1234, precision=0)
|
| 248 |
-
|
| 249 |
-
label="Raw H3 prompt override (optional) — replaces the builder above",
|
| 250 |
-
lines=3, value="",
|
| 251 |
-
)
|
| 252 |
-
gr.Markdown(
|
| 253 |
-
"Prompts are assembled into the six-section H3 format "
|
| 254 |
-
"(subject / summary / retention / detailed description / soundscape / music). "
|
| 255 |
-
"8 fused steps and guidance 1.0 are fixed by the model's contract."
|
| 256 |
-
)
|
| 257 |
|
| 258 |
with gr.Column(scale=1):
|
| 259 |
-
result = gr.Video(label="
|
| 260 |
-
|
| 261 |
-
inputs = [subject, scene, soundscape, music, raw_prompt,
|
| 262 |
-
img1, img2, img3, audio, resolution, length, seed]
|
| 263 |
-
|
| 264 |
-
# Populate-only: clicking fills the subject, scene, soundscape, music and the
|
| 265 |
-
# reference image; the user then presses Generate. Resolution stays at the
|
| 266 |
-
# recommended 768p default.
|
| 267 |
-
gr.Examples(
|
| 268 |
-
label="Examples — click to load a reference + structured prompt, then press Generate",
|
| 269 |
-
examples=EXAMPLES,
|
| 270 |
-
inputs=[subject, scene, soundscape, music, img1],
|
| 271 |
-
)
|
| 272 |
|
| 273 |
-
|
|
|
|
| 274 |
|
| 275 |
|
| 276 |
if __name__ == "__main__":
|
| 277 |
-
demo.queue(max_size=12).launch(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastH3-Omni · reference-to-video (+audio) on ZeroGPU.
|
| 2 |
|
| 3 |
+
Ordered image, video and audio references plus a prompt become a short video
|
| 4 |
+
with a matching 32 kHz soundtrack in eight fused forwards of the FastVideo
|
| 5 |
+
FastH3 OmniRef student. A plain prompt is wrapped into the model's six-section
|
| 6 |
+
H3 format; "Upsample prompt" rewrites it with the MiniMax-H3 prompt rewriter
|
| 7 |
+
instead. See fasth3_engine.py for the ZeroGPU + FastVideo integration notes.
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
import os
|
| 10 |
import tempfile
|
| 11 |
import threading
|
| 12 |
+
from functools import cache
|
| 13 |
+
from pathlib import Path
|
| 14 |
|
| 15 |
import gradio as gr
|
| 16 |
from PIL import Image
|
|
|
|
| 32 |
|
| 33 |
from fasth3_engine import FastH3OmniRefEngine, MODEL_REPO, prefetch
|
| 34 |
|
| 35 |
+
REWRITER_SPACE = os.getenv("FASTH3_REWRITER", "hugging-apps/minimax-h3-prompt-rewriter")
|
| 36 |
+
|
| 37 |
+
CANVASES = {
|
| 38 |
+
"1344x768 · 16:9 recommended": (768, 1344),
|
| 39 |
+
"832x480 · 16:9 fast": (480, 832),
|
| 40 |
+
"1280x704 · 16:9": (704, 1280),
|
| 41 |
+
"960x544 · 16:9": (544, 960),
|
| 42 |
+
"768x1344 · 9:16 recommended": (1344, 768),
|
| 43 |
+
"480x832 · 9:16 fast": (832, 480),
|
| 44 |
+
"768x768 · 1:1": (768, 768),
|
| 45 |
+
"1024x1024 · 1:1": (1024, 1024),
|
| 46 |
+
"1024x768 · 4:3": (768, 1024),
|
| 47 |
+
"768x1024 · 3:4": (1024, 768),
|
| 48 |
+
"1536x672 · 21:9": (672, 1536),
|
|
|
|
| 49 |
}
|
| 50 |
+
DEFAULT_CANVAS = "1344x768 · 16:9 recommended"
|
| 51 |
+
PORTRAIT_CANVAS = "768x1344 · 9:16 recommended"
|
| 52 |
+
FPS, FRAMES_PER_CHUNK, LATENTS_PER_CHUNK = 24, 17, 5
|
| 53 |
+
MIN_FRAMES, MAX_FRAMES = 124, 362
|
| 54 |
+
MIN_DURATION, MAX_UI_DURATION = 5, 14
|
| 55 |
+
MIN_REFERENCE_VIDEO, MAX_REFERENCE_VIDEO = 2.0, 15.0
|
| 56 |
+
MAX_IMAGE_SLOTS, OPEN_IMAGE_SLOTS = 9, 2
|
| 57 |
+
REWRITER_IMAGE_SLOTS = 4
|
| 58 |
+
DEFAULT_PIXEL_FRAMES = 1344 * 768 * 124
|
| 59 |
+
|
| 60 |
+
APP_DIR = Path(__file__).resolve().parent
|
| 61 |
+
EXAMPLES_DIR = APP_DIR / "examples"
|
| 62 |
+
LIMITS = {"image": 9, "video": 3, "audio": 3}
|
| 63 |
+
REWRITER_LIMITS = {"image": 4, "video": 1, "audio": 1}
|
| 64 |
+
|
| 65 |
+
EXAMPLES = [
|
| 66 |
+
{
|
| 67 |
+
"title": "Beach promenade at golden hour",
|
| 68 |
+
"image": "woman.jpg",
|
| 69 |
+
"desc": "a young woman with a dark curly afro and gold hoop earrings, wearing a white t-shirt",
|
| 70 |
+
"scene": "walks along a sunlit beach promenade at golden hour, smiling and glancing toward the camera, "
|
| 71 |
+
"palm trees and the ocean behind her.",
|
| 72 |
+
"sound": "ocean waves, a gentle breeze and distant seagulls",
|
| 73 |
+
"music": "none",
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
"title": "Dog bounding through snow",
|
| 77 |
+
"image": "dog.jpg",
|
| 78 |
+
"desc": "a pale cream, fluffy long-haired dog with floppy ears and a dark nose",
|
| 79 |
+
"scene": "bounds across a snowy field in bright winter sunlight, kicking up powder snow, then shakes itself off.",
|
| 80 |
+
"sound": "crunching snow, happy panting and a cold wind",
|
| 81 |
+
"music": "none",
|
| 82 |
+
},
|
| 83 |
+
{
|
| 84 |
+
"title": "Birthday cake with candles",
|
| 85 |
+
"image": "cake.jpg",
|
| 86 |
+
"desc": "a whole chocolate layer cake with chocolate frosting, pastel sprinkles and chocolate shards on top",
|
| 87 |
+
"scene": "sits at the center of a festive birthday table in a warm, dim room as lit candles flicker and "
|
| 88 |
+
"colorful confetti drifts down around it.",
|
| 89 |
+
"sound": "soft party chatter and a faint crackle",
|
| 90 |
+
"music": "cheerful, upbeat birthday music",
|
| 91 |
+
},
|
| 92 |
+
]
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def composer_examples():
|
| 96 |
+
rows = []
|
| 97 |
+
for example in EXAMPLES:
|
| 98 |
+
path = str(EXAMPLES_DIR / example["image"])
|
| 99 |
+
rows.append({
|
| 100 |
+
"title": example["title"],
|
| 101 |
+
"refs": [{"kind": "image", "path": path, "url": f"/gradio_api/file={path}", "name": example["image"],
|
| 102 |
+
"desc": example["desc"]}],
|
| 103 |
+
"builder": {"scene": example["scene"], "sound": example["sound"], "music": example["music"],
|
| 104 |
+
"style": "realistic photographic"},
|
| 105 |
+
})
|
| 106 |
+
return rows
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def snap_frames(seconds: float) -> int:
|
| 110 |
+
frames = max(1, round(float(seconds) * FPS))
|
| 111 |
+
while frames % FRAMES_PER_CHUNK != LATENTS_PER_CHUNK:
|
| 112 |
+
frames += 1
|
| 113 |
+
return frames
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def reference_labels(references):
|
| 117 |
+
names = {"image": "Picture", "video": "Video", "audio": "Audio"}
|
| 118 |
+
counts, labels = {}, []
|
| 119 |
+
for kind, _ in references:
|
| 120 |
+
counts[kind] = counts.get(kind, 0) + 1
|
| 121 |
+
labels.append((kind, f"<{names[kind]} {counts[kind]}>"))
|
| 122 |
+
return labels
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def to_h3_prompt(prompt: str, references) -> str:
|
| 126 |
+
"""Wrap a plain prompt into the six-section H3 format the checkpoint was trained on."""
|
| 127 |
+
prompt = (prompt or "").strip()
|
| 128 |
+
if "subject_definitions:" in prompt:
|
| 129 |
+
return prompt
|
| 130 |
+
labels = reference_labels(references)
|
| 131 |
+
visual = " and ".join(label for kind, label in labels if kind != "audio") or "the references"
|
| 132 |
+
audio = [label for kind, label in labels if kind == "audio"]
|
| 133 |
+
sound = (f"the voice and sound of {' and '.join(audio)}, in a natural acoustic space matching the scene"
|
| 134 |
+
if audio else "natural ambient sound that matches the scene")
|
| 135 |
return (
|
| 136 |
+
f"subject_definitions:\n<Subject 1> is the subject shown in {visual}.\n\n"
|
| 137 |
+
f"summary:\n{prompt}\n\n"
|
| 138 |
f"retention_analysis:\n<Subject 1>: fully_preserved - the reference subject's identity, "
|
| 139 |
f"appearance and details are kept.\n\n"
|
| 140 |
f"detailed_description:\nThe target video is in a realistic photographic style.\n"
|
| 141 |
+
f"[Shot 1] {prompt}\n\n"
|
| 142 |
f"overall_soundscape:\n{sound}.\n\n"
|
| 143 |
+
f"non_diegetic_music:\nnone."
|
| 144 |
)
|
| 145 |
|
| 146 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
# --- engine (built lazily inside the first GPU window) ----------------------
|
| 148 |
_ENGINE: FastH3OmniRefEngine | None = None
|
| 149 |
_ENGINE_LOCK = threading.Lock()
|
|
|
|
| 169 |
if the startup GPU isn't granted this raises and we fall back to lazy."""
|
| 170 |
eng = get_engine()
|
| 171 |
try:
|
| 172 |
+
h, w = CANVASES[DEFAULT_CANVAS]
|
| 173 |
+
img = str(EXAMPLES_DIR / "woman.jpg")
|
| 174 |
+
references = [("image", img)]
|
| 175 |
+
prompt = to_h3_prompt("The person stands still and looks at the camera. Quiet room tone.", references)
|
| 176 |
+
eng.generate(prompt, references, h, w, MIN_FRAMES, 0)
|
| 177 |
print("[warmboot] warmup generation complete (FP4 JIT + shape warmed)", flush=True)
|
| 178 |
except Exception as e:
|
| 179 |
print(f"[warmboot] warmup generation skipped ({e!r}); JIT will hit first call", flush=True)
|
|
|
|
| 192 |
threading.Thread(target=prefetch, daemon=True).start()
|
| 193 |
|
| 194 |
|
| 195 |
+
def probe(path: str):
|
| 196 |
+
import av
|
| 197 |
+
|
| 198 |
+
def seconds(stream, container):
|
| 199 |
+
if stream.duration is not None and stream.time_base is not None:
|
| 200 |
+
return float(stream.duration * stream.time_base)
|
| 201 |
+
return None if container.duration is None else container.duration / av.time_base
|
| 202 |
+
|
| 203 |
+
with av.open(path) as container:
|
| 204 |
+
video = seconds(container.streams.video[0], container) if container.streams.video else None
|
| 205 |
+
audio = seconds(container.streams.audio[0], container) if container.streams.audio else None
|
| 206 |
+
return video, audio
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def _allowed(path: str) -> bool:
|
| 210 |
+
from gradio.utils import get_upload_folder
|
| 211 |
+
|
| 212 |
+
real = os.path.realpath(path)
|
| 213 |
+
roots = [os.path.realpath(get_upload_folder()), os.path.realpath(EXAMPLES_DIR)]
|
| 214 |
+
return any(real == root or real.startswith(root + os.sep) for root in roots) and os.path.isfile(real)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def references_from(composer: dict):
|
| 218 |
+
refs = (composer or {}).get("refs") or []
|
| 219 |
+
if any(ref.get("uploading") for ref in refs):
|
| 220 |
+
raise gr.Error("A reference is still uploading. Give it a second and try again.")
|
| 221 |
+
for ref in refs:
|
| 222 |
+
if ref.get("error"):
|
| 223 |
+
raise gr.Error(ref["error"])
|
| 224 |
+
references = []
|
| 225 |
+
for ref in refs:
|
| 226 |
+
kind, path = ref.get("kind"), ref.get("path")
|
| 227 |
+
if kind not in LIMITS or not path or not _allowed(path):
|
| 228 |
+
raise gr.Error("One of the references could not be read. Remove it and add it again.")
|
| 229 |
+
references.append((kind, _as_rgb_png(path, len(references)) if kind == "image" else path))
|
| 230 |
+
for kind, limit in LIMITS.items():
|
| 231 |
+
if sum(k == kind for k, _ in references) > limit:
|
| 232 |
+
raise gr.Error(f"At most {limit} {kind} references.")
|
| 233 |
+
return references
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def _as_rgb_png(path: str, index: int) -> str:
|
| 237 |
+
tmp = tempfile.NamedTemporaryFile(suffix=f"_ref{index + 1}.png", delete=False)
|
| 238 |
+
Image.open(path).convert("RGB").save(tmp.name)
|
| 239 |
return tmp.name
|
| 240 |
|
| 241 |
|
| 242 |
+
def soundtrack_seconds(references):
|
| 243 |
+
carried = []
|
| 244 |
+
for kind, path in references:
|
| 245 |
+
if kind == "image":
|
| 246 |
+
continue
|
| 247 |
+
_, audio_seconds = probe(path)
|
| 248 |
+
if audio_seconds is not None:
|
| 249 |
+
carried.append(audio_seconds)
|
| 250 |
+
return carried
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def check(prompt: str, references) -> None:
|
| 254 |
+
if not references:
|
| 255 |
+
raise gr.Error("Add at least one image or video reference.")
|
| 256 |
+
if not any(kind != "audio" for kind, _ in references):
|
| 257 |
+
raise gr.Error("Audio needs an image or a video alongside it.")
|
| 258 |
+
if not prompt or not prompt.strip():
|
| 259 |
+
raise gr.Error("Describe the video: fill in the builder or write a prompt.")
|
| 260 |
+
if "<removed>" in prompt:
|
| 261 |
+
raise gr.Error("The prompt still refers to a removed reference (<removed>). Edit it out first.")
|
| 262 |
+
for kind, path in references:
|
| 263 |
+
if kind != "video":
|
| 264 |
+
continue
|
| 265 |
+
video_seconds, _ = probe(path)
|
| 266 |
+
if video_seconds is None:
|
| 267 |
+
raise gr.Error("A reference video has no video stream. Add it as audio instead.")
|
| 268 |
+
if not MIN_REFERENCE_VIDEO <= video_seconds <= MAX_REFERENCE_VIDEO:
|
| 269 |
+
raise gr.Error(
|
| 270 |
+
f"A reference video is {video_seconds:.1f} s. Use clips between "
|
| 271 |
+
f"{MIN_REFERENCE_VIDEO:g} and {MAX_REFERENCE_VIDEO:g} seconds."
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
@cache
|
| 276 |
+
def rewriter():
|
| 277 |
+
from gradio_client import Client
|
| 278 |
+
|
| 279 |
+
return Client(REWRITER_SPACE, token=False)
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def aspect_label(height: int, width: int) -> str:
|
| 283 |
+
ratio = width / height
|
| 284 |
+
options = {"21:9": 21 / 9, "16:9": 16 / 9, "4:3": 4 / 3, "1:1": 1.0, "3:4": 3 / 4, "9:16": 9 / 16}
|
| 285 |
+
return min(options, key=lambda key: abs(options[key] - ratio))
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def resolve_frames(references, match, duration) -> int:
|
| 289 |
+
carried = soundtrack_seconds(references)
|
| 290 |
+
if match and len(carried) == 1 and MIN_FRAMES <= snap_frames(carried[0]) <= MAX_FRAMES:
|
| 291 |
+
return snap_frames(carried[0])
|
| 292 |
+
return min(MAX_FRAMES, max(MIN_FRAMES, snap_frames(duration)))
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def upsample(composer, canvas, match, duration):
|
| 296 |
+
from gradio_client import handle_file
|
| 297 |
+
|
| 298 |
+
composer = dict(composer or {})
|
| 299 |
+
try:
|
| 300 |
+
references = references_from(composer)
|
| 301 |
+
if not any(kind != "audio" for kind, _ in references):
|
| 302 |
+
raise gr.Error("Add an image or a video reference first.")
|
| 303 |
+
for kind, limit in REWRITER_LIMITS.items():
|
| 304 |
+
if sum(k == kind for k, _ in references) > limit:
|
| 305 |
+
raise gr.Error(
|
| 306 |
+
f"The rewriter takes at most {REWRITER_LIMITS['image']} images, 1 video and 1 audio clip."
|
| 307 |
+
)
|
| 308 |
+
prompt = (composer.get("upsample_source") or "").strip()
|
| 309 |
+
if not prompt:
|
| 310 |
+
raise gr.Error("Describe the video first, then upsample it.")
|
| 311 |
+
missing = [label for _, label in reference_labels(references) if label not in prompt]
|
| 312 |
+
if missing:
|
| 313 |
+
prompt = f"Keep the subject shown in {' and '.join(missing)}. {prompt}"
|
| 314 |
+
height, width = CANVASES.get(canvas, CANVASES[DEFAULT_CANVAS])
|
| 315 |
+
num_frames = resolve_frames(references, match, duration)
|
| 316 |
+
images = [path for kind, path in references if kind == "image"]
|
| 317 |
+
images += [None] * (REWRITER_LIMITS["image"] - len(images))
|
| 318 |
+
video = next((path for kind, path in references if kind == "video"), None)
|
| 319 |
+
audio = next((path for kind, path in references if kind == "audio"), None)
|
| 320 |
+
rewritten, status = rewriter().predict(
|
| 321 |
+
task="Ref2AV",
|
| 322 |
+
prompt=prompt,
|
| 323 |
+
duration=round(num_frames / FPS, 2),
|
| 324 |
+
resolution=aspect_label(height, width),
|
| 325 |
+
image_1=handle_file(images[0]) if images[0] else None,
|
| 326 |
+
image_2=handle_file(images[1]) if images[1] else None,
|
| 327 |
+
image_3=handle_file(images[2]) if images[2] else None,
|
| 328 |
+
image_4=handle_file(images[3]) if images[3] else None,
|
| 329 |
+
ref_video=handle_file(video) if video else None,
|
| 330 |
+
ref_audio=handle_file(audio) if audio else None,
|
| 331 |
+
api_name="/rewrite",
|
| 332 |
+
)
|
| 333 |
+
rewritten = (rewritten or "").strip()
|
| 334 |
+
if not rewritten:
|
| 335 |
+
raise gr.Error(f"The rewriter returned nothing: {status}")
|
| 336 |
+
except gr.Error as error:
|
| 337 |
+
gr.Warning(str(error.message))
|
| 338 |
+
rewritten = ""
|
| 339 |
+
except Exception as error:
|
| 340 |
+
gr.Warning(f"The prompt rewriter failed: {type(error).__name__}: {error}")
|
| 341 |
+
rewritten = ""
|
| 342 |
+
composer["upsampled"] = rewritten
|
| 343 |
+
return composer
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def _gpu_duration(prompt, references, height, width, num_frames, seed, *args, **kwargs):
|
| 347 |
# First call builds the generator (weights load + kernel JIT); later calls
|
| 348 |
+
# reuse it. Both scale with the canvas and clip length.
|
| 349 |
+
base = 300 if (_ENGINE is not None) else 900
|
| 350 |
+
scale = max(1.0, (height * width * num_frames) / DEFAULT_PIXEL_FRAMES)
|
| 351 |
+
scale += 0.5 * sum(kind == "video" for kind, _ in references)
|
| 352 |
+
return int(min(1500, base * scale))
|
| 353 |
|
| 354 |
|
| 355 |
@spaces.GPU(duration=_gpu_duration, size="xlarge")
|
| 356 |
+
def _generate(prompt, references, height, width, num_frames, seed, progress=gr.Progress(track_tqdm=True)):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
progress(0.02, desc="Loading model (first run only)…")
|
| 358 |
eng = get_engine()
|
| 359 |
progress(0.1, desc="Generating video + audio…")
|
| 360 |
+
return eng.generate(prompt, references, height, width, num_frames, seed)
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def generate(composer, canvas=DEFAULT_CANVAS, match=True, duration=5, seed=1234):
|
| 364 |
+
references = references_from(composer)
|
| 365 |
+
prompt = ((composer or {}).get("prompt") or "").strip()
|
| 366 |
+
check(prompt, references)
|
| 367 |
+
height, width = CANVASES.get(canvas, CANVASES[DEFAULT_CANVAS])
|
| 368 |
+
num_frames = resolve_frames(references, match, duration)
|
| 369 |
+
seed = int(seed) if seed is not None else 0
|
| 370 |
+
return _generate(to_h3_prompt(prompt, references), references, height, width, num_frames, seed)
|
| 371 |
|
| 372 |
|
| 373 |
# --- UI ---------------------------------------------------------------------
|
| 374 |
HEADER = """
|
| 375 |
<div class="fh-header">
|
| 376 |
+
<h1>FastH3-<span class="fh-accent">Omni</span></h1>
|
| 377 |
+
<p>FastH3-Omni is FastVideo's 8-step reference-to-video student that turns reference images, videos and
|
| 378 |
+
audio plus a prompt into a short clip with a matching soundtrack. Drop in references, then describe the video
|
| 379 |
+
with the builder or write the prompt yourself, tagging each reference by name.</p>
|
| 380 |
<div class="fh-pills">
|
| 381 |
+
<a href="https://huggingface.co/FastVideo/FastVideo-FastH3-Omni-8-Step-V1" target="_blank">Model</a>
|
| 382 |
+
<a href="https://huggingface.co/collections/FastVideo/fasth3-omni-6ad501acfccdebd3c3b7445b" target="_blank">Collection</a>
|
| 383 |
<a href="https://github.com/hao-ai-lab/FastVideo" target="_blank">FastVideo</a>
|
| 384 |
<a href="https://huggingface.co/MiniMaxAI/MiniMax-H3" target="_blank">MiniMax-H3</a>
|
| 385 |
</div>
|
| 386 |
</div>
|
| 387 |
"""
|
| 388 |
|
| 389 |
+
FASTVIDEO_BLUE = gr.themes.Color(
|
| 390 |
+
c50="#eef3ff", c100="#dbe5ff", c200="#bccfff", c300="#8eaeff", c400="#5f8cff",
|
| 391 |
+
c500="#346cff", c600="#1f55ec", c700="#1843c4", c800="#17389c", c900="#16317c", c950="#0e1d4a",
|
| 392 |
+
name="fastvideo",
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
CSS = """
|
| 396 |
+
.main.fillable { max-width: 1250px !important; }
|
| 397 |
.fh-header { padding: 6px 2px 12px; }
|
| 398 |
.fh-header h1 { font-size: 1.6rem; margin: 0 0 6px; font-weight: 700; }
|
| 399 |
+
.fh-header h1 .fh-accent { color: #346cff; }
|
| 400 |
.fh-header p { color: var(--body-text-color-subdued); margin: 0 0 12px; max-width: 76ch; }
|
| 401 |
.fh-pills { display: flex; gap: 8px; flex-wrap: wrap; }
|
| 402 |
.fh-pills a { font-size: .8rem; padding: 3px 11px; border-radius: 999px;
|
| 403 |
background: var(--button-secondary-background-fill); color: var(--body-text-color);
|
| 404 |
text-decoration: none; border: 1px solid var(--border-color-primary); }
|
| 405 |
+
.fh-pills a:hover { border-color: #346cff; color: #346cff; }
|
| 406 |
+
#gen-btn { background: #346cff; color: #fff; border: none; }
|
| 407 |
+
#gen-btn:hover { background: #1f55ec; }
|
| 408 |
"""
|
| 409 |
|
| 410 |
THEME = gr.themes.Base(
|
| 411 |
+
primary_hue=FASTVIDEO_BLUE,
|
| 412 |
+
secondary_hue=FASTVIDEO_BLUE,
|
| 413 |
+
neutral_hue="zinc",
|
| 414 |
font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
|
| 415 |
+
).set(
|
| 416 |
+
body_background_fill_dark="#000000",
|
| 417 |
+
block_background_fill_dark="#0b0b0f",
|
| 418 |
+
background_fill_secondary_dark="#0b0b0f",
|
| 419 |
+
color_accent="#346cff",
|
| 420 |
+
slider_color="#346cff",
|
| 421 |
+
checkbox_background_color_selected="#346cff",
|
| 422 |
+
checkbox_background_color_selected_dark="#346cff",
|
| 423 |
)
|
| 424 |
|
| 425 |
+
COMPOSER_HTML = (APP_DIR / "composer.html").read_text()
|
| 426 |
+
COMPOSER_CSS = (APP_DIR / "composer.css").read_text()
|
| 427 |
+
COMPOSER_JS = (APP_DIR / "composer.js").read_text()
|
| 428 |
+
|
| 429 |
+
with gr.Blocks(title="FastH3-Omni") as demo:
|
| 430 |
gr.HTML(HEADER)
|
| 431 |
|
| 432 |
with gr.Row():
|
| 433 |
with gr.Column(scale=1):
|
| 434 |
+
composer = gr.HTML(
|
| 435 |
+
value={"examples": composer_examples()},
|
| 436 |
+
html_template=COMPOSER_HTML,
|
| 437 |
+
css_template=COMPOSER_CSS,
|
| 438 |
+
js_on_load=COMPOSER_JS,
|
| 439 |
+
apply_default_css=False,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
)
|
| 441 |
+
run = gr.Button("Generate", variant="primary", size="lg", elem_id="gen-btn")
|
| 442 |
with gr.Accordion("Advanced options", open=False):
|
| 443 |
+
canvas = gr.Dropdown(label="Canvas", choices=list(CANVASES), value=DEFAULT_CANVAS)
|
| 444 |
+
match = gr.Checkbox(
|
| 445 |
+
label="Match the reference soundtrack",
|
| 446 |
+
info="When exactly one reference carries a soundtrack, the clip takes its length.",
|
| 447 |
+
value=True,
|
| 448 |
)
|
| 449 |
+
duration = gr.Slider(
|
| 450 |
+
label="Duration (s)", minimum=MIN_DURATION, maximum=MAX_UI_DURATION, step=1, value=5
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
)
|
| 452 |
seed = gr.Number(label="Seed", value=1234, precision=0)
|
| 453 |
+
gr.Markdown("8 fused steps and guidance 1.0 are fixed by the model's contract.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
|
| 455 |
with gr.Column(scale=1):
|
| 456 |
+
result = gr.Video(label="Video + soundtrack", autoplay=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
|
| 458 |
+
composer.edit(upsample, [composer, canvas, match, duration], [composer], api_name=False, show_progress="hidden")
|
| 459 |
+
run.click(generate, [composer, canvas, match, duration, seed], [result], api_name="generate")
|
| 460 |
|
| 461 |
|
| 462 |
if __name__ == "__main__":
|
| 463 |
+
demo.queue(max_size=12).launch(
|
| 464 |
+
server_name="0.0.0.0",
|
| 465 |
+
server_port=7860,
|
| 466 |
+
show_error=True,
|
| 467 |
+
theme=THEME,
|
| 468 |
+
css=CSS,
|
| 469 |
+
ssr_mode=False,
|
| 470 |
+
allowed_paths=[str(EXAMPLES_DIR)],
|
| 471 |
+
)
|
composer.css
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.fh-composer { --fh-blue: #346cff; --fh-blue-soft: rgba(52, 108, 255, .14); display: flex; flex-direction: column; gap: 14px; }
|
| 2 |
+
.fh-composer [hidden] { display: none !important; }
|
| 3 |
+
.fh-section { border: 1px solid var(--border-color-primary); border-radius: 12px; padding: 12px; background: var(--block-background-fill); }
|
| 4 |
+
.fh-section-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; }
|
| 5 |
+
.fh-title { font-weight: 600; font-size: .9rem; }
|
| 6 |
+
.fh-counts { font-size: .75rem; color: var(--body-text-color-subdued); font-variant-numeric: tabular-nums; }
|
| 7 |
+
.fh-tray { display: flex; flex-wrap: wrap; gap: 10px; }
|
| 8 |
+
.fh-drop { width: 132px; height: 150px; border: 1.5px dashed var(--border-color-primary); border-radius: 10px; background: transparent;
|
| 9 |
+
color: var(--body-text-color-subdued); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
|
| 10 |
+
cursor: pointer; font: inherit; text-align: center; padding: 8px; transition: border-color .15s, background .15s, color .15s; }
|
| 11 |
+
.fh-drop:hover, .fh-tray.is-over .fh-drop { border-color: var(--fh-blue); color: var(--fh-blue); background: var(--fh-blue-soft); }
|
| 12 |
+
.fh-drop-icon { font-size: 1.6rem; line-height: 1; }
|
| 13 |
+
.fh-drop-text { font-size: .78rem; line-height: 1.3; }
|
| 14 |
+
.fh-card { position: relative; width: 132px; height: 150px; border-radius: 10px; overflow: hidden; border: 1px solid var(--border-color-primary);
|
| 15 |
+
background: var(--background-fill-secondary); cursor: grab; display: flex; flex-direction: column; animation: fh-pop .18s ease-out; }
|
| 16 |
+
.fh-card.is-dragging { opacity: .4; }
|
| 17 |
+
.fh-card.is-target { outline: 2px solid var(--fh-blue); outline-offset: 2px; }
|
| 18 |
+
@keyframes fh-pop { from { transform: scale(.94); opacity: 0; } to { transform: none; opacity: 1; } }
|
| 19 |
+
.fh-thumb { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; background: #0b0b0f; }
|
| 20 |
+
.fh-thumb img, .fh-thumb video { width: 100%; height: 100%; object-fit: cover; display: block; }
|
| 21 |
+
.fh-thumb .fh-audio-glyph { color: var(--fh-blue); font-size: 2rem; }
|
| 22 |
+
.fh-card-foot { display: flex; align-items: center; justify-content: space-between; gap: 4px; padding: 6px; }
|
| 23 |
+
.fh-meta { font-size: .68rem; color: var(--body-text-color-subdued); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
| 24 |
+
.fh-x { position: absolute; top: 5px; right: 5px; width: 22px; height: 22px; border-radius: 50%; border: none; background: rgba(0,0,0,.6);
|
| 25 |
+
color: #fff; cursor: pointer; font-size: 13px; line-height: 22px; padding: 0; opacity: 0; transition: opacity .15s; }
|
| 26 |
+
.fh-card:hover .fh-x, .fh-card:focus-within .fh-x { opacity: 1; }
|
| 27 |
+
.fh-busy { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,.45); color: #fff; font-size: .75rem; }
|
| 28 |
+
.fh-bad { box-shadow: inset 0 0 0 2px #ef4444; }
|
| 29 |
+
.fh-tag { display: inline-flex; align-items: center; gap: 3px; border: 1px solid transparent; border-radius: 999px; padding: 2px 8px;
|
| 30 |
+
font-size: .72rem; font-weight: 600; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; cursor: pointer; white-space: nowrap;
|
| 31 |
+
background: var(--fh-blue-soft); color: var(--fh-blue); transition: background .15s, transform .1s; }
|
| 32 |
+
.fh-tag:hover { background: var(--fh-blue); color: #fff; }
|
| 33 |
+
.fh-tag:active { transform: scale(.96); }
|
| 34 |
+
.fh-tag.is-audio { background: rgba(16, 185, 129, .14); color: #10b981; }
|
| 35 |
+
.fh-tag.is-audio:hover { background: #10b981; color: #fff; }
|
| 36 |
+
.fh-tag.is-video { background: rgba(245, 158, 11, .16); color: #d97706; }
|
| 37 |
+
.fh-tag.is-video:hover { background: #d97706; color: #fff; }
|
| 38 |
+
.fh-tag.is-subject { background: transparent; border-color: var(--fh-blue); }
|
| 39 |
+
.fh-tag.is-missing { outline: 1.5px dashed currentColor; outline-offset: 1px; }
|
| 40 |
+
.fh-hint { font-size: .75rem; color: var(--body-text-color-subdued); margin-top: 8px; min-height: 1em; }
|
| 41 |
+
.fh-hint.is-error { color: #ef4444; }
|
| 42 |
+
.fh-seg { display: inline-flex; padding: 3px; border-radius: 9px; background: var(--background-fill-secondary); border: 1px solid var(--border-color-primary); }
|
| 43 |
+
.fh-seg-btn { border: none; background: transparent; color: var(--body-text-color-subdued); font: inherit; font-size: .82rem; font-weight: 600;
|
| 44 |
+
padding: 5px 14px; border-radius: 7px; cursor: pointer; }
|
| 45 |
+
.fh-seg-btn.is-active { background: var(--fh-blue); color: #fff; }
|
| 46 |
+
.fh-upsample { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--fh-blue); color: var(--fh-blue); background: transparent;
|
| 47 |
+
border-radius: 9px; padding: 5px 12px; font: inherit; font-size: .82rem; font-weight: 600; cursor: pointer; }
|
| 48 |
+
.fh-upsample:hover { background: var(--fh-blue-soft); }
|
| 49 |
+
.fh-upsample[disabled] { opacity: .6; cursor: progress; }
|
| 50 |
+
.fh-upsample.is-busy .fh-spark { animation: fh-spin 1s linear infinite; display: inline-block; }
|
| 51 |
+
@keyframes fh-spin { to { transform: rotate(360deg); } }
|
| 52 |
+
.fh-pane { display: flex; flex-direction: column; gap: 10px; }
|
| 53 |
+
.fh-field { display: flex; flex-direction: column; gap: 5px; flex: 1; min-width: 0; }
|
| 54 |
+
.fh-label { font-size: .78rem; font-weight: 600; color: var(--body-text-color-subdued); }
|
| 55 |
+
.fh-row { display: flex; gap: 10px; flex-wrap: wrap; }
|
| 56 |
+
.fh-composer textarea, .fh-composer input[type=text] { width: 100%; box-sizing: border-box; border: 1px solid var(--border-color-primary);
|
| 57 |
+
border-radius: 8px; background: var(--input-background-fill); color: var(--body-text-color); font: inherit; font-size: .9rem; padding: 8px 10px; resize: vertical; }
|
| 58 |
+
.fh-composer textarea:focus, .fh-composer input[type=text]:focus { outline: none; border-color: var(--fh-blue); box-shadow: 0 0 0 3px var(--fh-blue-soft); }
|
| 59 |
+
.fh-composer textarea[data-write] { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .82rem; line-height: 1.5; }
|
| 60 |
+
.fh-refdescs { display: flex; flex-direction: column; gap: 8px; }
|
| 61 |
+
.fh-refdesc { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 8px; }
|
| 62 |
+
.fh-refdesc .fh-refdesc-lead { display: flex; flex-direction: column; align-items: flex-start; gap: 3px; min-width: 92px; }
|
| 63 |
+
.fh-refdesc .fh-arrow { font-size: .68rem; color: var(--body-text-color-subdued); }
|
| 64 |
+
.fh-empty { font-size: .82rem; color: var(--body-text-color-subdued); padding: 6px 0; }
|
| 65 |
+
.fh-chipbar { display: flex; flex-wrap: wrap; gap: 5px; align-items: center; min-height: 0; }
|
| 66 |
+
.fh-chipbar:empty { display: none; }
|
| 67 |
+
.fh-chipbar .fh-chipbar-label { font-size: .7rem; color: var(--body-text-color-subdued); margin-right: 2px; }
|
| 68 |
+
.fh-preview summary { cursor: pointer; font-size: .78rem; font-weight: 600; color: var(--body-text-color-subdued); display: flex; align-items: center; gap: 10px; }
|
| 69 |
+
.fh-preview pre { white-space: pre-wrap; font-size: .74rem; line-height: 1.45; max-height: 260px; overflow: auto; background: var(--background-fill-secondary);
|
| 70 |
+
border-radius: 8px; padding: 10px; margin: 8px 0 0; }
|
| 71 |
+
.fh-link { border: none; background: none; color: var(--fh-blue); font: inherit; font-size: .78rem; cursor: pointer; padding: 0; }
|
| 72 |
+
.fh-lint { font-size: .75rem; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; color: var(--body-text-color-subdued); }
|
| 73 |
+
.fh-lint .is-ok { color: #10b981; }
|
| 74 |
+
.fh-lint .is-warn { color: #d97706; }
|
| 75 |
+
.fh-lint .is-err { color: #ef4444; }
|
| 76 |
+
.fh-examples { display: flex; flex-direction: column; gap: 8px; }
|
| 77 |
+
.fh-example-row { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 2px; }
|
| 78 |
+
.fh-example { display: flex; align-items: center; gap: 8px; border: 1px solid var(--border-color-primary); border-radius: 10px; padding: 6px 10px 6px 6px;
|
| 79 |
+
background: transparent; color: var(--body-text-color); cursor: pointer; font: inherit; font-size: .8rem; text-align: left; min-width: 0; flex: 1 1 0; }
|
| 80 |
+
.fh-example:hover { border-color: var(--fh-blue); background: var(--fh-blue-soft); }
|
| 81 |
+
.fh-example img { width: 44px; height: 44px; border-radius: 7px; object-fit: cover; flex: none; }
|
| 82 |
+
.fh-example span { overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
| 83 |
+
.fh-hidden { display: none !important; }
|
composer.html
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div id="fh-composer" class="fh-composer">
|
| 2 |
+
<section class="fh-section">
|
| 3 |
+
<div class="fh-section-head">
|
| 4 |
+
<span class="fh-title">References</span>
|
| 5 |
+
<span class="fh-counts" data-counts></span>
|
| 6 |
+
</div>
|
| 7 |
+
<div class="fh-tray" data-tray>
|
| 8 |
+
<button type="button" class="fh-drop" data-drop>
|
| 9 |
+
<span class="fh-drop-icon">+</span>
|
| 10 |
+
<span class="fh-drop-text">Drop images, videos or audio<br><small>or click to add · order matters</small></span>
|
| 11 |
+
</button>
|
| 12 |
+
</div>
|
| 13 |
+
<input type="file" data-file multiple accept="image/*,video/*,audio/*" hidden>
|
| 14 |
+
<div class="fh-hint" data-ref-hint></div>
|
| 15 |
+
</section>
|
| 16 |
+
|
| 17 |
+
<section class="fh-section">
|
| 18 |
+
<div class="fh-section-head">
|
| 19 |
+
<div class="fh-seg" role="tablist">
|
| 20 |
+
<button type="button" class="fh-seg-btn is-active" data-mode="builder">Builder</button>
|
| 21 |
+
<button type="button" class="fh-seg-btn" data-mode="write">Write</button>
|
| 22 |
+
</div>
|
| 23 |
+
<button type="button" class="fh-upsample" data-upsample title="Rewrite into a detailed H3 prompt with the MiniMax-H3 rewriter">
|
| 24 |
+
<span class="fh-spark">✦</span><span data-upsample-label>Upsample</span>
|
| 25 |
+
</button>
|
| 26 |
+
</div>
|
| 27 |
+
|
| 28 |
+
<div class="fh-pane" data-pane="builder">
|
| 29 |
+
<div class="fh-refdescs" data-refdescs></div>
|
| 30 |
+
<label class="fh-field">
|
| 31 |
+
<span class="fh-label">Action & scene</span>
|
| 32 |
+
<div class="fh-chipbar" data-subject-chips></div>
|
| 33 |
+
<textarea data-b="scene" rows="3" placeholder="<Subject 1> walks along a sunlit beach promenade at golden hour, glancing toward the camera…"></textarea>
|
| 34 |
+
</label>
|
| 35 |
+
<div class="fh-row">
|
| 36 |
+
<label class="fh-field">
|
| 37 |
+
<span class="fh-label">Soundscape</span>
|
| 38 |
+
<input data-b="sound" type="text" placeholder="ocean waves, a gentle breeze and distant seagulls">
|
| 39 |
+
</label>
|
| 40 |
+
<label class="fh-field">
|
| 41 |
+
<span class="fh-label">Music</span>
|
| 42 |
+
<input data-b="music" type="text" placeholder="none">
|
| 43 |
+
</label>
|
| 44 |
+
</div>
|
| 45 |
+
<label class="fh-field">
|
| 46 |
+
<span class="fh-label">Style</span>
|
| 47 |
+
<input data-b="style" type="text" value="realistic photographic">
|
| 48 |
+
</label>
|
| 49 |
+
<details class="fh-preview">
|
| 50 |
+
<summary>H3 prompt preview <button type="button" class="fh-link" data-to-write>Edit as text →</button></summary>
|
| 51 |
+
<pre data-preview></pre>
|
| 52 |
+
</details>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<div class="fh-pane" data-pane="write" hidden>
|
| 56 |
+
<div class="fh-chipbar" data-write-chips></div>
|
| 57 |
+
<textarea data-write rows="9" placeholder="Describe the video and refer to each reference by its tag, e.g. <Picture 1> dances to <Audio 1> in a neon-lit club…"></textarea>
|
| 58 |
+
<div class="fh-lint" data-lint></div>
|
| 59 |
+
</div>
|
| 60 |
+
</section>
|
| 61 |
+
|
| 62 |
+
<section class="fh-section fh-examples">
|
| 63 |
+
<span class="fh-title">Try an example</span>
|
| 64 |
+
<div class="fh-example-row" data-examples></div>
|
| 65 |
+
</section>
|
| 66 |
+
</div>
|
composer.js
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const LIMITS = { image: 9, video: 3, audio: 3 };
|
| 2 |
+
const NAMES = { image: "Picture", video: "Video", audio: "Audio" };
|
| 3 |
+
const PLACEHOLDERS = {
|
| 4 |
+
image: "who or what is this? e.g. a woman with a curly afro in a white t-shirt",
|
| 5 |
+
video: "what to take from it? e.g. the dance moves and camera push",
|
| 6 |
+
audio: "what is it? e.g. her voice, or upbeat synth-pop",
|
| 7 |
+
};
|
| 8 |
+
const VIDEO_MIN = 2, VIDEO_MAX = 15;
|
| 9 |
+
const $ = (sel) => element.querySelector(sel);
|
| 10 |
+
|
| 11 |
+
const initial = props.value || {};
|
| 12 |
+
const state = {
|
| 13 |
+
mode: initial.mode || "builder",
|
| 14 |
+
refs: (initial.refs || []).map((ref) => ({ ...ref, id: ref.id || crypto.randomUUID() })),
|
| 15 |
+
builder: Object.assign({ scene: "", sound: "", music: "", style: "realistic photographic" }, initial.builder || {}),
|
| 16 |
+
write: initial.write || "",
|
| 17 |
+
writeEdited: !!initial.writeEdited,
|
| 18 |
+
};
|
| 19 |
+
let lastPushed = "";
|
| 20 |
+
let upsampling = false;
|
| 21 |
+
|
| 22 |
+
function kindOf(file) {
|
| 23 |
+
const type = file.type || "";
|
| 24 |
+
if (type.startsWith("image/")) return "image";
|
| 25 |
+
if (type.startsWith("video/")) return "video";
|
| 26 |
+
if (type.startsWith("audio/")) return "audio";
|
| 27 |
+
const ext = (file.name.split(".").pop() || "").toLowerCase();
|
| 28 |
+
if (["png", "jpg", "jpeg", "webp", "gif", "bmp"].includes(ext)) return "image";
|
| 29 |
+
if (["mp4", "mov", "webm", "mkv", "avi"].includes(ext)) return "video";
|
| 30 |
+
if (["wav", "mp3", "flac", "ogg", "m4a", "aac", "opus"].includes(ext)) return "audio";
|
| 31 |
+
return null;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function labelFor(index) {
|
| 35 |
+
const ref = state.refs[index];
|
| 36 |
+
let n = 0;
|
| 37 |
+
for (let i = 0; i <= index; i++) if (state.refs[i].kind === ref.kind) n++;
|
| 38 |
+
return `<${NAMES[ref.kind]} ${n}>`;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function labels() {
|
| 42 |
+
return state.refs.map((ref, index) => ({ ref, index, label: labelFor(index) }));
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function subjects() {
|
| 46 |
+
return labels().filter(({ ref }) => ref.kind !== "audio");
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
function count(kind) {
|
| 50 |
+
return state.refs.filter((ref) => ref.kind === kind).length;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function escapeHtml(text) {
|
| 54 |
+
return String(text).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function clean(text) {
|
| 58 |
+
return (text || "").trim().replace(/[.\s]+$/, "");
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function buildPrompt() {
|
| 62 |
+
const b = state.builder;
|
| 63 |
+
const all = labels();
|
| 64 |
+
const visual = subjects();
|
| 65 |
+
const audio = all.filter(({ ref }) => ref.kind === "audio");
|
| 66 |
+
const defs = [];
|
| 67 |
+
const retention = [];
|
| 68 |
+
visual.forEach(({ ref, label }, k) => {
|
| 69 |
+
const subject = `<Subject ${k + 1}>`;
|
| 70 |
+
const what = clean(ref.desc) || (ref.kind === "video" ? "the subject and motion of the reference video" : "the reference subject");
|
| 71 |
+
defs.push(`${subject} is ${what}, shown in ${label}.`);
|
| 72 |
+
retention.push(`${subject}: fully_preserved - the identity, appearance and details from ${label} are kept.`);
|
| 73 |
+
});
|
| 74 |
+
audio.forEach(({ ref, label }) => {
|
| 75 |
+
defs.push(`${label} is ${clean(ref.desc) || "the voice and sound reference"}.`);
|
| 76 |
+
});
|
| 77 |
+
const names = visual.map(({ ref }, k) => clean(ref.desc) || `<Subject ${k + 1}>`);
|
| 78 |
+
const summaryWho = names.length ? names.join(" and ") : "the scene";
|
| 79 |
+
let scene = (b.scene || "").trim();
|
| 80 |
+
if (scene && visual.length && !/<Subject \d+>/.test(scene) && /^[a-z]/.test(scene)) scene = `<Subject 1> ${scene}`;
|
| 81 |
+
const style = clean(b.style) || "realistic photographic";
|
| 82 |
+
let sound = clean(b.sound) || "natural ambient sound that matches the scene";
|
| 83 |
+
const unmentioned = audio.filter(({ label }) => !sound.includes(label)).map(({ label }) => label);
|
| 84 |
+
if (unmentioned.length) sound += `, following ${unmentioned.join(" and ")}`;
|
| 85 |
+
return [
|
| 86 |
+
`subject_definitions:\n${defs.join("\n") || "<Subject 1> is the subject of the video."}`,
|
| 87 |
+
`summary:\nA short video in a ${style} style of ${summaryWho}.`,
|
| 88 |
+
`retention_analysis:\n${retention.join("\n") || "none"}`,
|
| 89 |
+
`detailed_description:\nThe target video is in a ${style} style.\n[Shot 1] ${scene || "The subject moves naturally in the scene."}`,
|
| 90 |
+
`overall_soundscape:\n${sound}.`,
|
| 91 |
+
`non_diegetic_music:\n${clean(b.music) || "none"}.`,
|
| 92 |
+
].join("\n\n");
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
function upsampleSource() {
|
| 96 |
+
if (state.mode === "write") return state.write;
|
| 97 |
+
const parts = [];
|
| 98 |
+
subjects().forEach(({ ref, label }, k) => parts.push(`${label} shows ${clean(ref.desc) || `subject ${k + 1}`}.`));
|
| 99 |
+
labels().filter(({ ref }) => ref.kind === "audio").forEach(({ ref, label }) => parts.push(`${label} is ${clean(ref.desc) || "the voice and sound reference"}.`));
|
| 100 |
+
const b = state.builder;
|
| 101 |
+
let scene = (b.scene || "").replace(/<Subject (\d+)>/g, (_, n) => (subjects()[n - 1] ? clean(subjects()[n - 1].ref.desc) || `subject ${n}` : `subject ${n}`));
|
| 102 |
+
if (scene) parts.push(scene.trim());
|
| 103 |
+
if (clean(b.sound)) parts.push(`Sound: ${clean(b.sound)}.`);
|
| 104 |
+
if (clean(b.music) && clean(b.music).toLowerCase() !== "none") parts.push(`Music: ${clean(b.music)}.`);
|
| 105 |
+
if (clean(b.style) && clean(b.style) !== "realistic photographic") parts.push(`Style: ${clean(b.style)}.`);
|
| 106 |
+
return parts.join(" ");
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
function finalPrompt() {
|
| 110 |
+
return state.mode === "write" ? state.write : buildPrompt();
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
function push() {
|
| 114 |
+
const value = {
|
| 115 |
+
mode: state.mode,
|
| 116 |
+
refs: state.refs.map(({ id, kind, path, url, name, desc, duration, uploading, error }) => ({ id, kind, path, url, name, desc, duration, uploading: !!uploading, error: error || null })),
|
| 117 |
+
builder: { ...state.builder },
|
| 118 |
+
write: state.write,
|
| 119 |
+
writeEdited: state.writeEdited,
|
| 120 |
+
prompt: finalPrompt(),
|
| 121 |
+
upsample_source: upsampleSource(),
|
| 122 |
+
};
|
| 123 |
+
lastPushed = JSON.stringify(value);
|
| 124 |
+
props.value = value;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
function insertAt(target, text) {
|
| 128 |
+
const start = target.selectionStart ?? target.value.length;
|
| 129 |
+
const end = target.selectionEnd ?? target.value.length;
|
| 130 |
+
const before = target.value.slice(0, start);
|
| 131 |
+
const pad = before && !/\s$/.test(before) ? " " : "";
|
| 132 |
+
target.value = before + pad + text + " " + target.value.slice(end);
|
| 133 |
+
const caret = start + pad.length + text.length + 1;
|
| 134 |
+
target.focus();
|
| 135 |
+
target.setSelectionRange(caret, caret);
|
| 136 |
+
target.dispatchEvent(new Event("input", { bubbles: true }));
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
function tagButton(label, kind, onClick, extra = "") {
|
| 140 |
+
const button = document.createElement("button");
|
| 141 |
+
button.type = "button";
|
| 142 |
+
button.className = `fh-tag ${kind === "audio" ? "is-audio" : kind === "video" ? "is-video" : kind === "subject" ? "is-subject" : ""} ${extra}`;
|
| 143 |
+
button.textContent = label;
|
| 144 |
+
button.title = "Insert into the prompt";
|
| 145 |
+
button.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); onClick(); });
|
| 146 |
+
return button;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
function insertLabel(label) {
|
| 150 |
+
if (state.mode === "write") {
|
| 151 |
+
insertAt($("[data-write]"), label);
|
| 152 |
+
} else {
|
| 153 |
+
insertAt($('[data-b="scene"]'), label);
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
function fmtSeconds(seconds) {
|
| 158 |
+
return seconds == null ? "" : `${seconds.toFixed(1)}s`;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
function renderTray() {
|
| 162 |
+
const tray = $("[data-tray]");
|
| 163 |
+
tray.querySelectorAll(".fh-card").forEach((card) => card.remove());
|
| 164 |
+
const drop = $("[data-drop]");
|
| 165 |
+
labels().forEach(({ ref, index, label }) => {
|
| 166 |
+
const card = document.createElement("div");
|
| 167 |
+
card.className = "fh-card" + (ref.error ? " fh-bad" : "");
|
| 168 |
+
card.draggable = true;
|
| 169 |
+
card.dataset.index = index;
|
| 170 |
+
const thumb = document.createElement("div");
|
| 171 |
+
thumb.className = "fh-thumb";
|
| 172 |
+
if (ref.kind === "image") {
|
| 173 |
+
thumb.innerHTML = `<img alt="" src="${escapeHtml(ref.preview || ref.url || "")}">`;
|
| 174 |
+
} else if (ref.kind === "video") {
|
| 175 |
+
thumb.innerHTML = `<video muted playsinline preload="metadata" src="${escapeHtml((ref.preview || ref.url || "") + "#t=0.1")}"></video>`;
|
| 176 |
+
const video = thumb.querySelector("video");
|
| 177 |
+
card.addEventListener("mouseenter", () => video.play().catch(() => {}));
|
| 178 |
+
card.addEventListener("mouseleave", () => { video.pause(); });
|
| 179 |
+
} else {
|
| 180 |
+
thumb.innerHTML = `<span class="fh-audio-glyph">♫</span>`;
|
| 181 |
+
card.addEventListener("click", (event) => {
|
| 182 |
+
if (event.target.closest("button")) return;
|
| 183 |
+
if (!ref._audio) ref._audio = new Audio(ref.preview || ref.url);
|
| 184 |
+
ref._audio.paused ? ref._audio.play() : ref._audio.pause();
|
| 185 |
+
});
|
| 186 |
+
card.title = "Click to play";
|
| 187 |
+
}
|
| 188 |
+
card.appendChild(thumb);
|
| 189 |
+
const foot = document.createElement("div");
|
| 190 |
+
foot.className = "fh-card-foot";
|
| 191 |
+
foot.appendChild(tagButton(label, ref.kind, () => insertLabel(label)));
|
| 192 |
+
const meta = document.createElement("span");
|
| 193 |
+
meta.className = "fh-meta";
|
| 194 |
+
meta.textContent = ref.error ? "!" : fmtSeconds(ref.duration);
|
| 195 |
+
foot.appendChild(meta);
|
| 196 |
+
card.appendChild(foot);
|
| 197 |
+
const remove = document.createElement("button");
|
| 198 |
+
remove.type = "button";
|
| 199 |
+
remove.className = "fh-x";
|
| 200 |
+
remove.textContent = "×";
|
| 201 |
+
remove.title = "Remove";
|
| 202 |
+
remove.addEventListener("click", (event) => { event.stopPropagation(); removeRef(index); });
|
| 203 |
+
card.appendChild(remove);
|
| 204 |
+
if (ref.uploading) {
|
| 205 |
+
const busy = document.createElement("div");
|
| 206 |
+
busy.className = "fh-busy";
|
| 207 |
+
busy.textContent = "uploading…";
|
| 208 |
+
card.appendChild(busy);
|
| 209 |
+
}
|
| 210 |
+
card.addEventListener("dragstart", (event) => {
|
| 211 |
+
card.classList.add("is-dragging");
|
| 212 |
+
event.dataTransfer.setData("text/fh-index", String(index));
|
| 213 |
+
event.dataTransfer.effectAllowed = "move";
|
| 214 |
+
});
|
| 215 |
+
card.addEventListener("dragend", () => card.classList.remove("is-dragging"));
|
| 216 |
+
card.addEventListener("dragover", (event) => {
|
| 217 |
+
if (!event.dataTransfer.types.includes("text/fh-index")) return;
|
| 218 |
+
event.preventDefault();
|
| 219 |
+
card.classList.add("is-target");
|
| 220 |
+
});
|
| 221 |
+
card.addEventListener("dragleave", () => card.classList.remove("is-target"));
|
| 222 |
+
card.addEventListener("drop", (event) => {
|
| 223 |
+
const from = event.dataTransfer.getData("text/fh-index");
|
| 224 |
+
card.classList.remove("is-target");
|
| 225 |
+
if (from === "") return;
|
| 226 |
+
event.preventDefault();
|
| 227 |
+
event.stopPropagation();
|
| 228 |
+
moveRef(Number(from), index);
|
| 229 |
+
});
|
| 230 |
+
tray.insertBefore(card, drop);
|
| 231 |
+
});
|
| 232 |
+
drop.hidden = state.refs.length >= LIMITS.image + LIMITS.video + LIMITS.audio;
|
| 233 |
+
$("[data-counts]").textContent = `${count("image")}/${LIMITS.image} images · ${count("video")}/${LIMITS.video} videos · ${count("audio")}/${LIMITS.audio} audio`;
|
| 234 |
+
const hint = $("[data-ref-hint]");
|
| 235 |
+
const bad = state.refs.find((ref) => ref.error);
|
| 236 |
+
if (bad) {
|
| 237 |
+
hint.textContent = bad.error;
|
| 238 |
+
hint.className = "fh-hint is-error";
|
| 239 |
+
} else if (!state.refs.length) {
|
| 240 |
+
hint.textContent = "Add at least one image or video. Each reference gets a tag you can use in the prompt.";
|
| 241 |
+
hint.className = "fh-hint";
|
| 242 |
+
} else if (!state.refs.some((ref) => ref.kind !== "audio")) {
|
| 243 |
+
hint.textContent = "Audio needs an image or a video alongside it.";
|
| 244 |
+
hint.className = "fh-hint is-error";
|
| 245 |
+
} else {
|
| 246 |
+
hint.textContent = "Drag to reorder: the order numbers the tags. Click a tag to insert it into the prompt.";
|
| 247 |
+
hint.className = "fh-hint";
|
| 248 |
+
}
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
function renderBuilder() {
|
| 252 |
+
const host = $("[data-refdescs]");
|
| 253 |
+
host.innerHTML = "";
|
| 254 |
+
if (!state.refs.length) {
|
| 255 |
+
host.innerHTML = `<div class="fh-empty">Add references above, then describe what each one is.</div>`;
|
| 256 |
+
}
|
| 257 |
+
let subjectIndex = 0;
|
| 258 |
+
labels().forEach(({ ref, label }) => {
|
| 259 |
+
const row = document.createElement("div");
|
| 260 |
+
row.className = "fh-refdesc";
|
| 261 |
+
const lead = document.createElement("div");
|
| 262 |
+
lead.className = "fh-refdesc-lead";
|
| 263 |
+
lead.appendChild(tagButton(label, ref.kind, () => insertLabel(label)));
|
| 264 |
+
if (ref.kind !== "audio") {
|
| 265 |
+
subjectIndex++;
|
| 266 |
+
const arrow = document.createElement("span");
|
| 267 |
+
arrow.className = "fh-arrow";
|
| 268 |
+
arrow.textContent = `→ <Subject ${subjectIndex}>`;
|
| 269 |
+
lead.appendChild(arrow);
|
| 270 |
+
}
|
| 271 |
+
const input = document.createElement("input");
|
| 272 |
+
input.type = "text";
|
| 273 |
+
input.placeholder = PLACEHOLDERS[ref.kind];
|
| 274 |
+
input.value = ref.desc || "";
|
| 275 |
+
input.addEventListener("input", () => { ref.desc = input.value; renderSubjectChips(); renderPreview(); push(); });
|
| 276 |
+
row.appendChild(lead);
|
| 277 |
+
row.appendChild(input);
|
| 278 |
+
host.appendChild(row);
|
| 279 |
+
});
|
| 280 |
+
renderSubjectChips();
|
| 281 |
+
renderPreview();
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
function renderSubjectChips() {
|
| 285 |
+
const bar = $("[data-subject-chips]");
|
| 286 |
+
bar.innerHTML = "";
|
| 287 |
+
const visual = subjects();
|
| 288 |
+
if (!visual.length) return;
|
| 289 |
+
const lead = document.createElement("span");
|
| 290 |
+
lead.className = "fh-chipbar-label";
|
| 291 |
+
lead.textContent = "Insert:";
|
| 292 |
+
bar.appendChild(lead);
|
| 293 |
+
visual.forEach(({ ref }, k) => {
|
| 294 |
+
const label = `<Subject ${k + 1}>`;
|
| 295 |
+
const chip = tagButton(label, "subject", () => insertAt($('[data-b="scene"]'), label));
|
| 296 |
+
if (clean(ref.desc)) chip.title = clean(ref.desc);
|
| 297 |
+
bar.appendChild(chip);
|
| 298 |
+
});
|
| 299 |
+
labels().filter(({ ref }) => ref.kind === "audio").forEach(({ label }) => {
|
| 300 |
+
bar.appendChild(tagButton(label, "audio", () => insertAt($('[data-b="sound"]'), label)));
|
| 301 |
+
});
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
function renderPreview() {
|
| 305 |
+
$("[data-preview]").textContent = buildPrompt();
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
function renderWrite() {
|
| 309 |
+
const bar = $("[data-write-chips]");
|
| 310 |
+
bar.innerHTML = "";
|
| 311 |
+
if (state.refs.length) {
|
| 312 |
+
const lead = document.createElement("span");
|
| 313 |
+
lead.className = "fh-chipbar-label";
|
| 314 |
+
lead.textContent = "Insert:";
|
| 315 |
+
bar.appendChild(lead);
|
| 316 |
+
}
|
| 317 |
+
const text = state.write || "";
|
| 318 |
+
labels().forEach(({ ref, label }) => {
|
| 319 |
+
bar.appendChild(tagButton(label, ref.kind, () => insertAt($("[data-write]"), label), text.includes(label) ? "" : "is-missing"));
|
| 320 |
+
});
|
| 321 |
+
const textarea = $("[data-write]");
|
| 322 |
+
if (textarea.value !== text) textarea.value = text;
|
| 323 |
+
renderLint();
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
function renderLint() {
|
| 327 |
+
const lint = $("[data-lint]");
|
| 328 |
+
const text = state.write || "";
|
| 329 |
+
const known = new Set(labels().map(({ label }) => label));
|
| 330 |
+
const used = [...new Set(text.match(/<(Picture|Video|Audio) \d+>/g) || [])];
|
| 331 |
+
const unknown = used.filter((label) => !known.has(label));
|
| 332 |
+
const missing = [...known].filter((label) => !text.includes(label));
|
| 333 |
+
const structured = /subject_definitions:/.test(text);
|
| 334 |
+
const parts = [];
|
| 335 |
+
if (text.includes("<removed>")) parts.push(`<span class="is-err">The prompt still refers to a removed reference (<removed>)</span>`);
|
| 336 |
+
if (!text.trim()) parts.push(`<span>Write a prompt, or switch to Builder.</span>`);
|
| 337 |
+
if (unknown.length) parts.push(`<span class="is-err">No reference for ${escapeHtml(unknown.join(", "))}</span>`);
|
| 338 |
+
if (missing.length && text.trim()) parts.push(`<span class="is-warn">Not mentioned: ${escapeHtml(missing.join(", "))}</span>`);
|
| 339 |
+
if (text.trim() && !unknown.length && !missing.length && !text.includes("<removed>")) parts.push(`<span class="is-ok">✓ every reference is mentioned</span>`);
|
| 340 |
+
if (text.trim()) parts.push(`<span>${structured ? "H3 six-section prompt, used as is" : "Plain prompt, wrapped into the H3 format on generate"}</span>`);
|
| 341 |
+
lint.innerHTML = parts.join("");
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
function renderMode() {
|
| 345 |
+
element.querySelectorAll("[data-mode]").forEach((button) => button.classList.toggle("is-active", button.dataset.mode === state.mode));
|
| 346 |
+
$('[data-pane="builder"]').hidden = state.mode !== "builder";
|
| 347 |
+
$('[data-pane="write"]').hidden = state.mode !== "write";
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
function renderBuilderFields() {
|
| 351 |
+
element.querySelectorAll("[data-b]").forEach((input) => {
|
| 352 |
+
const key = input.dataset.b;
|
| 353 |
+
if (input.value !== (state.builder[key] || "")) input.value = state.builder[key] || "";
|
| 354 |
+
});
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
function renderAll() {
|
| 358 |
+
renderMode();
|
| 359 |
+
renderTray();
|
| 360 |
+
renderBuilderFields();
|
| 361 |
+
renderBuilder();
|
| 362 |
+
renderWrite();
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
function relabelText(before) {
|
| 366 |
+
if (!state.write) return;
|
| 367 |
+
if (!state.writeEdited) {
|
| 368 |
+
state.write = buildPrompt();
|
| 369 |
+
return;
|
| 370 |
+
}
|
| 371 |
+
const after = labels();
|
| 372 |
+
const map = {};
|
| 373 |
+
before.forEach(({ id, label }) => {
|
| 374 |
+
const now = after.find(({ ref }) => ref.id === id);
|
| 375 |
+
map[label] = now ? now.label : null;
|
| 376 |
+
});
|
| 377 |
+
state.write = state.write.replace(/<(Picture|Video|Audio) \d+>/g, (label) => (label in map ? (map[label] === null ? "<removed>" : `\u0000${map[label]}\u0000`) : label)).replace(/\u0000/g, "");
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
function snapshotLabels() {
|
| 381 |
+
return labels().map(({ ref, label }) => ({ id: ref.id, label }));
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
function removeRef(index) {
|
| 385 |
+
const before = snapshotLabels();
|
| 386 |
+
const [ref] = state.refs.splice(index, 1);
|
| 387 |
+
if (ref && ref._audio) ref._audio.pause();
|
| 388 |
+
relabelText(before);
|
| 389 |
+
renderAll();
|
| 390 |
+
push();
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
function moveRef(from, to) {
|
| 394 |
+
if (from === to) return;
|
| 395 |
+
const before = snapshotLabels();
|
| 396 |
+
const [ref] = state.refs.splice(from, 1);
|
| 397 |
+
state.refs.splice(to, 0, ref);
|
| 398 |
+
relabelText(before);
|
| 399 |
+
renderAll();
|
| 400 |
+
push();
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
function mediaDuration(url, kind) {
|
| 404 |
+
return new Promise((resolve) => {
|
| 405 |
+
const media = document.createElement(kind === "video" ? "video" : "audio");
|
| 406 |
+
media.preload = "metadata";
|
| 407 |
+
media.onloadedmetadata = () => resolve(Number.isFinite(media.duration) ? media.duration : null);
|
| 408 |
+
media.onerror = () => resolve(null);
|
| 409 |
+
media.src = url;
|
| 410 |
+
});
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
async function addFiles(files) {
|
| 414 |
+
for (const file of files) {
|
| 415 |
+
const kind = kindOf(file);
|
| 416 |
+
if (!kind) continue;
|
| 417 |
+
if (count(kind) >= LIMITS[kind]) {
|
| 418 |
+
const hint = $("[data-ref-hint]");
|
| 419 |
+
hint.textContent = `At most ${LIMITS[kind]} ${kind} references.`;
|
| 420 |
+
hint.className = "fh-hint is-error";
|
| 421 |
+
continue;
|
| 422 |
+
}
|
| 423 |
+
const ref = { id: crypto.randomUUID(), kind, name: file.name, desc: "", preview: URL.createObjectURL(file), uploading: true };
|
| 424 |
+
state.refs.push(ref);
|
| 425 |
+
if (kind !== "image") {
|
| 426 |
+
ref.duration = await mediaDuration(ref.preview, kind);
|
| 427 |
+
if (kind === "video" && ref.duration != null && (ref.duration < VIDEO_MIN || ref.duration > VIDEO_MAX)) {
|
| 428 |
+
ref.error = `${file.name} is ${ref.duration.toFixed(1)} s; reference videos must be ${VIDEO_MIN}–${VIDEO_MAX} s.`;
|
| 429 |
+
}
|
| 430 |
+
}
|
| 431 |
+
renderAll();
|
| 432 |
+
push();
|
| 433 |
+
upload(file).then(({ path, url }) => {
|
| 434 |
+
ref.path = path;
|
| 435 |
+
ref.url = url;
|
| 436 |
+
ref.uploading = false;
|
| 437 |
+
renderTray();
|
| 438 |
+
push();
|
| 439 |
+
}).catch((error) => {
|
| 440 |
+
ref.uploading = false;
|
| 441 |
+
ref.error = `Upload failed for ${file.name}: ${error}`;
|
| 442 |
+
renderTray();
|
| 443 |
+
push();
|
| 444 |
+
});
|
| 445 |
+
}
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
const fileInput = $("[data-file]");
|
| 449 |
+
$("[data-drop]").addEventListener("click", () => fileInput.click());
|
| 450 |
+
fileInput.addEventListener("change", () => { addFiles([...fileInput.files]); fileInput.value = ""; });
|
| 451 |
+
const tray = $("[data-tray]");
|
| 452 |
+
tray.addEventListener("dragover", (event) => {
|
| 453 |
+
if (!event.dataTransfer.types.includes("Files")) return;
|
| 454 |
+
event.preventDefault();
|
| 455 |
+
tray.classList.add("is-over");
|
| 456 |
+
});
|
| 457 |
+
tray.addEventListener("dragleave", (event) => { if (!tray.contains(event.relatedTarget)) tray.classList.remove("is-over"); });
|
| 458 |
+
tray.addEventListener("drop", (event) => {
|
| 459 |
+
tray.classList.remove("is-over");
|
| 460 |
+
if (!event.dataTransfer.files.length) return;
|
| 461 |
+
event.preventDefault();
|
| 462 |
+
addFiles([...event.dataTransfer.files]);
|
| 463 |
+
});
|
| 464 |
+
|
| 465 |
+
element.querySelectorAll("[data-mode]").forEach((button) => button.addEventListener("click", () => {
|
| 466 |
+
if (button.dataset.mode === state.mode) return;
|
| 467 |
+
if (button.dataset.mode === "write" && (!state.write.trim() || !state.writeEdited)) {
|
| 468 |
+
state.write = buildPrompt();
|
| 469 |
+
state.writeEdited = false;
|
| 470 |
+
}
|
| 471 |
+
state.mode = button.dataset.mode;
|
| 472 |
+
renderAll();
|
| 473 |
+
push();
|
| 474 |
+
}));
|
| 475 |
+
|
| 476 |
+
$("[data-to-write]").addEventListener("click", (event) => {
|
| 477 |
+
event.preventDefault();
|
| 478 |
+
state.write = buildPrompt();
|
| 479 |
+
state.writeEdited = false;
|
| 480 |
+
state.mode = "write";
|
| 481 |
+
renderAll();
|
| 482 |
+
push();
|
| 483 |
+
});
|
| 484 |
+
|
| 485 |
+
element.querySelectorAll("[data-b]").forEach((input) => input.addEventListener("input", () => {
|
| 486 |
+
state.builder[input.dataset.b] = input.value;
|
| 487 |
+
renderPreview();
|
| 488 |
+
push();
|
| 489 |
+
}));
|
| 490 |
+
|
| 491 |
+
$("[data-write]").addEventListener("input", (event) => {
|
| 492 |
+
state.write = event.target.value;
|
| 493 |
+
state.writeEdited = true;
|
| 494 |
+
renderWrite();
|
| 495 |
+
push();
|
| 496 |
+
});
|
| 497 |
+
|
| 498 |
+
const upsampleButton = $("[data-upsample]");
|
| 499 |
+
function setUpsampling(busy) {
|
| 500 |
+
upsampling = busy;
|
| 501 |
+
upsampleButton.disabled = busy;
|
| 502 |
+
upsampleButton.classList.toggle("is-busy", busy);
|
| 503 |
+
$("[data-upsample-label]").textContent = busy ? "Upsampling…" : "Upsample";
|
| 504 |
+
}
|
| 505 |
+
upsampleButton.addEventListener("click", () => {
|
| 506 |
+
if (upsampling) return;
|
| 507 |
+
setUpsampling(true);
|
| 508 |
+
push();
|
| 509 |
+
trigger("edit");
|
| 510 |
+
});
|
| 511 |
+
|
| 512 |
+
function loadExample(example) {
|
| 513 |
+
state.refs = example.refs.map((ref) => ({ ...ref, id: crypto.randomUUID(), preview: ref.url }));
|
| 514 |
+
state.builder = Object.assign({ scene: "", sound: "", music: "", style: "realistic photographic" }, example.builder);
|
| 515 |
+
state.write = "";
|
| 516 |
+
state.writeEdited = false;
|
| 517 |
+
state.mode = "builder";
|
| 518 |
+
renderAll();
|
| 519 |
+
push();
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
const examples = (props.value && props.value.examples) || [];
|
| 523 |
+
const exampleRow = $("[data-examples]");
|
| 524 |
+
examples.forEach((example) => {
|
| 525 |
+
const button = document.createElement("button");
|
| 526 |
+
button.type = "button";
|
| 527 |
+
button.className = "fh-example";
|
| 528 |
+
button.innerHTML = `<img alt="" src="${escapeHtml(example.refs[0].url)}"><span>${escapeHtml(example.title)}</span>`;
|
| 529 |
+
button.addEventListener("click", () => loadExample(example));
|
| 530 |
+
exampleRow.appendChild(button);
|
| 531 |
+
});
|
| 532 |
+
if (!examples.length) $(".fh-examples").hidden = true;
|
| 533 |
+
|
| 534 |
+
watch("value", () => {
|
| 535 |
+
const incoming = props.value || {};
|
| 536 |
+
if (JSON.stringify(incoming) === lastPushed) return;
|
| 537 |
+
if ("upsampled" in incoming) {
|
| 538 |
+
setUpsampling(false);
|
| 539 |
+
if (incoming.upsampled) {
|
| 540 |
+
state.write = incoming.upsampled;
|
| 541 |
+
state.writeEdited = true;
|
| 542 |
+
state.mode = "write";
|
| 543 |
+
}
|
| 544 |
+
renderAll();
|
| 545 |
+
push();
|
| 546 |
+
}
|
| 547 |
+
});
|
| 548 |
+
|
| 549 |
+
if (!state.refs.length && examples.length) {
|
| 550 |
+
loadExample(examples[0]);
|
| 551 |
+
} else {
|
| 552 |
+
renderAll();
|
| 553 |
+
push();
|
| 554 |
+
}
|
fasth3_engine.py
CHANGED
|
@@ -120,15 +120,13 @@ class FastH3OmniRefEngine:
|
|
| 120 |
self.generator = VideoGenerator.from_config(cfg)
|
| 121 |
print(f"[fasth3-fp8] generator ready in {time.time() - t0:.1f}s", flush=True)
|
| 122 |
|
| 123 |
-
def generate(self, prompt,
|
| 124 |
if self.generator is None:
|
| 125 |
raise RuntimeError("engine not initialized")
|
| 126 |
from fastvideo.api import GenerationRequest, InputConfig, OutputConfig, SamplingConfig
|
| 127 |
from fastvideo.pipelines.basic.minimax_h3 import MiniMaxH3Reference
|
| 128 |
|
| 129 |
-
references = [MiniMaxH3Reference(source=
|
| 130 |
-
if audio_path:
|
| 131 |
-
references.append(MiniMaxH3Reference(source=audio_path, media_type="audio"))
|
| 132 |
|
| 133 |
out_dir = tempfile.mkdtemp(prefix="fasth3_fp8_out_")
|
| 134 |
out_path = os.path.join(out_dir, "fasth3_omniref.mp4")
|
|
|
|
| 120 |
self.generator = VideoGenerator.from_config(cfg)
|
| 121 |
print(f"[fasth3-fp8] generator ready in {time.time() - t0:.1f}s", flush=True)
|
| 122 |
|
| 123 |
+
def generate(self, prompt, references, height, width, num_frames, seed) -> str:
|
| 124 |
if self.generator is None:
|
| 125 |
raise RuntimeError("engine not initialized")
|
| 126 |
from fastvideo.api import GenerationRequest, InputConfig, OutputConfig, SamplingConfig
|
| 127 |
from fastvideo.pipelines.basic.minimax_h3 import MiniMaxH3Reference
|
| 128 |
|
| 129 |
+
references = [MiniMaxH3Reference(source=path, media_type=kind) for kind, path in references]
|
|
|
|
|
|
|
| 130 |
|
| 131 |
out_dir = tempfile.mkdtemp(prefix="fasth3_fp8_out_")
|
| 132 |
out_path = os.path.join(out_dir, "fasth3_omniref.mp4")
|
requirements.txt
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
-
# FastVideo main @ the FastH3 OmniRef PDD + NVFP4 consumer-profile commit,
|
| 2 |
-
#
|
| 3 |
-
# transformers>=5.15.0. cu130 wheels for Blackwell sm_120
|
| 4 |
--extra-index-url https://download.pytorch.org/whl/cu130
|
| 5 |
-
fastvideo @
|
| 6 |
|
| 7 |
# NVFP4 FP4 GEMMs (packed linears + decoder) on Blackwell.
|
| 8 |
flashinfer-python
|
|
|
|
| 1 |
+
# FastVideo main @ 376e445f (the FastH3 OmniRef PDD + NVFP4 consumer-profile commit),
|
| 2 |
+
# rebuilt with its unused gradio==5.32.0 pin relaxed so the Space runs Gradio 6.
|
| 3 |
+
# Pulls torch==2.12.0, transformers>=5.15.0. cu130 wheels for Blackwell sm_120.
|
| 4 |
--extra-index-url https://download.pytorch.org/whl/cu130
|
| 5 |
+
fastvideo @ https://huggingface.co/datasets/multimodalart/zerogpu-blackwell-wheels/resolve/main/wheels/fastvideo/376e445-gradio-unpinned/fastvideo-0.2.1-py3-none-any.whl
|
| 6 |
|
| 7 |
# NVFP4 FP4 GEMMs (packed linears + decoder) on Blackwell.
|
| 8 |
flashinfer-python
|