FastH3-Omni: Gradio 6 reference composer UI

#1
by multimodalart HF Staff - opened
Files changed (7) hide show
  1. README.md +24 -10
  2. app.py +342 -148
  3. composer.css +83 -0
  4. composer.html +66 -0
  5. composer.js +554 -0
  6. fasth3_engine.py +2 -4
  7. requirements.txt +4 -4
README.md CHANGED
@@ -1,24 +1,38 @@
1
  ---
2
- title: FastH3 OmniRef · FP8 (benchmark)
3
  emoji: 🎬
4
  colorFrom: blue
5
  colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 5.32.0
8
  app_file: app.py
9
  python_version: "3.12"
10
  pinned: false
11
- short_description: FP8 variant of FastH3 OmniRef, for ZeroGPU benchmarking
12
  startup_duration_timeout: 1h
13
  models:
14
- - FastVideo/FastH3-OmniRef-FP8
 
 
 
 
 
 
15
  - MiniMaxAI/MiniMax-H3
16
  ---
17
 
18
- # FastH3 OmniRef · FP8 variant (ZeroGPU benchmark)
19
 
20
- A duplicate of `diffusers-internal-dev/fasth3-omniref` that runs the **FP8 W8A8**
21
- OmniRef export (`FastVideo/FastH3-OmniRef-FP8`) instead of the packed NVFP4
22
- export, to compare cold-start and warm generation time on the RTX PRO 6000
23
- ZeroGPU slice. Both variants run the 128-token VSA tiles through the bf16 Triton
24
- emulator (the FP4 sparse-attention kernel is absent from the published wheel).
 
 
 
 
 
 
 
 
 
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 OmniRef · reference-to-video (+audio) on ZeroGPU.
2
 
3
- Give one or more reference images (and optionally a reference audio clip),
4
- describe the subject and the scene, and the FastVideo FastH3 OmniRef student
5
- generates a short video with a matching 32 kHz soundtrack in eight fused forwards.
6
-
7
- Prompts are assembled into the model's six-section H3 format
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
- # --- resolution / length presets -------------------------------------------
37
- # Width/height must be multiples of 32; frame counts must be 17n + 5. The model
38
- # card validates at 768p, so that is the default; 480p is the fast option.
39
- RESOLUTIONS = {
40
- "Landscape · 1344×768 (recommended)": (1344, 768),
41
- "Portrait · 768×1344 (recommended)": (768, 1344),
42
- "Landscape · 832×480 (fast)": (832, 480),
43
- "Portrait · 480×832 (fast)": (480, 832),
44
- }
45
- DEFAULT_RES = "Landscape · 1344×768 (recommended)"
46
- # The model accepts 124-362 frames (5-15 s at 24 fps); frame counts align to 17n+5.
47
- LENGTHS = {
48
- "Standard · ~5.2s (124f)": 124,
49
- "Medium · ~6.6s (158f)": 158,
50
- "Long · ~8s (192f)": 192,
51
  }
52
- DEFAULT_LEN = "Standard · ~5.2s (124f)"
53
-
54
- DEFAULT_SUBJECT = "a person in a casual knit sweater"
55
- DEFAULT_SCENE = ("sits on a sofa in a warm, lamp-lit living room, looking at the camera and "
56
- "speaking calmly, with small natural head and hand movements.")
57
- DEFAULT_SOUND = "soft indoor room tone and the person's clear, calm speaking voice"
58
- DEFAULT_MUSIC = "none"
59
-
60
-
61
- def build_h3_prompt(subject: str, scene: str, soundscape: str, music: str, n_imgs: int) -> str:
62
- """Assemble the six-section H3 prompt the checkpoint was trained on."""
63
- subject = (subject or "the reference subject").strip().rstrip(".")
64
- scene = (scene or "").strip()
65
- pics = " and ".join(f"<Picture {i + 1}>" for i in range(max(1, n_imgs)))
66
- sound = (soundscape or "").strip().rstrip(".") or "natural ambient sound that matches the scene"
67
- mus = (music or "").strip().rstrip(".") or "none"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  return (
69
- f"subject_definitions:\n<Subject 1> is {subject}, shown in {pics}.\n\n"
70
- f"summary:\nA short, realistic live-action video of {subject}.\n\n"
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] <Subject 1> {scene}\n\n"
75
  f"overall_soundscape:\n{sound}.\n\n"
76
- f"non_diegetic_music:\n{mus}."
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
- w, h = RESOLUTIONS[DEFAULT_RES]
120
- img = os.path.join(os.path.dirname(os.path.abspath(__file__)), "examples", "woman.jpg")
121
- prompt = build_h3_prompt("the reference subject", "stands still and looks at the camera",
122
- "quiet room tone", "none", 1)
123
- eng.generate(prompt, [img], None, h, w, LENGTHS[DEFAULT_LEN], 0)
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 _save_image(arr_or_path, idx: int) -> str | None:
143
- if arr_or_path is None:
144
- return None
145
- tmp = tempfile.NamedTemporaryFile(suffix=f"_ref{idx}.png", delete=False)
146
- if isinstance(arr_or_path, str):
147
- Image.open(arr_or_path).convert("RGB").save(tmp.name)
148
- else:
149
- Image.fromarray(arr_or_path).convert("RGB").save(tmp.name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  return tmp.name
151
 
152
 
153
- def _gpu_duration(*args, **kwargs):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  # First call builds the generator (weights load + kernel JIT); later calls
155
- # reuse it. 768p denoise is heavier than 480p, so the warm window is larger.
156
- return 300 if (_ENGINE is not None) else 900
 
 
 
157
 
158
 
159
  @spaces.GPU(duration=_gpu_duration, size="xlarge")
160
- def generate(subject, scene, soundscape, music, raw_prompt, img1, img2, img3,
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
- path = eng.generate(prompt, images, audio, height, width, num_frames, seed)
175
- return path
 
 
 
 
 
 
 
 
 
176
 
177
 
178
  # --- UI ---------------------------------------------------------------------
179
  HEADER = """
180
  <div class="fh-header">
181
- <h1>FastH3 · <span class="fh-accent">OmniRef</span> reference-to-video</h1>
182
- <p>FastH3 OmniRef is FastVideo's 8-step reference-to-video student that turns reference images plus a
183
- prompt into a short clip with a matching soundtrack. Add one or more reference images (and optional audio),
184
- describe the subject and scene, and the prompt is assembled into the model's six-section H3 format for you.</p>
185
  <div class="fh-pills">
186
- <a href="https://huggingface.co/FastVideo/FastH3-OmniRef-NVFP4-QAD" target="_blank">FastH3 OmniRef</a>
 
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: 1200px !important; }
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: var(--primary-500, #2563eb); }
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
- #gen-btn { background: linear-gradient(90deg, #2563eb, #1d4ed8); color: #fff; border: none; }
 
 
204
  """
205
 
206
  THEME = gr.themes.Base(
207
- primary_hue="blue",
208
- secondary_hue="sky",
209
- neutral_hue="slate",
210
  font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
 
 
 
 
 
 
 
 
211
  )
212
 
213
- with gr.Blocks(title="FastH3 OmniRef", theme=THEME, css=CSS) as demo:
 
 
 
 
214
  gr.HTML(HEADER)
215
 
216
  with gr.Row():
217
  with gr.Column(scale=1):
218
- with gr.Row():
219
- img1 = gr.Image(label="Reference 1 · required", type="numpy", height=190)
220
- img2 = gr.Image(label="Reference 2 · optional", type="numpy", height=190)
221
- img3 = gr.Image(label="Reference 3 · optional", type="numpy", height=190)
222
- with gr.Accordion("Reference audio (optional)", open=False):
223
- audio = gr.Audio(label="Voice / sound for the clip to follow", type="filepath")
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 video", variant="primary", size="lg", elem_id="gen-btn")
236
  with gr.Accordion("Advanced options", open=False):
237
- soundscape = gr.Textbox(
238
- label="Soundscape — ambient / diegetic sound", value=DEFAULT_SOUND,
 
 
 
239
  )
240
- music = gr.Textbox(label="Non-diegetic music", value=DEFAULT_MUSIC)
241
- resolution = gr.Dropdown(
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
- raw_prompt = gr.Textbox(
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="Generated clip + soundtrack", height=420, autoplay=True)
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
- run.click(generate, inputs=inputs, outputs=[result], api_name="generate")
 
274
 
275
 
276
  if __name__ == "__main__":
277
- demo.queue(max_size=12).launch(server_name="0.0.0.0", server_port=7860, show_error=True)
 
 
 
 
 
 
 
 
 
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 &amp; 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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 (&lt;removed&gt;)</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, image_paths, audio_path, 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=p, media_type="image") for p in image_paths]
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, with
2
- # the in-process UniprocExecutor. Pulls torch==2.12.0, gradio==5.32.0,
3
- # transformers>=5.15.0. cu130 wheels for Blackwell sm_120 (RTX PRO 6000).
4
  --extra-index-url https://download.pytorch.org/whl/cu130
5
- fastvideo @ git+https://github.com/hao-ai-lab/FastVideo.git@376e445f0e8d192c2d9a5f28010a6b3a7012667e
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