nitignar commited on
Commit
4b1abd9
·
verified ·
1 Parent(s): 46bf378

Upload 3 files

Browse files
Files changed (3) hide show
  1. README.md +3 -2
  2. app.py +53 -6
  3. test_app.py +15 -1
README.md CHANGED
@@ -21,7 +21,8 @@ A Hugging Face Space for generating a video from:
21
 
22
  - a required start image;
23
  - an optional end image; and
24
- - a text prompt describing the action, subject motion, and camera movement.
 
25
 
26
  The app uses [`Wan-AI/Wan2.1-FLF2V-14B-720P-diffusers`](https://huggingface.co/Wan-AI/Wan2.1-FLF2V-14B-720P-diffusers). When no end image is supplied, the same pipeline runs in regular image-to-video mode.
27
 
@@ -32,4 +33,4 @@ The app uses [`Wan-AI/Wan2.1-FLF2V-14B-720P-diffusers`](https://huggingface.co/W
32
  3. Select **ZeroGPU** in **Settings → Hardware**.
33
  4. Wait for the model weights to download on the first build/request.
34
 
35
- The model is large. CPU-only hardware is not supported. The app requests ZeroGPU's 96 GB `xlarge` tier while a generation is running; usage remains subject to Hugging Face's daily ZeroGPU quota and queue.
 
21
 
22
  - a required start image;
23
  - an optional end image; and
24
+ - a text prompt describing the action, subject motion, and camera movement;
25
+ - a selectable duration of 1, 2, or 3 seconds.
26
 
27
  The app uses [`Wan-AI/Wan2.1-FLF2V-14B-720P-diffusers`](https://huggingface.co/Wan-AI/Wan2.1-FLF2V-14B-720P-diffusers). When no end image is supplied, the same pipeline runs in regular image-to-video mode.
28
 
 
33
  3. Select **ZeroGPU** in **Settings → Hardware**.
34
  4. Wait for the model weights to download on the first build/request.
35
 
36
+ The model is large. CPU-only hardware is not supported. The app requests ZeroGPU's 96 GB `xlarge` tier while a generation is running and dynamically scales the reservation with the selected duration, resolution, and step count. Usage remains subject to Hugging Face's daily ZeroGPU quota and queue.
app.py CHANGED
@@ -16,6 +16,7 @@ from transformers import CLIPVisionModel
16
 
17
 
18
  MODEL_ID = "Wan-AI/Wan2.1-FLF2V-14B-720P-diffusers"
 
19
  OUTPUT_DIR = Path("outputs")
20
  OUTPUT_DIR.mkdir(exist_ok=True)
21
 
@@ -88,14 +89,38 @@ def prepare_frame(image: Image.Image, max_area: int, size=None):
88
  return ImageOps.fit(image, size, method=Image.Resampling.LANCZOS), size
89
 
90
 
91
- @spaces.GPU(size="xlarge", duration=12)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  def generate_video(
93
  start_image,
94
  end_image,
95
  prompt,
96
  negative_prompt,
97
  resolution,
98
- num_frames,
99
  steps,
100
  guidance,
101
  seed,
@@ -120,6 +145,8 @@ def generate_video(
120
  last_frame = first_frame.copy()
121
 
122
  width, height = target_size
 
 
123
  actual_seed = random.randint(0, 2**31 - 1) if int(seed) < 0 else int(seed)
124
  generator = torch.Generator(device="cpu").manual_seed(actual_seed)
125
 
@@ -144,7 +171,7 @@ def generate_video(
144
  generator=generator,
145
  callback_on_step_end=update_progress,
146
  ).frames[0]
147
- export_to_video(frames, str(output_path), fps=16)
148
  except torch.cuda.OutOfMemoryError as exc:
149
  gc.collect()
150
  torch.cuda.empty_cache()
@@ -152,7 +179,10 @@ def generate_video(
152
 
153
  progress(1, desc="Video ready")
154
  mode = "start → end" if has_end_frame else "loop"
155
- info = f"Seed **{actual_seed}** · {width}×{height} · {num_frames} frames · {mode} mode"
 
 
 
156
  return str(output_path), info, actual_seed
157
 
158
 
@@ -217,7 +247,14 @@ with gr.Blocks(css=CSS, title="Between Frames · Image to Video") as demo:
217
  negative_prompt = gr.Textbox(label="Negative prompt", value=NEGATIVE_PROMPT, lines=2)
218
  with gr.Row():
219
  resolution = gr.Radio(list(RESOLUTIONS), value="480p (faster)", label="Resolution")
220
- num_frames = gr.Dropdown([17, 33], value=17, label="Frames (16 fps)")
 
 
 
 
 
 
 
221
  with gr.Row():
222
  steps = gr.Slider(8, 16, value=8, step=1, label="Inference steps")
223
  guidance = gr.Slider(1, 6, value=1.0, step=0.1, label="Prompt guidance")
@@ -231,7 +268,17 @@ with gr.Blocks(css=CSS, title="Between Frames · Image to Video") as demo:
231
 
232
  gr.HTML('<div class="footer-note">Large video models need a GPU. 720p generation can take several minutes.</div>')
233
 
234
- inputs = [start_image, end_image, prompt, negative_prompt, resolution, num_frames, steps, guidance, seed]
 
 
 
 
 
 
 
 
 
 
235
  generate_btn.click(
236
  fn=generate_video,
237
  inputs=inputs,
 
16
 
17
 
18
  MODEL_ID = "Wan-AI/Wan2.1-FLF2V-14B-720P-diffusers"
19
+ VIDEO_FPS = 16
20
  OUTPUT_DIR = Path("outputs")
21
  OUTPUT_DIR.mkdir(exist_ok=True)
22
 
 
89
  return ImageOps.fit(image, size, method=Image.Resampling.LANCZOS), size
90
 
91
 
92
+ def seconds_to_frames(duration_seconds):
93
+ """Wan accepts 4k+1 frame counts; whole seconds at 16 fps fit exactly."""
94
+ seconds = max(1, min(3, int(duration_seconds)))
95
+ return seconds * VIDEO_FPS + 1
96
+
97
+
98
+ def estimate_gpu_duration(
99
+ _start_image,
100
+ _end_image,
101
+ _prompt,
102
+ _negative_prompt,
103
+ resolution,
104
+ duration_seconds,
105
+ steps,
106
+ _guidance,
107
+ _seed,
108
+ ):
109
+ """Reserve only the free ZeroGPU time appropriate for this request."""
110
+ seconds = max(1, min(3, int(duration_seconds)))
111
+ resolution_factor = 1.6 if resolution == "720p (best quality)" else 1.0
112
+ estimate = (8 + 7 * seconds) * (int(steps) / 8) * resolution_factor
113
+ return max(12, min(60, int(round(estimate))))
114
+
115
+
116
+ @spaces.GPU(size="xlarge", duration=estimate_gpu_duration)
117
  def generate_video(
118
  start_image,
119
  end_image,
120
  prompt,
121
  negative_prompt,
122
  resolution,
123
+ duration_seconds,
124
  steps,
125
  guidance,
126
  seed,
 
145
  last_frame = first_frame.copy()
146
 
147
  width, height = target_size
148
+ duration_seconds = max(1, min(3, int(duration_seconds)))
149
+ num_frames = seconds_to_frames(duration_seconds)
150
  actual_seed = random.randint(0, 2**31 - 1) if int(seed) < 0 else int(seed)
151
  generator = torch.Generator(device="cpu").manual_seed(actual_seed)
152
 
 
171
  generator=generator,
172
  callback_on_step_end=update_progress,
173
  ).frames[0]
174
+ export_to_video(frames, str(output_path), fps=VIDEO_FPS)
175
  except torch.cuda.OutOfMemoryError as exc:
176
  gc.collect()
177
  torch.cuda.empty_cache()
 
179
 
180
  progress(1, desc="Video ready")
181
  mode = "start → end" if has_end_frame else "loop"
182
+ info = (
183
+ f"Seed **{actual_seed}** · {width}×{height} · "
184
+ f"{duration_seconds}s ({num_frames} frames at {VIDEO_FPS} fps) · {mode} mode"
185
+ )
186
  return str(output_path), info, actual_seed
187
 
188
 
 
247
  negative_prompt = gr.Textbox(label="Negative prompt", value=NEGATIVE_PROMPT, lines=2)
248
  with gr.Row():
249
  resolution = gr.Radio(list(RESOLUTIONS), value="480p (faster)", label="Resolution")
250
+ duration_seconds = gr.Slider(
251
+ 1,
252
+ 3,
253
+ value=1,
254
+ step=1,
255
+ label="Video duration (seconds)",
256
+ info="Longer videos use more of the free daily GPU quota.",
257
+ )
258
  with gr.Row():
259
  steps = gr.Slider(8, 16, value=8, step=1, label="Inference steps")
260
  guidance = gr.Slider(1, 6, value=1.0, step=0.1, label="Prompt guidance")
 
268
 
269
  gr.HTML('<div class="footer-note">Large video models need a GPU. 720p generation can take several minutes.</div>')
270
 
271
+ inputs = [
272
+ start_image,
273
+ end_image,
274
+ prompt,
275
+ negative_prompt,
276
+ resolution,
277
+ duration_seconds,
278
+ steps,
279
+ guidance,
280
+ seed,
281
+ ]
282
  generate_btn.click(
283
  fn=generate_video,
284
  inputs=inputs,
test_app.py CHANGED
@@ -1,6 +1,6 @@
1
  from PIL import Image
2
 
3
- from app import prepare_frame
4
 
5
 
6
  def test_prepare_frame_uses_multiple_of_sixteen():
@@ -19,3 +19,17 @@ def test_end_frame_matches_start_geometry():
19
  prepared_end, _ = prepare_frame(end, 480 * 832, target_size)
20
 
21
  assert prepared_end.size == target_size
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from PIL import Image
2
 
3
+ from app import estimate_gpu_duration, prepare_frame, seconds_to_frames
4
 
5
 
6
  def test_prepare_frame_uses_multiple_of_sixteen():
 
19
  prepared_end, _ = prepare_frame(end, 480 * 832, target_size)
20
 
21
  assert prepared_end.size == target_size
22
+
23
+
24
+ def test_duration_seconds_map_to_wan_frame_counts():
25
+ assert seconds_to_frames(1) == 17
26
+ assert seconds_to_frames(2) == 33
27
+ assert seconds_to_frames(3) == 49
28
+
29
+
30
+ def test_gpu_reservation_scales_with_duration():
31
+ one_second = estimate_gpu_duration(None, None, "", "", "480p (faster)", 1, 8, 1.0, -1)
32
+ three_seconds = estimate_gpu_duration(None, None, "", "", "480p (faster)", 3, 8, 1.0, -1)
33
+
34
+ assert one_second == 15
35
+ assert three_seconds == 29