sdfdsfsf32e3 commited on
Commit
3f96ccc
·
verified ·
1 Parent(s): 285a8bb

Upload 6 files

Browse files
Files changed (6) hide show
  1. aoti.py +43 -0
  2. app.py +719 -0
  3. lora_loader.py +158 -0
  4. loras.json +1 -0
  5. packages.txt +1 -0
  6. requirements.txt +16 -0
aoti.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Block-level AoT-Inductor loading for ZeroGPU.
3
+
4
+ `HunyuanVideo15Transformer3DModel._repeated_blocks` lists
5
+ ["HunyuanVideo15TransformerBlock", "HunyuanVideo15PatchEmbed", "HunyuanVideo15TokenRefiner"],
6
+ so this generic helper works unchanged from the Wan reference space -- but it needs a Hub repo
7
+ containing a `package.pt2` per block name, compiled for your exact GPU/CUDA/torch combination.
8
+ No public repo of prebuilt packages exists for HunyuanVideo 1.5 yet, so set AOTI_REPO only after
9
+ you build and upload your own. Without it the model still runs, just without AoTI.
10
+ """
11
+
12
+ from typing import cast
13
+
14
+ import torch
15
+ from huggingface_hub import hf_hub_download
16
+ from spaces.zero.torch.aoti import ZeroGPUCompiledModel
17
+ from spaces.zero.torch.aoti import ZeroGPUWeights
18
+ from torch._functorch._aot_autograd.subclass_parametrization import unwrap_tensor_subclass_parameters
19
+
20
+
21
+ def _shallow_clone_module(module: torch.nn.Module) -> torch.nn.Module:
22
+ clone = object.__new__(module.__class__)
23
+ clone.__dict__ = module.__dict__.copy()
24
+ clone._parameters = module._parameters.copy()
25
+ clone._buffers = module._buffers.copy()
26
+ clone._modules = {k: _shallow_clone_module(v) for k, v in module._modules.items() if v is not None}
27
+ return clone
28
+
29
+
30
+ def aoti_blocks_load(module: torch.nn.Module, repo_id: str, variant: str | None = None):
31
+ repeated_blocks = cast(list[str], module._repeated_blocks)
32
+ aoti_files = {name: hf_hub_download(
33
+ repo_id=repo_id,
34
+ filename='package.pt2',
35
+ subfolder=name if variant is None else f'{name}.{variant}',
36
+ ) for name in repeated_blocks}
37
+ for block_name, aoti_file in aoti_files.items():
38
+ for block in module.modules():
39
+ if block.__class__.__name__ == block_name:
40
+ block_ = _shallow_clone_module(block)
41
+ unwrap_tensor_subclass_parameters(block_)
42
+ weights = ZeroGPUWeights(block_.state_dict())
43
+ block.forward = ZeroGPUCompiledModel(aoti_file, weights)
app.py ADDED
@@ -0,0 +1,719 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ if os.getenv("SPACES_ZERO_GPU"):
3
+ os.system('pip install --upgrade --no-deps spaces')
4
+ import spaces
5
+ import copy
6
+ import gc
7
+ import random
8
+ import subprocess
9
+ import tempfile
10
+ import time
11
+ import uuid
12
+ import warnings
13
+
14
+ from tqdm import tqdm
15
+ import cv2
16
+ import numpy as np
17
+ import torch
18
+ import torch._dynamo
19
+ from torch.nn import functional as F
20
+ from PIL import Image
21
+
22
+ import gradio as gr
23
+ from diffusers import (
24
+ FlowMatchEulerDiscreteScheduler,
25
+ SASolverScheduler,
26
+ DEISMultistepScheduler,
27
+ UniPCMultistepScheduler,
28
+ DPMSolverMultistepScheduler,
29
+ DPMSolverSinglestepScheduler,
30
+ )
31
+ from diffusers import HunyuanVideo15ImageToVideoPipeline
32
+ from diffusers.utils.export_utils import export_to_video
33
+
34
+ from torchao.quantization import (
35
+ quantize_,
36
+ Float8DynamicActivationFloat8WeightConfig,
37
+ Int8WeightOnlyConfig,
38
+ )
39
+ import aoti
40
+ import lora_loader
41
+
42
+ os.environ["TOKENIZERS_PARALLELISM"] = "true"
43
+ warnings.filterwarnings("ignore")
44
+ IS_ZERO_GPU = bool(os.getenv("SPACES_ZERO_GPU"))
45
+
46
+ # --- FRAME EXTRACTION JS & LOGIC ---
47
+
48
+ # JS to grab timestamp from the output video
49
+ get_timestamp_js = """
50
+ function() {
51
+ // Select the video element specifically inside the component with id 'generated-video'
52
+ const video = document.querySelector('#generated-video video');
53
+
54
+ if (video) {
55
+ console.log("Video found! Time: " + video.currentTime);
56
+ return video.currentTime;
57
+ } else {
58
+ console.log("No video element found.");
59
+ return 0;
60
+ }
61
+ }
62
+ """
63
+
64
+
65
+ def extract_frame(video_path, timestamp):
66
+ # Safety check: if no video is present
67
+ if not video_path:
68
+ return None
69
+
70
+ print(f"Extracting frame at timestamp: {timestamp}")
71
+
72
+ cap = cv2.VideoCapture(video_path)
73
+
74
+ if not cap.isOpened():
75
+ return None
76
+
77
+ # Calculate frame number
78
+ fps = cap.get(cv2.CAP_PROP_FPS)
79
+ target_frame_num = int(float(timestamp) * fps)
80
+
81
+ # Cap total frames to prevent errors at the very end of video
82
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
83
+ if target_frame_num >= total_frames:
84
+ target_frame_num = total_frames - 1
85
+
86
+ # Set position
87
+ cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame_num)
88
+ ret, frame = cap.read()
89
+ cap.release()
90
+
91
+ if ret:
92
+ # Convert from BGR (OpenCV) to RGB (Gradio)
93
+ return cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
94
+
95
+ return None
96
+
97
+ # --- END FRAME EXTRACTION LOGIC ---
98
+
99
+
100
+ def clear_vram():
101
+ gc.collect()
102
+ torch.cuda.empty_cache()
103
+
104
+
105
+ # RIFE
106
+ if not os.path.exists("RIFEv4.26_0921.zip"):
107
+ print("Downloading RIFE Model...")
108
+ subprocess.run([
109
+ "wget", "-q",
110
+ "https://huggingface.co/thornmaze/RIFE/resolve/main/RIFEv4.26_0921.zip",
111
+ "-O", "RIFEv4.26_0921.zip"
112
+ ], check=True)
113
+ subprocess.run(["unzip", "-o", "RIFEv4.26_0921.zip"], check=True)
114
+
115
+ from train_log.RIFE_HDv3 import Model
116
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
117
+ rife_model = Model()
118
+ rife_model.load_model("train_log", -1)
119
+ rife_model.eval()
120
+
121
+
122
+ @torch.no_grad()
123
+ def interpolate_bits(frames_np, multiplier=2, scale=1.0):
124
+ """
125
+ Interpolation maintaining Numpy Float 0-1 format.
126
+ Args:
127
+ frames_np: Numpy Array (Time, Height, Width, Channels) - Float32 [0.0, 1.0]
128
+ multiplier: int (2, 4)
129
+ Returns:
130
+ List of Numpy Arrays (Height, Width, Channels) - Float32 [0.0, 1.0]
131
+ """
132
+
133
+ # Handle input shape
134
+ if isinstance(frames_np, list):
135
+ T = len(frames_np)
136
+ H, W, C = frames_np[0].shape
137
+ else:
138
+ T, H, W, C = frames_np.shape
139
+
140
+ # 1. No Interpolation Case
141
+ if multiplier < 2:
142
+ if isinstance(frames_np, np.ndarray):
143
+ return list(frames_np)
144
+ return frames_np
145
+
146
+ n_interp = multiplier - 1
147
+
148
+ # Pre-calc padding for RIFE (requires dimensions divisible by 32/scale)
149
+ tmp = max(128, int(128 / scale))
150
+ ph = ((H - 1) // tmp + 1) * tmp
151
+ pw = ((W - 1) // tmp + 1) * tmp
152
+ padding = (0, pw - W, 0, ph - H)
153
+
154
+ # Helper: Numpy (H, W, C) Float -> Tensor (1, C, H, W) Half
155
+ def to_tensor(frame_np):
156
+ # frame_np is float32 0-1
157
+ t = torch.from_numpy(frame_np).to(device)
158
+ # HWC -> CHW
159
+ t = t.permute(2, 0, 1).unsqueeze(0)
160
+ return F.pad(t, padding).half()
161
+
162
+ # Helper: Tensor (1, C, H, W) Half -> Numpy (H, W, C) Float
163
+ def from_tensor(tensor):
164
+ # Crop padding
165
+ t = tensor[0, :, :H, :W]
166
+ # CHW -> HWC
167
+ t = t.permute(1, 2, 0)
168
+ # Keep as float32, range 0-1
169
+ return t.float().cpu().numpy()
170
+
171
+ def make_inference(I0, I1, n):
172
+ if rife_model.version >= 3.9:
173
+ res = []
174
+ for i in range(n):
175
+ res.append(rife_model.inference(I0, I1, (i + 1) * 1. / (n + 1), scale))
176
+ return res
177
+ else:
178
+ middle = rife_model.inference(I0, I1, scale)
179
+ if n == 1:
180
+ return [middle]
181
+ first_half = make_inference(I0, middle, n=n // 2)
182
+ second_half = make_inference(middle, I1, n=n // 2)
183
+ if n % 2:
184
+ return [*first_half, middle, *second_half]
185
+ else:
186
+ return [*first_half, *second_half]
187
+
188
+ output_frames = []
189
+
190
+ # Process Frames
191
+ I1 = to_tensor(frames_np[0])
192
+ mid_tensors = []
193
+
194
+ total_steps = T - 1
195
+
196
+ with tqdm(total=total_steps, desc="Interpolating", unit="frame") as pbar:
197
+
198
+ for i in range(total_steps):
199
+ I0 = I1
200
+ # Add original frame to output
201
+ output_frames.append(from_tensor(I0))
202
+
203
+ # Load next frame
204
+ I1 = to_tensor(frames_np[i + 1])
205
+
206
+ # Generate intermediate frames
207
+ mid_tensors = make_inference(I0, I1, n_interp)
208
+
209
+ # Append intermediate frames
210
+ for mid in mid_tensors:
211
+ output_frames.append(from_tensor(mid))
212
+
213
+ if (i + 1) % 50 == 0:
214
+ pbar.update(50)
215
+ pbar.update(total_steps % 50)
216
+
217
+ # Add the very last frame
218
+ output_frames.append(from_tensor(I1))
219
+
220
+ # Cleanup
221
+ del I0, I1, mid_tensors
222
+ torch.cuda.empty_cache()
223
+
224
+ return output_frames
225
+
226
+
227
+ # HUNYUANVIDEO 1.5
228
+
229
+ # Step-distilled I2V: 8-12 steps, CFG 1.0, flow shift 7.0, 480p bucket base (target_size=640).
230
+ # Alternatives (set MODEL_ID):
231
+ # hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_i2v_distilled (50 steps, CFG 1.0, shift 5.0)
232
+ # hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_i2v (50 steps, CFG 6.0, shift 5.0)
233
+ # hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_i2v_distilled (50 steps, CFG 1.0, shift 7.0, target_size=960)
234
+ MODEL_ID = os.getenv(
235
+ "MODEL_ID",
236
+ "hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_i2v_step_distilled",
237
+ )
238
+
239
+ # Attention backend. H100/H200 -> _flash_3_hub, A100/4090 -> flash_hub, other -> sage_hub.
240
+ ATTN_BACKEND = os.getenv("ATTN_BACKEND", "_flash_3_hub")
241
+
242
+ # Optional prebuilt AoT-Inductor packages for HunyuanVideo15TransformerBlock etc.
243
+ # Unset by default: no public package repo exists for this architecture yet.
244
+ AOTI_REPO = os.getenv("AOTI_REPO")
245
+ AOTI_VARIANT = os.getenv("AOTI_VARIANT")
246
+
247
+ QUANTIZE = os.getenv("QUANTIZE", "1" if IS_ZERO_GPU else "0") == "1"
248
+
249
+ MAX_SEED = np.iinfo(np.int32).max
250
+
251
+ # HunyuanVideo 1.5 is a 24 fps model; the VAE compresses time 4x, so num_frames must be 4k+1.
252
+ FIXED_FPS = 24
253
+ MIN_FRAMES_MODEL = 25
254
+ MAX_FRAMES_MODEL = int(os.getenv("MAX_FRAMES", "121")) # 121 = the trained 5s length
255
+
256
+ MIN_DURATION = round(MIN_FRAMES_MODEL / FIXED_FPS, 1)
257
+ MAX_DURATION = round(MAX_FRAMES_MODEL / FIXED_FPS, 1)
258
+
259
+ DEFAULT_STEPS = int(os.getenv("DEFAULT_STEPS", "8"))
260
+ DEFAULT_SHIFT = float(os.getenv("DEFAULT_SHIFT", "7.0"))
261
+ DEFAULT_GUIDANCE = float(os.getenv("DEFAULT_GUIDANCE", "1.0"))
262
+
263
+ # Only flow-matching schedulers make sense for this model. FlowMatchEulerDiscrete is what the
264
+ # checkpoint ships with; the multistep solvers are run in their flow-prediction mode and are
265
+ # experimental here, especially on the meanflow step-distilled weights.
266
+ SCHEDULER_MAP = {
267
+ "FlowMatchEulerDiscrete": FlowMatchEulerDiscreteScheduler,
268
+ "UniPCMultistep": UniPCMultistepScheduler,
269
+ "DPMSolverMultistep": DPMSolverMultistepScheduler,
270
+ "DPMSolverSinglestep": DPMSolverSinglestepScheduler,
271
+ "DEISMultistep": DEISMultistepScheduler,
272
+ "SASolver": SASolverScheduler,
273
+ }
274
+
275
+ pipe = HunyuanVideo15ImageToVideoPipeline.from_pretrained(
276
+ MODEL_ID,
277
+ torch_dtype=torch.bfloat16,
278
+ ).to('cuda')
279
+ original_scheduler = copy.deepcopy(pipe.scheduler)
280
+
281
+ try:
282
+ pipe.transformer.set_attention_backend(ATTN_BACKEND)
283
+ print(f"Attention backend: {ATTN_BACKEND}")
284
+ except Exception as e:
285
+ print(f"Attention backend '{ATTN_BACKEND}' unavailable, using default: {e}")
286
+
287
+ # Fuse any `fuse_at_startup` LoRAs from the catalog before quantization: fused weights survive
288
+ # the fp8 conversion, runtime adapters may not.
289
+ try:
290
+ lora_loader.fuse_startup_loras(pipe)
291
+ except Exception as e:
292
+ print("Startup LoRA fusion skipped:", e)
293
+
294
+ if QUANTIZE:
295
+ # Qwen2.5-VL text encoder -> int8 weight only, DiT -> fp8 dynamic activations.
296
+ # The SigLIP image encoder, ByT5 and the VAE are small enough to leave alone.
297
+ try:
298
+ quantize_(pipe.text_encoder, Int8WeightOnlyConfig())
299
+ torch._dynamo.reset()
300
+ except Exception as e:
301
+ print("text_encoder quantization skipped:", e)
302
+ try:
303
+ quantize_(pipe.transformer, Float8DynamicActivationFloat8WeightConfig())
304
+ torch._dynamo.reset()
305
+ except Exception as e:
306
+ print("transformer quantization skipped:", e)
307
+
308
+ if AOTI_REPO:
309
+ try:
310
+ aoti.aoti_blocks_load(pipe.transformer, AOTI_REPO, variant=AOTI_VARIANT)
311
+ print(f"AoTI blocks loaded from {AOTI_REPO}")
312
+ except Exception as e:
313
+ print("AoTI load skipped:", e)
314
+
315
+ pipe.vae.enable_tiling()
316
+
317
+ default_prompt_i2v = "make this image come alive, cinematic motion, smooth animation"
318
+ default_negative_prompt = "overexposed, low quality, blurry details, subtitles, watermark, static, still frame, jpeg artifacts, deformed, disfigured, extra fingers, malformed hands, malformed face, cluttered background"
319
+
320
+
321
+ def model_title():
322
+ return "## HunyuanVideo 1.5 I2V 8.3B — Fast Preview"
323
+
324
+
325
+ def bucket_size(image: Image.Image):
326
+ """Resolution the pipeline will pick for this image (aspect-ratio bucket around target_size)."""
327
+ height, width = pipe.video_processor.calculate_default_height_width(
328
+ height=image.size[1], width=image.size[0], target_size=pipe.target_size
329
+ )
330
+ return width, height
331
+
332
+
333
+ def resize_image(image: Image.Image) -> Image.Image:
334
+ """Center-crop/resize to the exact bucket the pipeline would choose, so the cost estimate
335
+ below and the actual generation agree. Passing the result back is idempotent."""
336
+ width, height = bucket_size(image)
337
+ return pipe.video_processor.resize(image, height=height, width=width, resize_mode="crop")
338
+
339
+
340
+ def get_num_frames(duration_seconds: float):
341
+ raw = int(round(duration_seconds * FIXED_FPS))
342
+ raw = max(MIN_FRAMES_MODEL, min(MAX_FRAMES_MODEL, raw))
343
+ return ((raw - 1) // 4) * 4 + 1
344
+
345
+
346
+ def get_inference_duration(
347
+ resized_image,
348
+ prompt,
349
+ steps,
350
+ negative_prompt,
351
+ num_frames,
352
+ guidance_scale,
353
+ current_seed,
354
+ scheduler_name,
355
+ flow_shift,
356
+ frame_multiplier,
357
+ quality,
358
+ duration_seconds,
359
+ safe_mode,
360
+ lora_groups,
361
+ lora_scale,
362
+ custom_lora,
363
+ progress
364
+ ):
365
+ # Calibrated on the 480p step-distilled checkpoint at its base config (121 frames, 704x480).
366
+ # Re-tune BASE_STEP_DURATION from the "gen time passed" logs on your own hardware.
367
+ BASE_FRAMES_HEIGHT_WIDTH = 121 * 704 * 480
368
+ BASE_STEP_DURATION = float(os.getenv("BASE_STEP_DURATION", "3.5"))
369
+ width, height = resized_image.size
370
+ factor = num_frames * width * height / BASE_FRAMES_HEIGHT_WIDTH
371
+ step_duration = BASE_STEP_DURATION * factor ** 1.5
372
+ gen_time = int(steps) * step_duration
373
+
374
+ # guidance > 1 turns CFG back on -> two transformer passes per step
375
+ if guidance_scale > 1:
376
+ gen_time = gen_time * 2.0
377
+
378
+ frame_factor = frame_multiplier // FIXED_FPS
379
+ if frame_factor > 1:
380
+ total_out_frames = (num_frames * frame_factor) - num_frames
381
+ inter_time = (total_out_frames * 0.02)
382
+ gen_time += inter_time
383
+
384
+ total_time = 15 + gen_time
385
+ if safe_mode:
386
+ total_time = total_time * 1.30
387
+
388
+ return total_time
389
+
390
+
391
+ def _apply_scheduler(scheduler_name, flow_shift):
392
+ scheduler_class = SCHEDULER_MAP.get(scheduler_name, FlowMatchEulerDiscreteScheduler)
393
+
394
+ if scheduler_class is FlowMatchEulerDiscreteScheduler:
395
+ current = pipe.scheduler
396
+ if current.config._class_name == "FlowMatchEulerDiscreteScheduler" and \
397
+ float(current.config.get("shift", -1)) == float(flow_shift):
398
+ return
399
+ config = copy.deepcopy(original_scheduler.config)
400
+ config["shift"] = float(flow_shift)
401
+ pipe.scheduler = FlowMatchEulerDiscreteScheduler.from_config(config)
402
+ return
403
+
404
+ try:
405
+ pipe.scheduler = scheduler_class.from_config({
406
+ "num_train_timesteps": 1000,
407
+ "prediction_type": "flow_prediction",
408
+ "use_flow_sigmas": True,
409
+ "flow_shift": float(flow_shift),
410
+ })
411
+ except Exception as e:
412
+ print(f"Scheduler '{scheduler_name}' failed ({e}); falling back to FlowMatchEulerDiscrete.")
413
+ config = copy.deepcopy(original_scheduler.config)
414
+ config["shift"] = float(flow_shift)
415
+ pipe.scheduler = FlowMatchEulerDiscreteScheduler.from_config(config)
416
+
417
+
418
+ def _apply_guidance(guidance_scale):
419
+ """HunyuanVideo 1.5 takes CFG through a guider object, not a __call__ argument."""
420
+ try:
421
+ if float(pipe.guider.config.guidance_scale) != float(guidance_scale):
422
+ pipe.guider = pipe.guider.new(guidance_scale=float(guidance_scale))
423
+ except Exception as e:
424
+ print("Could not update guider:", e)
425
+
426
+
427
+ @spaces.GPU(duration=get_inference_duration, size='xlarge')
428
+ def run_inference(
429
+ resized_image,
430
+ prompt,
431
+ steps,
432
+ negative_prompt,
433
+ num_frames,
434
+ guidance_scale,
435
+ current_seed,
436
+ scheduler_name,
437
+ flow_shift,
438
+ frame_multiplier,
439
+ quality,
440
+ duration_seconds,
441
+ safe_mode=False,
442
+ lora_groups=None,
443
+ lora_scale=1.0,
444
+ custom_lora="",
445
+ progress=gr.Progress(track_tqdm=True),
446
+ ):
447
+ _apply_scheduler(scheduler_name, flow_shift)
448
+ _apply_guidance(guidance_scale)
449
+
450
+ clear_vram()
451
+
452
+ task_name = str(uuid.uuid4())[:8]
453
+ print(f"Generating {num_frames} frames, task: {task_name}, {duration_seconds}, {resized_image.size}, lora={lora_groups}")
454
+ start = time.time()
455
+
456
+ lora_loaded = False
457
+ try:
458
+ lora_loaded = lora_loader.load_loras_to_pipe(
459
+ pipe, lora_groups, custom_lora, scale=float(lora_scale)
460
+ )
461
+ except Exception as e:
462
+ print(f"LoRA warning: {e}")
463
+ lora_loader.unload_lora(pipe)
464
+
465
+ result = pipe(
466
+ image=resized_image,
467
+ prompt=prompt,
468
+ negative_prompt=negative_prompt,
469
+ num_frames=num_frames,
470
+ num_inference_steps=int(steps),
471
+ generator=torch.Generator(device="cuda").manual_seed(current_seed),
472
+ output_type="np",
473
+ )
474
+
475
+ if lora_loaded:
476
+ lora_loader.unload_lora(pipe)
477
+
478
+ print("gen time passed:", time.time() - start)
479
+
480
+ raw_frames_np = result.frames[0] # (T, H, W, C) float32
481
+ pipe.scheduler = original_scheduler
482
+
483
+ frame_factor = frame_multiplier // FIXED_FPS
484
+ if frame_factor > 1:
485
+ start = time.time()
486
+ print(f"Processing frames (RIFE Multiplier: {frame_factor}x)...")
487
+ rife_model.device()
488
+ rife_model.flownet = rife_model.flownet.half()
489
+ final_frames = interpolate_bits(raw_frames_np, multiplier=int(frame_factor))
490
+ print("Interpolation time passed:", time.time() - start)
491
+ else:
492
+ final_frames = list(raw_frames_np)
493
+
494
+ final_fps = FIXED_FPS * int(max(1, frame_factor))
495
+
496
+ with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmpfile:
497
+ video_path = tmpfile.name
498
+
499
+ start = time.time()
500
+ with tqdm(total=3, desc="Rendering Media", unit="clip") as pbar:
501
+ pbar.update(2)
502
+ export_to_video(final_frames, video_path, fps=final_fps, quality=quality)
503
+ pbar.update(1)
504
+ print(f"Export time passed, {final_fps} FPS:", time.time() - start)
505
+
506
+ return video_path, task_name
507
+
508
+
509
+ def generate_video(
510
+ input_image,
511
+ prompt,
512
+ steps=DEFAULT_STEPS,
513
+ negative_prompt=default_negative_prompt,
514
+ duration_seconds=MAX_DURATION,
515
+ guidance_scale=DEFAULT_GUIDANCE,
516
+ seed=42,
517
+ randomize_seed=False,
518
+ quality=6,
519
+ scheduler="FlowMatchEulerDiscrete",
520
+ flow_shift=DEFAULT_SHIFT,
521
+ frame_multiplier=FIXED_FPS,
522
+ safe_mode=False,
523
+ lora_groups=None,
524
+ lora_scale=1.0,
525
+ custom_lora="",
526
+ video_component=True,
527
+ progress=gr.Progress(track_tqdm=True),
528
+ ):
529
+ """
530
+ Generate a video from an input image using HunyuanVideo 1.5 I2V (8.3B).
531
+
532
+ This function takes an input image and generates a video animation based on the provided
533
+ prompt and parameters. It uses an fp8-quantized HunyuanVideo 1.5 image-to-video model; with
534
+ the step-distilled checkpoint 8-12 steps are enough.
535
+
536
+ Args:
537
+ input_image (PIL.Image): The input image to animate. Cropped to the closest aspect-ratio
538
+ bucket around the model's target size (640px for 480p, 960px for 720p).
539
+ prompt (str): Text prompt describing the desired animation or motion.
540
+ steps (int, optional): Number of inference steps. Defaults to 8. Range: 1-50.
541
+ The step-distilled checkpoint is tuned for 8 or 12; the plain checkpoints want 50.
542
+ negative_prompt (str, optional): Negative prompt to avoid unwanted elements.
543
+ Only used when guidance_scale > 1 (CFG is off on the distilled checkpoints).
544
+ duration_seconds (float, optional): Duration of the generated video in seconds.
545
+ Clamped between MIN_FRAMES_MODEL/FIXED_FPS and MAX_FRAMES_MODEL/FIXED_FPS.
546
+ guidance_scale (float, optional): Classifier-free guidance scale, applied through the
547
+ pipeline's guider. Defaults to 1.0 (disabled, one transformer pass per step).
548
+ Values above 1 double the generation time. Range: 0.0-10.0.
549
+ seed (int, optional): Random seed for reproducible results. Defaults to 42.
550
+ Range: 0 to MAX_SEED (2147483647).
551
+ randomize_seed (bool, optional): Whether to use a random seed instead of the provided seed.
552
+ quality (float, optional): Video output quality. Uses variable bit rate.
553
+ Highest quality is 10, lowest is 1.
554
+ scheduler (str, optional): The name of the scheduler to use for inference.
555
+ Defaults to "FlowMatchEulerDiscrete", which is what the checkpoint ships with.
556
+ flow_shift (float, optional): The flow shift value. Defaults to 7.0 for the 480p
557
+ step-distilled checkpoint (5.0 for the plain 480p ones).
558
+ frame_multiplier (int, optional): Target fps; extra frames are produced by RIFE.
559
+ lora_groups (list, optional): LoRA entries from the catalog to apply.
560
+ lora_scale (float, optional): Weight applied to the selected LoRAs.
561
+ custom_lora (str, optional): Extra LoRA as "repo_id" or "repo_id:filename".
562
+ video_component (bool, optional): Show video player in output. Defaults to True.
563
+ progress (gr.Progress, optional): Gradio progress tracker.
564
+
565
+ Returns:
566
+ tuple: A tuple containing:
567
+ - video_path (str): Path for the video component.
568
+ - video_path (str): Path for the file download component.
569
+ - current_seed (int): The seed used for generation.
570
+
571
+ Raises:
572
+ gr.Error: If input_image is None (no image uploaded).
573
+
574
+ Note:
575
+ - Frame count is calculated as duration_seconds * FIXED_FPS (24) rounded to 4k+1
576
+ - Output dimensions come from the model's aspect-ratio buckets, not from sliders
577
+ - The function uses GPU acceleration via the @spaces.GPU decorator
578
+ - Generation time varies based on steps and duration (see get_inference_duration)
579
+ """
580
+
581
+ if input_image is None:
582
+ raise gr.Error("Please upload an input image.")
583
+
584
+ num_frames = get_num_frames(duration_seconds)
585
+ current_seed = random.randint(0, MAX_SEED) if randomize_seed else int(seed)
586
+ resized_image = resize_image(input_image)
587
+
588
+ video_path, task_n = run_inference(
589
+ resized_image,
590
+ prompt,
591
+ steps,
592
+ negative_prompt,
593
+ num_frames,
594
+ guidance_scale,
595
+ current_seed,
596
+ scheduler,
597
+ flow_shift,
598
+ frame_multiplier,
599
+ quality,
600
+ duration_seconds,
601
+ safe_mode,
602
+ lora_groups,
603
+ lora_scale,
604
+ custom_lora,
605
+ progress,
606
+ )
607
+ print(f"GPU complete: {task_n}")
608
+
609
+ return (video_path if video_component else None), video_path, current_seed
610
+
611
+
612
+ CSS = """
613
+ #hidden-timestamp {
614
+ opacity: 0;
615
+ height: 0px;
616
+ width: 0px;
617
+ margin: 0px;
618
+ padding: 0px;
619
+ overflow: hidden;
620
+ position: absolute;
621
+ pointer-events: none;
622
+ }
623
+ """
624
+
625
+
626
+ with gr.Blocks(delete_cache=(3600, 10800)) as demo:
627
+ gr.Markdown(model_title())
628
+ gr.Markdown(
629
+ "Run HunyuanVideo 1.5 image-to-video in 8-12 steps, fp8 quantization - "
630
+ "compatible with 🧨 diffusers and ZeroGPU"
631
+ )
632
+
633
+ with gr.Row():
634
+ with gr.Column():
635
+ input_image_component = gr.Image(type="pil", label="Input Image", sources=["upload", "clipboard"])
636
+ prompt_input = gr.Textbox(label="Prompt", value=default_prompt_i2v)
637
+ duration_seconds_input = gr.Slider(minimum=MIN_DURATION, maximum=MAX_DURATION, step=0.1, value=MAX_DURATION, label="Duration (seconds)", info=f"Clamped to model's {MIN_FRAMES_MODEL}-{MAX_FRAMES_MODEL} frames at {FIXED_FPS}fps.")
638
+ frame_multi = gr.Dropdown(
639
+ choices=[FIXED_FPS, FIXED_FPS * 2, FIXED_FPS * 4],
640
+ value=FIXED_FPS,
641
+ label="Video Fluidity (Frames per Second)",
642
+ info="Extra frames will be generated using flow estimation, which estimates motion between frames to make the video smoother."
643
+ )
644
+ safe_mode_checkbox = gr.Checkbox(
645
+ label="🛠️ Safe Mode",
646
+ value=True,
647
+ info="Requests 30% extra processing time to try to prevent unfinished tasks when the server is busy."
648
+ )
649
+ with gr.Accordion("Advanced Settings", open=False):
650
+ negative_prompt_input = gr.Textbox(label="Negative Prompt", value=default_negative_prompt, info="Used only if Guidance Scale > 1.", lines=3)
651
+ quality_slider = gr.Slider(minimum=1, maximum=10, step=1, value=6, label="Video Quality", info="If set to 10, the generated video may be too large and won't play in the Gradio preview.")
652
+ seed_input = gr.Slider(label="Seed", minimum=0, maximum=MAX_SEED, step=1, value=42, interactive=True)
653
+ randomize_seed_checkbox = gr.Checkbox(label="Randomize seed", value=True, interactive=True)
654
+ steps_slider = gr.Slider(minimum=1, maximum=50, step=1, value=DEFAULT_STEPS, label="Inference Steps", info="8 or 12 for the step-distilled checkpoint, 50 for the plain ones.")
655
+ guidance_scale_input = gr.Slider(minimum=0.0, maximum=10.0, step=0.5, value=DEFAULT_GUIDANCE, label="Guidance Scale (CFG)", info="1.0 = off. Values above 1 double GPU time and enable the negative prompt.")
656
+ scheduler_dropdown = gr.Dropdown(
657
+ label="Scheduler",
658
+ choices=list(SCHEDULER_MAP.keys()),
659
+ value="FlowMatchEulerDiscrete",
660
+ info="FlowMatchEulerDiscrete is what the checkpoint ships with; the rest run in flow-prediction mode and are experimental."
661
+ )
662
+ flow_shift_slider = gr.Slider(minimum=0.5, maximum=15.0, step=0.1, value=DEFAULT_SHIFT, label="Flow Shift", info="7.0 for the 480p step-distilled / 720p checkpoints, 5.0 for plain 480p.")
663
+ lora_dropdown = gr.Dropdown(choices=lora_loader.get_lora_choices(), label="LoRA", multiselect=True, info="Entries from loras.json / LORA_CATALOG. HunyuanVideo 1.5 LoRAs only.")
664
+ lora_scale_slider = gr.Slider(minimum=0.0, maximum=2.0, step=0.05, value=1.0, label="LoRA Scale")
665
+ custom_lora_input = gr.Textbox(label="Custom LoRA", value="", placeholder="repo_id or repo_id:file.safetensors", info="Any Hub repo holding a HunyuanVideo 1.5 LoRA.")
666
+ play_result_video = gr.Checkbox(label="Display result", value=True, interactive=True)
667
+
668
+ generate_button = gr.Button("Generate Video", variant="primary")
669
+
670
+ with gr.Column():
671
+ # ASSIGNED elem_id="generated-video" so JS can find it
672
+ video_output = gr.Video(label="Generated Video", autoplay=True, sources=["upload"], buttons=["download", "share"], interactive=True, elem_id="generated-video")
673
+
674
+ # --- Frame Grabbing UI ---
675
+ with gr.Row():
676
+ grab_frame_btn = gr.Button("📸 Use Current Frame as Input", variant="secondary")
677
+ timestamp_box = gr.Number(value=0, label="Timestamp", visible=True, elem_id="hidden-timestamp")
678
+ # -------------------------
679
+
680
+ file_output = gr.File(label="Download Video")
681
+
682
+ ui_inputs = [
683
+ input_image_component, prompt_input, steps_slider,
684
+ negative_prompt_input, duration_seconds_input,
685
+ guidance_scale_input, seed_input, randomize_seed_checkbox,
686
+ quality_slider, scheduler_dropdown, flow_shift_slider, frame_multi,
687
+ safe_mode_checkbox,
688
+ lora_dropdown, lora_scale_slider, custom_lora_input,
689
+ play_result_video
690
+ ]
691
+
692
+ generate_button.click(
693
+ fn=generate_video,
694
+ inputs=ui_inputs,
695
+ outputs=[video_output, file_output, seed_input]
696
+ )
697
+
698
+ # --- Frame Grabbing Events ---
699
+ # 1. Click button -> JS runs -> puts time in hidden number box
700
+ grab_frame_btn.click(
701
+ fn=None,
702
+ inputs=None,
703
+ outputs=[timestamp_box],
704
+ js=get_timestamp_js
705
+ )
706
+
707
+ # 2. Hidden number box changes -> Python runs -> puts frame in Input Image
708
+ timestamp_box.change(
709
+ fn=extract_frame,
710
+ inputs=[video_output, timestamp_box],
711
+ outputs=[input_image_component]
712
+ )
713
+
714
+ if __name__ == "__main__":
715
+ demo.queue().launch(
716
+ mcp_server=True,
717
+ css=CSS,
718
+ show_error=True,
719
+ )
lora_loader.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LoRA loading for HunyuanVideo 1.5 I2V.
3
+
4
+ Unlike the Wan pipelines, `HunyuanVideo15ImageToVideoPipeline` does not inherit a LoRA loader
5
+ mixin, so LoRAs go straight onto the transformer through `PeftAdapterMixin`
6
+ (`load_lora_adapter` / `set_adapters` / `delete_adapters` / `fuse_lora`).
7
+
8
+ Wan 2.2 LoRAs are NOT compatible: different architecture, different key names, and Wan's
9
+ high-noise/low-noise expert pairs have no counterpart here (HunyuanVideo 1.5 has a single
10
+ transformer). You need LoRAs trained for HunyuanVideo 1.5.
11
+
12
+ The catalog is data, not code. Put a `loras.json` next to this file:
13
+
14
+ [
15
+ {"label": "My Style", "repo_id": "user/repo", "weight_name": "style.safetensors", "scale": 1.0},
16
+ {"label": "Fused One", "repo_id": "user/repo2", "fuse_at_startup": true, "scale": 0.8}
17
+ ]
18
+
19
+ or set LORA_CATALOG to the same JSON inline. Users can also type any repo into the
20
+ "Custom LoRA" box in the UI as `repo_id` or `repo_id:weight_name`.
21
+ """
22
+ import json
23
+ import os
24
+
25
+ from huggingface_hub import hf_hub_download, snapshot_download
26
+
27
+ HF_TOKEN = os.environ.get("HF_TOKEN") # authenticated downloads (covers private repos)
28
+ CATALOG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "loras.json")
29
+
30
+ _LOADED_ADAPTERS = []
31
+
32
+
33
+ def _read_catalog():
34
+ raw = os.environ.get("LORA_CATALOG")
35
+ if raw:
36
+ try:
37
+ return json.loads(raw)
38
+ except Exception as e:
39
+ print("LORA_CATALOG is not valid JSON:", e)
40
+ return []
41
+ if os.path.exists(CATALOG_PATH):
42
+ try:
43
+ with open(CATALOG_PATH) as fh:
44
+ return json.load(fh)
45
+ except Exception as e:
46
+ print(f"Could not read {CATALOG_PATH}:", e)
47
+ return []
48
+
49
+
50
+ CATALOG = {}
51
+ for _entry in _read_catalog():
52
+ if not isinstance(_entry, dict) or not _entry.get("repo_id"):
53
+ continue
54
+ _label = _entry.get("label") or _entry["repo_id"].split("/")[-1]
55
+ CATALOG[_label] = _entry
56
+
57
+
58
+ def get_lora_choices():
59
+ return sorted(CATALOG.keys())
60
+
61
+
62
+ def _resolve_path(repo_id, weight_name=None, revision=None):
63
+ """Return a local path diffusers can load: a single file when weight_name is given,
64
+ otherwise the whole snapshot (diffusers will find the LoRA inside)."""
65
+ if weight_name:
66
+ return hf_hub_download(repo_id, weight_name, token=HF_TOKEN, revision=revision)
67
+ return snapshot_download(repo_id, token=HF_TOKEN, revision=revision)
68
+
69
+
70
+ def _parse_custom(spec):
71
+ """'repo/name' or 'repo/name:file.safetensors' -> (repo_id, weight_name|None)"""
72
+ spec = (spec or "").strip()
73
+ if not spec:
74
+ return None
75
+ if ":" in spec:
76
+ repo_id, weight_name = spec.split(":", 1)
77
+ return repo_id.strip(), weight_name.strip() or None
78
+ return spec, None
79
+
80
+
81
+ def load_loras_to_pipe(pipe, labels=None, custom=None, scale=1.0):
82
+ """Load the selected catalog entries plus an optional custom LoRA onto pipe.transformer.
83
+
84
+ Returns True if at least one adapter was attached. Note that stacking runtime LoRAs on an
85
+ fp8-quantized transformer can fail depending on the torchao/peft versions; if that happens,
86
+ use `fuse_at_startup` in the catalog instead (fusion runs before quantization).
87
+ """
88
+ unload_lora(pipe)
89
+
90
+ requests = []
91
+ for label in (labels or []):
92
+ entry = CATALOG.get(label)
93
+ if not entry or entry.get("fuse_at_startup"):
94
+ continue
95
+ requests.append((label, entry.get("repo_id"), entry.get("weight_name"),
96
+ entry.get("revision"), float(entry.get("scale", 1.0))))
97
+
98
+ parsed = _parse_custom(custom)
99
+ if parsed:
100
+ requests.append(("custom", parsed[0], parsed[1], None, 1.0))
101
+
102
+ if not requests:
103
+ return False
104
+
105
+ names, weights = [], []
106
+ for idx, (label, repo_id, weight_name, revision, entry_scale) in enumerate(requests):
107
+ path = _resolve_path(repo_id, weight_name, revision)
108
+ adapter_name = f"lora_{idx}"
109
+ pipe.transformer.load_lora_adapter(path, prefix="transformer", adapter_name=adapter_name)
110
+ names.append(adapter_name)
111
+ weights.append(entry_scale * float(scale))
112
+ print(f"Loaded LoRA: {label} ({repo_id})")
113
+
114
+ pipe.transformer.set_adapters(names, weights=weights)
115
+ _LOADED_ADAPTERS[:] = names
116
+ return True
117
+
118
+
119
+ def unload_lora(pipe):
120
+ if not _LOADED_ADAPTERS:
121
+ return
122
+ try:
123
+ pipe.transformer.delete_adapters(list(_LOADED_ADAPTERS))
124
+ except Exception:
125
+ try:
126
+ pipe.transformer.unload_lora()
127
+ except Exception:
128
+ pass
129
+ _LOADED_ADAPTERS.clear()
130
+
131
+
132
+ def fuse_startup_loras(pipe):
133
+ """Fuse catalog entries marked `fuse_at_startup` into the transformer weights.
134
+
135
+ Call this once at import time, before quantization: fused weights survive fp8 conversion,
136
+ runtime adapters may not. Mirrors what the Wan reference space does with its Lightning LoRAs.
137
+ """
138
+ entries = [e for e in CATALOG.values() if e.get("fuse_at_startup")]
139
+ if not entries:
140
+ return
141
+ for i, entry in enumerate(entries):
142
+ adapter_name = f"startup_{i}"
143
+ try:
144
+ path = _resolve_path(entry["repo_id"], entry.get("weight_name"), entry.get("revision"))
145
+ pipe.transformer.load_lora_adapter(path, prefix="transformer", adapter_name=adapter_name)
146
+ pipe.transformer.set_adapters([adapter_name], weights=[1.0])
147
+ pipe.transformer.fuse_lora(lora_scale=float(entry.get("scale", 1.0)),
148
+ adapter_names=[adapter_name])
149
+ pipe.transformer.delete_adapters([adapter_name])
150
+ print(f"Fused LoRA at startup: {entry.get('label', entry['repo_id'])} "
151
+ f"(scale={entry.get('scale', 1.0)}), {i + 1}/{len(entries)}")
152
+ except Exception as e:
153
+ print("Error:", str(e))
154
+ print("Failed LoRA:", entry.get("label", entry.get("repo_id")))
155
+ try:
156
+ pipe.transformer.delete_adapters([adapter_name])
157
+ except Exception:
158
+ pass
loras.json ADDED
@@ -0,0 +1 @@
 
 
1
+ []
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ ffmpeg
requirements.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ diffusers==0.39.0
2
+ transformers==4.57.6
3
+ accelerate===1.13.0
4
+ safetensors
5
+ sentencepiece
6
+ peft==0.19.1
7
+ ftfy
8
+ imageio
9
+ imageio-ffmpeg
10
+ opencv-python
11
+ torchao==0.17.0
12
+ kernels
13
+
14
+ numpy>=1.26,<3
15
+ torch==2.11.0
16
+ torchvision==0.26.0