someone-in-the-world commited on
Commit
c8ec0f7
·
2 Parent(s): 268258997e9c44

Merge remote-tracking branch 'origin/develop' into add-mypy-strict-typecheck

Browse files

# Conflicts:
# app.py
# postprocess/interpolation.py
# postprocess/upscale/upscale.py

app.py CHANGED
@@ -5,7 +5,7 @@ import random
5
  import tempfile
6
  import threading
7
  import time as _time
8
- from typing import TYPE_CHECKING
9
 
10
  import gradio as gr
11
  import numpy as np
@@ -42,9 +42,12 @@ from model.pipeline import (
42
  estimate_generation_seconds,
43
  get_num_frames,
44
  load_pipeline,
 
45
  resize_image,
46
  )
 
47
  from postprocess.interpolation import interpolate_frames
 
48
 
49
  MAX_SEED = np.iinfo(np.int32).max
50
  FRAME_MULTIPLIER_CHOICES = [FIXED_FPS, FIXED_FPS * 2, FIXED_FPS * 4, FIXED_FPS * 8]
@@ -61,6 +64,12 @@ print_startup_env()
61
 
62
  pipe = load_pipeline()
63
 
 
 
 
 
 
 
64
  # Debug logging (LOG-2): gated entirely by LOG_HF_TOKEN/LOG_DATASET_REPO — a silent no-op when
65
  # either is unset. Deliberately not the deploy HF_TOKEN: this uses its own write-scoped token so a
66
  # leak has a much smaller blast radius (append-only on one dataset repo, not the whole Space repo).
@@ -68,6 +77,9 @@ _log_uploader = LogUploader(
68
  token=os.environ.get("LOG_HF_TOKEN"),
69
  repo_id=os.environ.get("LOG_DATASET_REPO"),
70
  max_bytes=int(float(os.environ.get("LOG_STORAGE_CAP_GB", "10")) * 1024**3),
 
 
 
71
  batch_interval=int(os.environ.get("LOG_BATCH_INTERVAL", "60")),
72
  )
73
 
@@ -85,6 +97,7 @@ _COMMUNITY_MD = (
85
 
86
  def _gpu_duration(
87
  resized_image: Image.Image,
 
88
  prompt: str,
89
  negative_prompt: str,
90
  steps: int,
@@ -114,8 +127,8 @@ def _gpu_duration(
114
 
115
 
116
  def _apply_interpolation(
117
- raw_frames: FrameArray, frame_factor: int, progress: gr.Progress
118
- ) -> tuple[list[FrameArray], int]:
119
  if frame_factor <= 1:
120
  return list(raw_frames), FIXED_FPS
121
 
@@ -125,8 +138,13 @@ def _apply_interpolation(
125
  print_stage_start("interpolation")
126
  t0 = _time.perf_counter()
127
  try:
 
 
128
  interpolated = interpolate_frames(
129
- raw_frames, multiplier=int(frame_factor), progress_callback=_report_interpolation_progress
 
 
 
130
  )
131
  print_stage_done("interpolation", _time.perf_counter() - t0)
132
  return interpolated, FIXED_FPS * frame_factor
@@ -137,7 +155,19 @@ def _apply_interpolation(
137
  return list(raw_frames), FIXED_FPS
138
 
139
 
140
- def _apply_upscale(frames: list[FrameArray], progress: gr.Progress) -> list[Image.Image] | None:
 
 
 
 
 
 
 
 
 
 
 
 
141
  from postprocess.upscale import upscale_frames
142
 
143
  def _report_upscale_progress(done: int, total: int) -> None:
@@ -147,10 +177,7 @@ def _apply_upscale(frames: list[FrameArray], progress: gr.Progress) -> list[Imag
147
  print_stage_start("upscaling")
148
  t0 = _time.perf_counter()
149
  try:
150
- pil_frames = [
151
- Image.fromarray((np.asarray(frame) * 255.0).round().astype(np.uint8)) for frame in frames
152
- ]
153
- upscaled = upscale_frames(pil_frames, progress_callback=_report_upscale_progress)
154
  print_stage_done("upscaling", _time.perf_counter() - t0)
155
  return upscaled
156
  except Exception as e:
@@ -163,6 +190,7 @@ def _apply_upscale(frames: list[FrameArray], progress: gr.Progress) -> list[Imag
163
  @spaces.GPU(duration=_gpu_duration) # type: ignore[untyped-decorator] # spaces ships no type stubs
164
  def run_inference(
165
  resized_image: Image.Image,
 
166
  prompt: str,
167
  negative_prompt: str,
168
  steps: int,
@@ -172,15 +200,22 @@ def run_inference(
172
  frame_multiplier: int,
173
  upscale_output: bool,
174
  progress: gr.Progress = gr.Progress(track_tqdm=True),
175
- ) -> tuple[Frames, int, list[FrameArray] | None, int]:
176
  print_infer_start(prompt, negative_prompt, seed, steps, guidance_scale, frame_multiplier, upscale_output)
177
  t_start = _time.perf_counter()
178
 
 
 
 
 
 
 
179
  print_stage_start("generation")
180
  t0 = _time.perf_counter()
181
  try:
182
  result = pipe(
183
  image=resized_image,
 
184
  prompt=prompt,
185
  negative_prompt=negative_prompt,
186
  height=resized_image.height,
@@ -190,6 +225,7 @@ def run_inference(
190
  num_inference_steps=int(steps),
191
  generator=torch.Generator(device="cuda").manual_seed(seed),
192
  output_type="np",
 
193
  )
194
  except Exception as e:
195
  print_stage_error("generation", e)
@@ -198,23 +234,27 @@ def run_inference(
198
  raw_frames = result.frames[0] # (T, H, W, C) float32 in [0, 1]
199
 
200
  frame_factor = frame_multiplier // FIXED_FPS
201
- final_frames, final_fps = _apply_interpolation(raw_frames, frame_factor, progress)
 
 
 
202
 
203
  upscaled_frames = _apply_upscale(final_frames, progress) if upscale_output else None
204
 
205
  if upscaled_frames is None:
 
 
 
206
  print_infer_done(_time.perf_counter() - t_start, final_fps, len(final_frames))
207
- return final_frames, final_fps, None, final_fps
208
 
209
- # LOG-4: the debug log records the pre-upscale (post-interpolation) result, not the final
210
- # upscaled video and not the raw un-interpolated base output.
211
  print_infer_done(_time.perf_counter() - t_start, final_fps, len(upscaled_frames))
212
- return upscaled_frames, final_fps, final_frames, final_fps
213
 
214
 
215
  def _spawn_log(
216
  input_image: Image.Image,
217
- output_frames: list[FrameArray] | None,
218
  output_fps: int | None,
219
  prompt: str,
220
  negative_prompt: str,
@@ -247,6 +287,7 @@ def _spawn_log(
247
 
248
  def generate_video(
249
  input_image: Image.Image | None,
 
250
  prompt: str,
251
  negative_prompt: str,
252
  duration_seconds: float,
@@ -262,6 +303,7 @@ def generate_video(
262
  raise gr.Error("Please upload an input image.")
263
 
264
  resized_image = resize_image(input_image)
 
265
  num_frames = get_num_frames(duration_seconds)
266
  current_seed = random.randint(0, MAX_SEED) if randomize_seed else int(seed)
267
 
@@ -271,8 +313,9 @@ def generate_video(
271
 
272
  t0 = _time.perf_counter()
273
  try:
274
- final_frames, final_fps, pre_upscale_frames, pre_upscale_fps = run_inference(
275
  resized_image,
 
276
  prompt,
277
  negative_prompt,
278
  steps,
@@ -299,7 +342,6 @@ def generate_video(
299
  duration = _time.perf_counter() - t0
300
 
301
  print_frames_info("final_frames", final_frames, final_fps)
302
- print_frames_info("pre_upscale_frames", pre_upscale_frames, pre_upscale_fps)
303
 
304
  with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmpfile:
305
  video_path = tmpfile.name
@@ -318,9 +360,8 @@ def generate_video(
318
  # run_inference — a video was still produced, so this still counts as success=True.
319
  # interpolation_enabled/upscale_output reflect what the user requested, not whether an
320
  # internal fallback fired.
321
- log_frames = pre_upscale_frames if pre_upscale_frames is not None else final_frames
322
  _spawn_log(
323
- resized_image, log_frames, pre_upscale_fps, prompt, negative_prompt, current_seed,
324
  steps, guidance_scale, interpolation_enabled, interpolation_multiplier, upscale_output,
325
  resized_image.width, resized_image.height, duration, True,
326
  )
@@ -355,6 +396,10 @@ with gr.Blocks() as demo:
355
  "- Service availability and response times may vary as this Space runs on shared GPU infrastructure.\n"
356
  "- When using this Space, please comply with the "
357
  "[Hugging Face Content Policy](https://huggingface.co/content-policy).\n"
 
 
 
 
358
  f"- To monitor application performance and improve quality, input data (image/prompt) and generated "
359
  f"outputs are logged solely for debugging purposes and retained in a private Hugging Face dataset "
360
  f"accessible only to {_OPERATOR_MD}, automatically pruned once the dataset reaches its configured "
@@ -369,11 +414,11 @@ with gr.Blocks() as demo:
369
  gr.Markdown(
370
  f"**Space:** High Quality Video Generation \n"
371
  f"**Operator:** {_OPERATOR_MD} \n"
372
- f"**Last updated:** 2026-08-30\n"
373
  "\n"
374
  "#### What data is collected\n"
375
  "When you submit a request, the following is logged: your uploaded input image, the generated "
376
- "output video (pre-upscale, post-interpolation), your prompt and negative prompt, seed, inference "
377
  "steps, guidance scale, frame-interpolation and upscaling settings, output resolution/fps/duration, "
378
  "generation duration, and success/error status. No user account, IP address, or session data is "
379
  "collected.\n"
@@ -428,6 +473,11 @@ with gr.Blocks() as demo:
428
  "interpolation, within the same GPU allocation, and adds to the GPU quota used.",
429
  )
430
  with gr.Accordion("Advanced Settings", open=False):
 
 
 
 
 
431
  negative_prompt_input = gr.Textbox(label="Negative Prompt", value=DEFAULT_NEGATIVE_PROMPT, lines=3)
432
  steps_slider = gr.Slider(minimum=1, maximum=30, step=1, value=6, label="Inference Steps")
433
  guidance_scale_input = gr.Slider(minimum=0.0, maximum=10.0, step=0.5, value=1.0, label="Guidance Scale")
@@ -443,6 +493,7 @@ with gr.Blocks() as demo:
443
  fn=generate_video,
444
  inputs=[
445
  input_image_component,
 
446
  prompt_input,
447
  negative_prompt_input,
448
  duration_input,
@@ -458,11 +509,7 @@ with gr.Blocks() as demo:
458
 
459
  preview_inputs = [input_image_component, duration_input, frame_multiplier_input, upscale_checkbox]
460
  for component in preview_inputs:
461
- # `component` collapses to the base Component type across this mixed list, and gradio's
462
- # stubs only declare .change on the concrete subclasses, not the base.
463
- component.change( # type: ignore[attr-defined]
464
- fn=preview_effect, inputs=preview_inputs, outputs=effect_preview
465
- )
466
  demo.load(fn=preview_effect, inputs=preview_inputs, outputs=effect_preview)
467
 
468
  if __name__ == "__main__":
 
5
  import tempfile
6
  import threading
7
  import time as _time
8
+ from typing import TYPE_CHECKING, Any
9
 
10
  import gradio as gr
11
  import numpy as np
 
42
  estimate_generation_seconds,
43
  get_num_frames,
44
  load_pipeline,
45
+ resize_and_crop_to_match,
46
  resize_image,
47
  )
48
+ from postprocess.interpolation import ensure_weights_downloaded as ensure_rife_weights_downloaded
49
  from postprocess.interpolation import interpolate_frames
50
+ from postprocess.upscale.upscale import ensure_weights_downloaded as ensure_upscale_weights_downloaded
51
 
52
  MAX_SEED = np.iinfo(np.int32).max
53
  FRAME_MULTIPLIER_CHOICES = [FIXED_FPS, FIXED_FPS * 2, FIXED_FPS * 4, FIXED_FPS * 8]
 
64
 
65
  pipe = load_pipeline()
66
 
67
+ # RIFE/upscale weight fetches are CPU/network-only (no CUDA needed) — done here, at process
68
+ # startup, so a cold container doesn't pay for them out of the metered @spaces.GPU allocation
69
+ # on its first interpolation/upscale request.
70
+ ensure_rife_weights_downloaded()
71
+ ensure_upscale_weights_downloaded()
72
+
73
  # Debug logging (LOG-2): gated entirely by LOG_HF_TOKEN/LOG_DATASET_REPO — a silent no-op when
74
  # either is unset. Deliberately not the deploy HF_TOKEN: this uses its own write-scoped token so a
75
  # leak has a much smaller blast radius (append-only on one dataset repo, not the whole Space repo).
 
77
  token=os.environ.get("LOG_HF_TOKEN"),
78
  repo_id=os.environ.get("LOG_DATASET_REPO"),
79
  max_bytes=int(float(os.environ.get("LOG_STORAGE_CAP_GB", "10")) * 1024**3),
80
+ # Hub's commit endpoint rejects pushes once a directory holds >10000 files; stay well under
81
+ # that per-directory cap (each logged stem adds one file to data/, images/, and videos/).
82
+ max_files=int(os.environ.get("LOG_MAX_FILES", "8000")),
83
  batch_interval=int(os.environ.get("LOG_BATCH_INTERVAL", "60")),
84
  )
85
 
 
97
 
98
  def _gpu_duration(
99
  resized_image: Image.Image,
100
+ processed_last_image: Image.Image | None,
101
  prompt: str,
102
  negative_prompt: str,
103
  steps: int,
 
127
 
128
 
129
  def _apply_interpolation(
130
+ raw_frames: FrameArray, frame_factor: int, upscale_output: bool, progress: gr.Progress
131
+ ) -> tuple[list[FrameArray] | list[torch.Tensor], int]:
132
  if frame_factor <= 1:
133
  return list(raw_frames), FIXED_FPS
134
 
 
138
  print_stage_start("interpolation")
139
  t0 = _time.perf_counter()
140
  try:
141
+ # When upscale will run right after, hand it GPU tensors directly instead of round-
142
+ # tripping through CPU numpy in between (both stages run in the same @spaces.GPU call).
143
  interpolated = interpolate_frames(
144
+ raw_frames,
145
+ multiplier=int(frame_factor),
146
+ progress_callback=_report_interpolation_progress,
147
+ as_tensor=upscale_output,
148
  )
149
  print_stage_done("interpolation", _time.perf_counter() - t0)
150
  return interpolated, FIXED_FPS * frame_factor
 
155
  return list(raw_frames), FIXED_FPS
156
 
157
 
158
+ def _frames_to_numpy(frames: list[FrameArray] | list[torch.Tensor]) -> list[FrameArray]:
159
+ # Fallback-path conversion only: interpolation may have handed back GPU tensors (C,H,W)
160
+ # expecting upscale to consume them next (see _apply_interpolation's as_tensor). If upscale
161
+ # then fails or wasn't requested, callers downstream (export_to_video, logging) need plain
162
+ # (H,W,C) numpy — this is a no-op when frames are already numpy.
163
+ if frames and isinstance(frames[0], torch.Tensor):
164
+ return [f.permute(1, 2, 0).float().cpu().numpy() for f in frames]
165
+ return frames # type: ignore[return-value] # narrowed by the isinstance check above
166
+
167
+
168
+ def _apply_upscale(
169
+ frames: list[FrameArray] | list[torch.Tensor], progress: gr.Progress
170
+ ) -> list[Image.Image] | None:
171
  from postprocess.upscale import upscale_frames
172
 
173
  def _report_upscale_progress(done: int, total: int) -> None:
 
177
  print_stage_start("upscaling")
178
  t0 = _time.perf_counter()
179
  try:
180
+ upscaled = upscale_frames(frames, progress_callback=_report_upscale_progress)
 
 
 
181
  print_stage_done("upscaling", _time.perf_counter() - t0)
182
  return upscaled
183
  except Exception as e:
 
190
  @spaces.GPU(duration=_gpu_duration) # type: ignore[untyped-decorator] # spaces ships no type stubs
191
  def run_inference(
192
  resized_image: Image.Image,
193
+ processed_last_image: Image.Image | None,
194
  prompt: str,
195
  negative_prompt: str,
196
  steps: int,
 
200
  frame_multiplier: int,
201
  upscale_output: bool,
202
  progress: gr.Progress = gr.Progress(track_tqdm=True),
203
+ ) -> tuple[Frames, int]:
204
  print_infer_start(prompt, negative_prompt, seed, steps, guidance_scale, frame_multiplier, upscale_output)
205
  t_start = _time.perf_counter()
206
 
207
+ def _report_generation_progress(
208
+ pipe_: Any, step_index: int, timestep: int, callback_kwargs: dict[str, Any]
209
+ ) -> dict[str, Any]:
210
+ progress(0.7 * (step_index + 1) / int(steps), desc=f"Generating ({step_index + 1}/{int(steps)} steps)...")
211
+ return callback_kwargs
212
+
213
  print_stage_start("generation")
214
  t0 = _time.perf_counter()
215
  try:
216
  result = pipe(
217
  image=resized_image,
218
+ last_image=processed_last_image,
219
  prompt=prompt,
220
  negative_prompt=negative_prompt,
221
  height=resized_image.height,
 
225
  num_inference_steps=int(steps),
226
  generator=torch.Generator(device="cuda").manual_seed(seed),
227
  output_type="np",
228
+ callback_on_step_end=_report_generation_progress,
229
  )
230
  except Exception as e:
231
  print_stage_error("generation", e)
 
234
  raw_frames = result.frames[0] # (T, H, W, C) float32 in [0, 1]
235
 
236
  frame_factor = frame_multiplier // FIXED_FPS
237
+ final_frames, final_fps = _apply_interpolation(raw_frames, frame_factor, upscale_output, progress)
238
+ # Confirms which handoff mode actually ran: frame_type=Tensor (GPU, no CPU round trip) when
239
+ # interpolation->upscale both ran, vs. ndarray otherwise. See issue #53.
240
+ print_frames_info("interpolation_output", final_frames, final_fps)
241
 
242
  upscaled_frames = _apply_upscale(final_frames, progress) if upscale_output else None
243
 
244
  if upscaled_frames is None:
245
+ # Covers both upscale_output=False (already numpy, no-op) and upscale_output=True but
246
+ # upscale failing (final_frames may be GPU tensors from the as_tensor interpolation path).
247
+ final_frames = _frames_to_numpy(final_frames)
248
  print_infer_done(_time.perf_counter() - t_start, final_fps, len(final_frames))
249
+ return final_frames, final_fps
250
 
 
 
251
  print_infer_done(_time.perf_counter() - t_start, final_fps, len(upscaled_frames))
252
+ return upscaled_frames, final_fps
253
 
254
 
255
  def _spawn_log(
256
  input_image: Image.Image,
257
+ output_frames: Frames | None,
258
  output_fps: int | None,
259
  prompt: str,
260
  negative_prompt: str,
 
287
 
288
  def generate_video(
289
  input_image: Image.Image | None,
290
+ last_image: Image.Image | None,
291
  prompt: str,
292
  negative_prompt: str,
293
  duration_seconds: float,
 
303
  raise gr.Error("Please upload an input image.")
304
 
305
  resized_image = resize_image(input_image)
306
+ processed_last_image = resize_and_crop_to_match(last_image, resized_image) if last_image is not None else None
307
  num_frames = get_num_frames(duration_seconds)
308
  current_seed = random.randint(0, MAX_SEED) if randomize_seed else int(seed)
309
 
 
313
 
314
  t0 = _time.perf_counter()
315
  try:
316
+ final_frames, final_fps = run_inference(
317
  resized_image,
318
+ processed_last_image,
319
  prompt,
320
  negative_prompt,
321
  steps,
 
342
  duration = _time.perf_counter() - t0
343
 
344
  print_frames_info("final_frames", final_frames, final_fps)
 
345
 
346
  with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmpfile:
347
  video_path = tmpfile.name
 
360
  # run_inference — a video was still produced, so this still counts as success=True.
361
  # interpolation_enabled/upscale_output reflect what the user requested, not whether an
362
  # internal fallback fired.
 
363
  _spawn_log(
364
+ resized_image, final_frames, final_fps, prompt, negative_prompt, current_seed,
365
  steps, guidance_scale, interpolation_enabled, interpolation_multiplier, upscale_output,
366
  resized_image.width, resized_image.height, duration, True,
367
  )
 
396
  "- Service availability and response times may vary as this Space runs on shared GPU infrastructure.\n"
397
  "- When using this Space, please comply with the "
398
  "[Hugging Face Content Policy](https://huggingface.co/content-policy).\n"
399
+ "- This Space and its outputs are provided **\"as is\"**, without warranties of any kind. You are "
400
+ "solely responsible for the content you generate and for how you use or share it; the operator "
401
+ "accepts no liability for any loss, damage, or claim arising from use of this Space or its "
402
+ "outputs.\n"
403
  f"- To monitor application performance and improve quality, input data (image/prompt) and generated "
404
  f"outputs are logged solely for debugging purposes and retained in a private Hugging Face dataset "
405
  f"accessible only to {_OPERATOR_MD}, automatically pruned once the dataset reaches its configured "
 
414
  gr.Markdown(
415
  f"**Space:** High Quality Video Generation \n"
416
  f"**Operator:** {_OPERATOR_MD} \n"
417
+ f"**Last updated:** 2026-09-18\n"
418
  "\n"
419
  "#### What data is collected\n"
420
  "When you submit a request, the following is logged: your uploaded input image, the generated "
421
+ "output video, your prompt and negative prompt, seed, inference "
422
  "steps, guidance scale, frame-interpolation and upscaling settings, output resolution/fps/duration, "
423
  "generation duration, and success/error status. No user account, IP address, or session data is "
424
  "collected.\n"
 
473
  "interpolation, within the same GPU allocation, and adds to the GPU quota used.",
474
  )
475
  with gr.Accordion("Advanced Settings", open=False):
476
+ last_image_component = gr.Image(
477
+ type="pil",
478
+ label="Last Image (Optional) — end frame to animate towards",
479
+ sources=["upload", "clipboard"],
480
+ )
481
  negative_prompt_input = gr.Textbox(label="Negative Prompt", value=DEFAULT_NEGATIVE_PROMPT, lines=3)
482
  steps_slider = gr.Slider(minimum=1, maximum=30, step=1, value=6, label="Inference Steps")
483
  guidance_scale_input = gr.Slider(minimum=0.0, maximum=10.0, step=0.5, value=1.0, label="Guidance Scale")
 
493
  fn=generate_video,
494
  inputs=[
495
  input_image_component,
496
+ last_image_component,
497
  prompt_input,
498
  negative_prompt_input,
499
  duration_input,
 
509
 
510
  preview_inputs = [input_image_component, duration_input, frame_multiplier_input, upscale_checkbox]
511
  for component in preview_inputs:
512
+ component.change(fn=preview_effect, inputs=preview_inputs, outputs=effect_preview)
 
 
 
 
513
  demo.load(fn=preview_effect, inputs=preview_inputs, outputs=effect_preview)
514
 
515
  if __name__ == "__main__":
logging_utils.py CHANGED
@@ -3,8 +3,11 @@ Face Hub dataset repo (LOG-2 through LOG-9).
3
 
4
  Modeled on the FireRed-Image-Edit-1.0-Fast reference project's logging_utils.py/inference.py
5
  pipeline, adapted for video: logged media is referenced by path rather than embedded as bytes
6
- (LOG-5), the logged output video is the pre-upscale result (LOG-4), and retention is a
7
- total-storage-size cap rather than a file-count cap (LOG-7).
 
 
 
8
  """
9
 
10
  import json
@@ -271,19 +274,26 @@ def _group_by_stem(existing: list[tuple[str, int]]) -> dict[str, dict[str, Any]]
271
  return groups
272
 
273
 
274
- def _build_delete_ops_by_size(existing: list[tuple[str, int]], new_batch_size: int,
275
- max_bytes: int) -> list[CommitOperationDelete]:
276
  groups = _group_by_stem(existing)
277
- total = sum(g["size"] for g in groups.values()) + new_batch_size
278
- if max_bytes <= 0 or total <= max_bytes:
 
 
 
 
 
 
279
  return []
280
  ops: list[CommitOperationDelete] = []
281
  for stem in sorted(groups):
282
- if total <= max_bytes:
283
  break
284
  group = groups[stem]
285
  ops.extend(CommitOperationDelete(path_in_repo=p) for p in group["paths"])
286
- total -= group["size"]
 
287
  return ops
288
 
289
 
@@ -313,10 +323,12 @@ def _delete_temp_files(paths: list[str]) -> None:
313
 
314
 
315
  class LogUploader:
316
- def __init__(self, token: str | None, repo_id: str | None, max_bytes: int, batch_interval: int = 60) -> None:
 
317
  self._token = token
318
  self._repo_id = repo_id
319
  self._max_bytes = max_bytes
 
320
  self._batch_interval = batch_interval
321
  self._pending: list[tuple[str, str]] = []
322
  self._lock = threading.Lock()
@@ -412,9 +424,8 @@ class LogUploader:
412
  api = HfApi(token=self._token)
413
  api.create_repo(repo_id=self._repo_id, repo_type="dataset", private=True, exist_ok=True)
414
  existing = _list_existing_files_with_sizes(api, self._repo_id)
415
- new_batch_size = sum(os.path.getsize(local) for _, local in batch)
416
  add_ops = [CommitOperationAdd(path_in_repo=p, path_or_fileobj=local) for p, local in batch]
417
- del_ops = _build_delete_ops_by_size(existing, new_batch_size, self._max_bytes)
418
  api.create_commit(
419
  repo_id=self._repo_id, repo_type="dataset",
420
  operations=[*add_ops, *del_ops],
 
3
 
4
  Modeled on the FireRed-Image-Edit-1.0-Fast reference project's logging_utils.py/inference.py
5
  pipeline, adapted for video: logged media is referenced by path rather than embedded as bytes
6
+ (LOG-5), the logged output video is the same final result served to the user, and retention
7
+ prunes on both a total-storage-size cap and a per-directory file-count cap (LOG-7) — the latter exists
8
+ because the Hub commit endpoint rejects pushes once a directory (data/, images/, videos/)
9
+ holds more than 10000 files, which a size-only cap doesn't bound since data/ and images/
10
+ files are tiny compared to videos/.
11
  """
12
 
13
  import json
 
274
  return groups
275
 
276
 
277
+ def _build_delete_ops(existing: list[tuple[str, int]], new_batch: list[tuple[str, str]],
278
+ max_bytes: int, max_files: int) -> list[CommitOperationDelete]:
279
  groups = _group_by_stem(existing)
280
+ new_stems = {_stem_of(p) for p, _ in new_batch}
281
+ total_bytes = sum(g["size"] for g in groups.values()) + sum(os.path.getsize(local) for _, local in new_batch)
282
+ total_files = len(groups) + len(new_stems)
283
+
284
+ def _over_cap() -> bool:
285
+ return (max_bytes > 0 and total_bytes > max_bytes) or (max_files > 0 and total_files > max_files)
286
+
287
+ if not _over_cap():
288
  return []
289
  ops: list[CommitOperationDelete] = []
290
  for stem in sorted(groups):
291
+ if not _over_cap():
292
  break
293
  group = groups[stem]
294
  ops.extend(CommitOperationDelete(path_in_repo=p) for p in group["paths"])
295
+ total_bytes -= group["size"]
296
+ total_files -= 1
297
  return ops
298
 
299
 
 
323
 
324
 
325
  class LogUploader:
326
+ def __init__(self, token: str | None, repo_id: str | None, max_bytes: int, max_files: int,
327
+ batch_interval: int = 60) -> None:
328
  self._token = token
329
  self._repo_id = repo_id
330
  self._max_bytes = max_bytes
331
+ self._max_files = max_files
332
  self._batch_interval = batch_interval
333
  self._pending: list[tuple[str, str]] = []
334
  self._lock = threading.Lock()
 
424
  api = HfApi(token=self._token)
425
  api.create_repo(repo_id=self._repo_id, repo_type="dataset", private=True, exist_ok=True)
426
  existing = _list_existing_files_with_sizes(api, self._repo_id)
 
427
  add_ops = [CommitOperationAdd(path_in_repo=p, path_or_fileobj=local) for p, local in batch]
428
+ del_ops = _build_delete_ops(existing, batch, self._max_bytes, self._max_files)
429
  api.create_commit(
430
  repo_id=self._repo_id, repo_type="dataset",
431
  operations=[*add_ops, *del_ops],
model/pipeline.py CHANGED
@@ -1,14 +1,21 @@
1
  """WAMU_v3 image-to-video pipeline loading and sizing helpers.
2
 
3
- Baseline (non-AOT) loading per the SRS's "baseline first, AOT after" sequencing: FR-6's
4
- AOT-compiled-transformer requirement is deferred until AOT-package compatibility with WAMU_v3
5
- is confirmed (open item C-2, tracked in issue #3). Weight quantization (below) is independent
6
- of AOT compilation and is applied now: the full bf16 pipe packs to ~69GB, which made ZeroGPU's
7
- per-call GPU init (loading the packed weights onto the GPU) exceed the requested call duration.
 
 
 
 
 
 
8
  """
9
 
10
  from __future__ import annotations
11
 
 
12
  import torch
13
  import torch._dynamo
14
  from diffusers.pipelines.wan.pipeline_wan_i2v import WanImageToVideoPipeline
@@ -17,6 +24,15 @@ from torchao.quantization import Float8DynamicActivationFloat8WeightConfig, Int8
17
 
18
  MODEL_ID = "thornmaze/WAMU_v3_WAN2.2_I2V_LIGHTNING"
19
 
 
 
 
 
 
 
 
 
 
20
  MAX_DIM = 832
21
  MIN_DIM = 480
22
  SQUARE_DIM = 640
@@ -41,7 +57,6 @@ def load_pipeline() -> WanImageToVideoPipeline:
41
  pipe = WanImageToVideoPipeline.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16).to("cuda")
42
 
43
  # Quantize to shrink the ~69GB bf16 pipe (drops per-call ZeroGPU init time and peak memory).
44
- # Independent of AOT compilation (FR-6/C-2, deferred) — same recipe as the reference project.
45
  quantize_(pipe.text_encoder, Int8WeightOnlyConfig())
46
  torch._dynamo.reset()
47
  quantize_(pipe.transformer, Float8DynamicActivationFloat8WeightConfig())
@@ -49,6 +64,18 @@ def load_pipeline() -> WanImageToVideoPipeline:
49
  quantize_(pipe.transformer_2, Float8DynamicActivationFloat8WeightConfig())
50
  torch._dynamo.reset()
51
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  return pipe
53
 
54
 
@@ -86,6 +113,18 @@ def resize_image(image: Image.Image) -> Image.Image:
86
  return image_to_resize.resize((final_w, final_h), Image.Resampling.LANCZOS)
87
 
88
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  def get_num_frames(duration_seconds: float) -> int:
90
  raw = int(round(duration_seconds * FIXED_FPS))
91
  raw = max(MIN_FRAMES_MODEL, min(MAX_FRAMES_MODEL, raw))
@@ -99,6 +138,10 @@ def estimate_generation_seconds(
99
 
100
  Exact limits are still an open item (SRS Section 4, #3) pending empirical testing on the
101
  dev Space; this heuristic is a starting point, not a final calibration.
 
 
 
 
102
  """
103
  base_frames_height_width = 81 * 832 * 624
104
  base_step_duration = 5.0
@@ -108,9 +151,8 @@ def estimate_generation_seconds(
108
  if guidance_scale > 1:
109
  gen_time *= 2.4
110
 
111
- # The reference project's "15" was calibrated with AOT-compiled transformers (FR-6, deferred
112
- # here per C-2/issue #3). Without AOT, per-call ZeroGPU init (loading the quantized-but-not-
113
- # compiled pipe onto the GPU) plus eager-mode execution both take longer, so pad generously
114
- # until this can be measured empirically on the dev Space.
115
  init_overhead = 60
116
  return init_overhead + gen_time
 
1
  """WAMU_v3 image-to-video pipeline loading and sizing helpers.
2
 
3
+ Weight quantization (below) shrinks the full bf16 pipe from ~69GB, which made ZeroGPU's per-call
4
+ GPU init (loading the packed weights onto the GPU) exceed the requested call duration. The
5
+ transformers additionally get an AOT-compiled forward (FR-6) via `spaces.aoti_load()`, matching
6
+ the reference project: this is what makes `Float8DynamicActivationFloat8WeightConfig` (fp8x8
7
+ matmuls, both weights and activations quantized) safe to run. AOTInductor pre-plans a single
8
+ memory pool for every op in the compiled graph, including that config's per-layer
9
+ activation-quantize casts; run the same config eagerly (no AOT), as an earlier version of this
10
+ file did, and every one of those casts is an unplanned CUDA allocator request instead — that
11
+ churn reliably crashed ZeroGPU with an NVML assert (#35), fixed at the time by downgrading to
12
+ weight-only fp8 (no runtime activation quantization) as a stopgap. See #36 for the AOT
13
+ investigation (package-reuse feasibility) that unblocked switching back.
14
  """
15
 
16
  from __future__ import annotations
17
 
18
+ import spaces
19
  import torch
20
  import torch._dynamo
21
  from diffusers.pipelines.wan.pipeline_wan_i2v import WanImageToVideoPipeline
 
24
 
25
  MODEL_ID = "thornmaze/WAMU_v3_WAN2.2_I2V_LIGHTNING"
26
 
27
+ # Precompiled AOT package for the transformer, from the reference project (zerogpu-aoti's
28
+ # wan2-2-fp8da-aoti-faster Space). Confirmed reusable for WAMU_v3 without recompiling (#36):
29
+ # WAMU_v3's transformer/transformer_2 share the exact same config (layer count, head dims, etc.)
30
+ # as the Wan2.2-I2V-A14B base model this package was compiled against — `spaces.aoti_load()`
31
+ # applies compiled kernels to an already-loaded model rather than embedding weights, so
32
+ # compatibility hinges on that shape/architecture match, not on WAMU_v3's fine-tuned weight
33
+ # values. "sm120" in the name matches ZeroGPU's current hardware (RTX Pro 6000 Blackwell).
34
+ AOTI_TRANSFORMER_REPO = "thornmaze/WanTransformer3DModel-sm120-cu130-raa"
35
+
36
  MAX_DIM = 832
37
  MIN_DIM = 480
38
  SQUARE_DIM = 640
 
57
  pipe = WanImageToVideoPipeline.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16).to("cuda")
58
 
59
  # Quantize to shrink the ~69GB bf16 pipe (drops per-call ZeroGPU init time and peak memory).
 
60
  quantize_(pipe.text_encoder, Int8WeightOnlyConfig())
61
  torch._dynamo.reset()
62
  quantize_(pipe.transformer, Float8DynamicActivationFloat8WeightConfig())
 
64
  quantize_(pipe.transformer_2, Float8DynamicActivationFloat8WeightConfig())
65
  torch._dynamo.reset()
66
 
67
+ # AOT-compile the transformers (FR-6) — see module docstring and #36 for why this must come
68
+ # after the dynamic-activation quantize_() calls above, not before: the compiled graph is
69
+ # built from (and expects) that config's op layout, matching how the reference project's own
70
+ # usage example sequences it.
71
+ #
72
+ # Not yet validated against this repo's pinned torch==2.11.0: the package was compiled with
73
+ # torch==2.9.1+cu130, and AOTInductor artifacts can be ABI-sensitive across torch minor
74
+ # versions. Needs real-GPU testing on the dev Space — if loading fails here, the fallback is
75
+ # either pinning torch back to 2.9.1 or recompiling this package against 2.11.0.
76
+ spaces.aoti_load(module=pipe.transformer, repo_id=AOTI_TRANSFORMER_REPO)
77
+ spaces.aoti_load(module=pipe.transformer_2, repo_id=AOTI_TRANSFORMER_REPO)
78
+
79
  return pipe
80
 
81
 
 
113
  return image_to_resize.resize((final_w, final_h), Image.Resampling.LANCZOS)
114
 
115
 
116
+ def resize_and_crop_to_match(target_image: Image.Image, reference_image: Image.Image) -> Image.Image:
117
+ """Resize/crop `target_image` to exactly match `reference_image`'s size (the pipeline requires
118
+ the first and last frame to share dimensions)."""
119
+ ref_width, ref_height = reference_image.size
120
+ target_width, target_height = target_image.size
121
+ scale = max(ref_width / target_width, ref_height / target_height)
122
+ new_width, new_height = int(target_width * scale), int(target_height * scale)
123
+ resized = target_image.resize((new_width, new_height), Image.Resampling.LANCZOS)
124
+ left, top = (new_width - ref_width) // 2, (new_height - ref_height) // 2
125
+ return resized.crop((left, top, left + ref_width, top + ref_height))
126
+
127
+
128
  def get_num_frames(duration_seconds: float) -> int:
129
  raw = int(round(duration_seconds * FIXED_FPS))
130
  raw = max(MIN_FRAMES_MODEL, min(MAX_FRAMES_MODEL, raw))
 
138
 
139
  Exact limits are still an open item (SRS Section 4, #3) pending empirical testing on the
140
  dev Space; this heuristic is a starting point, not a final calibration.
141
+
142
+ Kept conservative even now that the transformers are AOT-compiled (#36): the reference
143
+ project's numbers below assume a validated AOT setup, and ours hasn't been measured on real
144
+ GPU yet (see load_pipeline's AOT caveat) — recalibrate once that's confirmed working.
145
  """
146
  base_frames_height_width = 81 * 832 * 624
147
  base_step_duration = 5.0
 
151
  if guidance_scale > 1:
152
  gen_time *= 2.4
153
 
154
+ # Padded well above the reference project's flat overhead (its pipe loads a validated AOT
155
+ # setup already proven on its own GPU quota) until ours is confirmed working and measured
156
+ # empirically on the dev Space (#36).
 
157
  init_overhead = 60
158
  return init_overhead + gen_time
postprocess/interpolation.py CHANGED
@@ -6,6 +6,10 @@ Model code/weights come from `thornmaze/RIFE`, a packaged build of hzwer/Practic
6
  `from model.warplayer import warp` (and, at module load time, from `model.loss` and
7
  `model.pytorch_msssim`) expecting a sibling `model` package — see the vendored copies in
8
  model/warplayer.py, model/loss.py, model/pytorch_msssim/.
 
 
 
 
9
  """
10
 
11
  from __future__ import annotations
@@ -13,7 +17,7 @@ from __future__ import annotations
13
  import subprocess
14
  from functools import lru_cache
15
  from pathlib import Path
16
- from typing import TYPE_CHECKING, Any, Callable
17
 
18
  import numpy as np
19
  import torch
@@ -37,7 +41,9 @@ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
37
  ProgressCallback = Callable[[int, int], None]
38
 
39
 
40
- def _ensure_weights_downloaded() -> None:
 
 
41
  if RIFE_DIR.exists():
42
  return
43
  subprocess.run(["wget", "-q", RIFE_ZIP_URL, "-O", str(RIFE_ZIP_PATH)], check=True)
@@ -46,7 +52,7 @@ def _ensure_weights_downloaded() -> None:
46
 
47
  @lru_cache(maxsize=1)
48
  def _load_model() -> Any:
49
- _ensure_weights_downloaded()
50
  from train_log.RIFE_HDv3 import Model # only importable after the zip is unpacked
51
 
52
  model = Model()
@@ -63,15 +69,21 @@ def interpolate_frames(
63
  multiplier: int,
64
  scale: float = 1.0,
65
  progress_callback: ProgressCallback | None = None,
66
- ) -> list[FrameArray]:
 
67
  """Interpolate frames with RIFE to `multiplier`x the input frame rate.
68
 
69
  Args:
70
  frames_np: (T, H, W, C) array or list of (H, W, C) arrays, float32 in [0, 1].
71
  multiplier: 2, 4, or 8. Values < 2 return the frames unchanged (as a list).
72
  progress_callback: progress_callback(done, total) fired after each input-frame gap.
 
 
 
 
73
  Returns:
74
- List of (H, W, C) float32 numpy arrays in [0, 1].
 
75
  """
76
  if multiplier < 2:
77
  return list(frames_np) if isinstance(frames_np, np.ndarray) else frames_np
@@ -91,11 +103,19 @@ def interpolate_frames(
91
  t = t.permute(2, 0, 1).unsqueeze(0)
92
  return F.pad(t, padding).half()
93
 
94
- def from_tensor(tensor: torch.Tensor) -> FrameArray:
95
  t = tensor[0, :, :height, :width]
96
  t = t.permute(1, 2, 0)
97
  return t.float().cpu().numpy()
98
 
 
 
 
 
 
 
 
 
99
  def make_inference(i0: torch.Tensor, i1: torch.Tensor, n: int) -> list[torch.Tensor]:
100
  if model.version >= 3.9:
101
  return [model.inference(i0, i1, (i + 1) / (n + 1), scale) for i in range(n)]
@@ -108,7 +128,7 @@ def interpolate_frames(
108
  return [*first_half, middle, *second_half]
109
  return [*first_half, *second_half]
110
 
111
- output_frames: list[FrameArray] = []
112
  next_tensor = to_tensor(frames_np[0])
113
  total_steps = total_frames - 1
114
 
@@ -126,4 +146,6 @@ def interpolate_frames(
126
  del current_tensor, next_tensor
127
  torch.cuda.empty_cache()
128
 
129
- return output_frames
 
 
 
6
  `from model.warplayer import warp` (and, at module load time, from `model.loss` and
7
  `model.pytorch_msssim`) expecting a sibling `model` package — see the vendored copies in
8
  model/warplayer.py, model/loss.py, model/pytorch_msssim/.
9
+
10
+ The zip download/unzip itself needs no GPU, so `ensure_weights_downloaded()` is called from
11
+ app.py at process startup (like `load_pipeline()`), before any `@spaces.GPU` call — otherwise
12
+ a cold container pays for that fetch out of the metered GPU allocation on its first request.
13
  """
14
 
15
  from __future__ import annotations
 
17
  import subprocess
18
  from functools import lru_cache
19
  from pathlib import Path
20
+ from typing import TYPE_CHECKING, Any, Callable, cast
21
 
22
  import numpy as np
23
  import torch
 
41
  ProgressCallback = Callable[[int, int], None]
42
 
43
 
44
+ def ensure_weights_downloaded() -> None:
45
+ """Download and unpack the RIFE weights if not already present. CPU/network only — no CUDA
46
+ needed — so this is safe (and intended) to call outside any @spaces.GPU allocation."""
47
  if RIFE_DIR.exists():
48
  return
49
  subprocess.run(["wget", "-q", RIFE_ZIP_URL, "-O", str(RIFE_ZIP_PATH)], check=True)
 
52
 
53
  @lru_cache(maxsize=1)
54
  def _load_model() -> Any:
55
+ ensure_weights_downloaded()
56
  from train_log.RIFE_HDv3 import Model # only importable after the zip is unpacked
57
 
58
  model = Model()
 
69
  multiplier: int,
70
  scale: float = 1.0,
71
  progress_callback: ProgressCallback | None = None,
72
+ as_tensor: bool = False,
73
+ ) -> list[FrameArray] | list[torch.Tensor]:
74
  """Interpolate frames with RIFE to `multiplier`x the input frame rate.
75
 
76
  Args:
77
  frames_np: (T, H, W, C) array or list of (H, W, C) arrays, float32 in [0, 1].
78
  multiplier: 2, 4, or 8. Values < 2 return the frames unchanged (as a list).
79
  progress_callback: progress_callback(done, total) fired after each input-frame gap.
80
+ as_tensor: if True, skip the GPU->CPU conversion and return (C, H, W) fp16 GPU
81
+ tensors instead of (H, W, C) float32 numpy arrays — for callers (e.g. upscale)
82
+ that consume the result on GPU right after, so frames never round-trip to CPU
83
+ in between.
84
  Returns:
85
+ List of (H, W, C) float32 numpy arrays in [0, 1], or (if as_tensor) list of
86
+ (C, H, W) fp16 GPU tensors in [0, 1].
87
  """
88
  if multiplier < 2:
89
  return list(frames_np) if isinstance(frames_np, np.ndarray) else frames_np
 
103
  t = t.permute(2, 0, 1).unsqueeze(0)
104
  return F.pad(t, padding).half()
105
 
106
+ def from_tensor_np(tensor: torch.Tensor) -> FrameArray:
107
  t = tensor[0, :, :height, :width]
108
  t = t.permute(1, 2, 0)
109
  return t.float().cpu().numpy()
110
 
111
+ def from_tensor_gpu(tensor: torch.Tensor) -> torch.Tensor:
112
+ # .contiguous() detaches from the padded working tensor's storage (a plain crop is
113
+ # just a view into it) so the padded buffer isn't kept alive for the batch's lifetime.
114
+ return tensor[0, :, :height, :width].contiguous()
115
+
116
+ from_tensor: Callable[[torch.Tensor], FrameArray | torch.Tensor]
117
+ from_tensor = from_tensor_gpu if as_tensor else from_tensor_np
118
+
119
  def make_inference(i0: torch.Tensor, i1: torch.Tensor, n: int) -> list[torch.Tensor]:
120
  if model.version >= 3.9:
121
  return [model.inference(i0, i1, (i + 1) / (n + 1), scale) for i in range(n)]
 
128
  return [*first_half, middle, *second_half]
129
  return [*first_half, *second_half]
130
 
131
+ output_frames: list[FrameArray | torch.Tensor] = []
132
  next_tensor = to_tensor(frames_np[0])
133
  total_steps = total_frames - 1
134
 
 
146
  del current_tensor, next_tensor
147
  torch.cuda.empty_cache()
148
 
149
+ if as_tensor:
150
+ return cast("list[torch.Tensor]", output_frames)
151
+ return cast("list[FrameArray]", output_frames)
postprocess/upscale/upscale.py CHANGED
@@ -2,9 +2,10 @@
2
 
3
  Inference structure (pre-pad, tile, merge, crop) is adapted from xinntao/Real-ESRGAN's `RealESRGANer`
4
  (BSD-3-Clause License, see `LICENSES/REAL-ESRGAN-LICENSE`, `realesrgan/utils.py`), trimmed to the single code path
5
- this Space needs: one fixed compact model, RGB `PIL.Image` in and out (no alpha channel, no 16-bit, no cv2/BGR
6
- round-trip, no `dni` model blending, no CLI prefetch/IO-queue threads — none of those apply to frames coming
7
- straight out of the Wan 2.2 decode step).
 
8
 
9
  The weights are `4xLSDIRCompact` (Philip Hofmann, CC BY 4.0, see `LICENSES/4xLSDIRCompact-LICENSE`) rather than
10
  Real-ESRGAN's own `realesr-general-x4v3`: same `SRVGGNetCompact` architecture (just `num_conv=16` instead of 32,
@@ -16,6 +17,10 @@ flattens real skin/fabric/hair texture into a smooth, CG-like look on frames tha
16
  Runs inside the caller's `@spaces.GPU` allocation (see app.py's `run_inference`), same as RIFE interpolation
17
  (postprocess/interpolation.py) — moved off the Space's shared CPU per issue #11 to avoid CPU contention across
18
  concurrent visitors. Falls back to CPU/fp32 automatically when no CUDA device is present (e.g. local runs).
 
 
 
 
19
  """
20
 
21
  from __future__ import annotations
@@ -23,7 +28,7 @@ from __future__ import annotations
23
  import math
24
  from functools import lru_cache
25
  from pathlib import Path
26
- from typing import Callable
27
 
28
  import numpy as np
29
  import torch
@@ -34,34 +39,71 @@ from torch.hub import download_url_to_file, get_dir
34
 
35
  from .srvgg_arch import SRVGGNetCompact
36
 
 
 
 
 
 
 
 
 
 
37
  WEIGHTS_URL = "https://huggingface.co/Phips/4xLSDIRCompact/resolve/main/4xLSDIRCompact.safetensors"
38
  SCALE = 4
39
  NUM_CONV = 16
40
- TILE_SIZE = 256
 
 
 
 
41
  TILE_PAD = 10
 
 
 
 
 
 
 
42
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
43
  dtype = torch.float16 if device.type == "cuda" else torch.float32
44
 
45
  ProgressCallback = Callable[[int, int], None]
46
 
47
 
48
- def _download_weights() -> Path:
49
  cache_dir = Path(get_dir()) / "checkpoints"
50
  cache_dir.mkdir(parents=True, exist_ok=True)
51
- weights_path = cache_dir / Path(WEIGHTS_URL).name
 
 
 
 
 
 
52
  if not weights_path.exists():
53
  download_url_to_file(WEIGHTS_URL, str(weights_path))
54
- return weights_path
55
 
56
 
57
  @lru_cache(maxsize=1)
58
- def _load_model() -> SRVGGNetCompact:
59
- model = SRVGGNetCompact( # type: ignore[no-untyped-call] # vendored, untyped (see srvgg_arch.py)
60
  num_in_ch=3, num_out_ch=3, num_feat=64, num_conv=NUM_CONV, upscale=SCALE, act_type="prelu"
61
  )
62
- state_dict = load_file(_download_weights())
 
63
  model.load_state_dict(state_dict, strict=True)
64
  model.to(device=device, dtype=dtype).eval()
 
 
 
 
 
 
 
 
 
 
 
65
  return model
66
 
67
 
@@ -70,51 +112,101 @@ def _pre_pad(tensor: torch.Tensor, pad: int) -> torch.Tensor:
70
 
71
 
72
  @torch.no_grad()
73
- def _tile_process(model: SRVGGNetCompact, img: torch.Tensor) -> torch.Tensor:
74
  batch, channel, height, width = img.shape
75
  output = img.new_zeros((batch, channel, height * SCALE, width * SCALE))
76
  tiles_x = math.ceil(width / TILE_SIZE)
77
  tiles_y = math.ceil(height / TILE_SIZE)
78
 
79
- for y in range(tiles_y):
80
- for x in range(tiles_x):
81
- in_x0, in_x1 = x * TILE_SIZE, min((x + 1) * TILE_SIZE, width)
82
- in_y0, in_y1 = y * TILE_SIZE, min((y + 1) * TILE_SIZE, height)
83
- pad_x0, pad_x1 = max(in_x0 - TILE_PAD, 0), min(in_x1 + TILE_PAD, width)
84
- pad_y0, pad_y1 = max(in_y0 - TILE_PAD, 0), min(in_y1 + TILE_PAD, height)
85
-
86
- tile_out = model(img[:, :, pad_y0:pad_y1, pad_x0:pad_x1])
87
-
88
- trim_x0, trim_y0 = (in_x0 - pad_x0) * SCALE, (in_y0 - pad_y0) * SCALE
89
- trim_x1 = trim_x0 + (in_x1 - in_x0) * SCALE
90
- trim_y1 = trim_y0 + (in_y1 - in_y0) * SCALE
91
- output[:, :, in_y0 * SCALE:in_y1 * SCALE, in_x0 * SCALE:in_x1 * SCALE] = (
92
- tile_out[:, :, trim_y0:trim_y1, trim_x0:trim_x1]
 
 
 
 
 
 
 
 
 
 
93
  )
 
 
 
 
 
 
 
 
 
94
  return output
95
 
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  def upscale_frames(
98
- frames: list[Image.Image], progress_callback: ProgressCallback | None = None
99
  ) -> list[Image.Image]:
100
  """Upscale every frame 4x with `4xLSDIRCompact`, tiled to bound peak memory.
101
 
102
- progress_callback(done, total), if given, fires after each frame finishes.
 
 
 
 
 
 
 
 
103
  """
104
  if not frames:
105
  return []
106
  model = _load_model()
107
- out_frames = []
108
- for frame in frames:
109
- w, h = frame.size
110
- array = np.asarray(frame.convert("RGB"), dtype=np.float32) / 255.0
111
- tensor = torch.from_numpy(array).permute(2, 0, 1).unsqueeze(0).to(device=device, dtype=dtype)
 
 
 
 
 
 
 
112
  padded = _pre_pad(tensor, TILE_PAD)
113
  upscaled = _tile_process(model, padded)
114
  # crop the pre-pad border (scaled) back off
115
  upscaled = upscaled[:, :, : h * SCALE, : w * SCALE]
116
- result = upscaled.squeeze(0).clamp(0, 1).permute(1, 2, 0).float().cpu().numpy()
117
- out_frames.append(Image.fromarray((result * 255.0).round().astype(np.uint8)))
118
- if progress_callback is not None:
119
- progress_callback(len(out_frames), len(frames))
 
120
  return out_frames
 
2
 
3
  Inference structure (pre-pad, tile, merge, crop) is adapted from xinntao/Real-ESRGAN's `RealESRGANer`
4
  (BSD-3-Clause License, see `LICENSES/REAL-ESRGAN-LICENSE`, `realesrgan/utils.py`), trimmed to the single code path
5
+ this Space needs: one fixed compact model, RGB frames in (numpy float32 `[0,1]`, `(H,W,C)` — the same
6
+ representation `_apply_interpolation` already produces, so no PIL round trip is needed at the boundary) and
7
+ `PIL.Image` out (no alpha channel, no 16-bit, no cv2/BGR round-trip, no `dni` model blending, no CLI
8
+ prefetch/IO-queue threads — none of those apply to frames coming straight out of the Wan 2.2 decode step).
9
 
10
  The weights are `4xLSDIRCompact` (Philip Hofmann, CC BY 4.0, see `LICENSES/4xLSDIRCompact-LICENSE`) rather than
11
  Real-ESRGAN's own `realesr-general-x4v3`: same `SRVGGNetCompact` architecture (just `num_conv=16` instead of 32,
 
17
  Runs inside the caller's `@spaces.GPU` allocation (see app.py's `run_inference`), same as RIFE interpolation
18
  (postprocess/interpolation.py) — moved off the Space's shared CPU per issue #11 to avoid CPU contention across
19
  concurrent visitors. Falls back to CPU/fp32 automatically when no CUDA device is present (e.g. local runs).
20
+
21
+ The weight download itself needs no GPU, so `ensure_weights_downloaded()` is called from app.py at process
22
+ startup (like `load_pipeline()`), before any `@spaces.GPU` call — otherwise a cold container pays for that
23
+ fetch out of the metered GPU allocation on its first upscale request.
24
  """
25
 
26
  from __future__ import annotations
 
28
  import math
29
  from functools import lru_cache
30
  from pathlib import Path
31
+ from typing import TYPE_CHECKING, Callable, cast
32
 
33
  import numpy as np
34
  import torch
 
39
 
40
  from .srvgg_arch import SRVGGNetCompact
41
 
42
+ if TYPE_CHECKING:
43
+ # numpy.typing needs numpy>=1.20; requirements.txt allows down to 1.16, so this must stay out
44
+ # of the runtime import path.
45
+ import numpy.typing as npt
46
+
47
+ FrameArray = npt.NDArray[np.float32]
48
+ else:
49
+ FrameArray = np.ndarray
50
+
51
  WEIGHTS_URL = "https://huggingface.co/Phips/4xLSDIRCompact/resolve/main/4xLSDIRCompact.safetensors"
52
  SCALE = 4
53
  NUM_CONV = 16
54
+ # Larger tiles mean fewer, bigger model() calls (less pad/crop overhead per pixel, better GPU
55
+ # occupancy) at the cost of more peak memory per call. 512 is still conservative for a 16GB+
56
+ # Spaces GPU (T4/L4/A10G) with such a compact network — bump further if profiling on the dev
57
+ # Space shows headroom.
58
+ TILE_SIZE = 512
59
  TILE_PAD = 10
60
+ # Frames are grouped into batches (of equal size) before tiling, and same-shaped tile patches
61
+ # — across both the frame batch and tile positions — are run through the model together instead
62
+ # of one at a time, since SRVGGNetCompact is small enough that a single 256x256-ish tile leaves
63
+ # most of the GPU idle. Conservative defaults for a 16GB+ Spaces GPU (T4/L4/A10G); bump if
64
+ # profiling on the dev Space shows headroom.
65
+ FRAME_BATCH_SIZE = 4
66
+ MAX_TILE_BATCH = 16
67
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
68
  dtype = torch.float16 if device.type == "cuda" else torch.float32
69
 
70
  ProgressCallback = Callable[[int, int], None]
71
 
72
 
73
+ def _weights_path() -> Path:
74
  cache_dir = Path(get_dir()) / "checkpoints"
75
  cache_dir.mkdir(parents=True, exist_ok=True)
76
+ return cache_dir / Path(WEIGHTS_URL).name
77
+
78
+
79
+ def ensure_weights_downloaded() -> None:
80
+ """Download the 4xLSDIRCompact weights if not already cached. CPU/network only — no CUDA
81
+ needed — so this is safe (and intended) to call outside any @spaces.GPU allocation."""
82
+ weights_path = _weights_path()
83
  if not weights_path.exists():
84
  download_url_to_file(WEIGHTS_URL, str(weights_path))
 
85
 
86
 
87
  @lru_cache(maxsize=1)
88
+ def _load_model() -> torch.nn.Module:
89
+ model: torch.nn.Module = SRVGGNetCompact( # type: ignore[no-untyped-call] # vendored, untyped (see srvgg_arch.py)
90
  num_in_ch=3, num_out_ch=3, num_feat=64, num_conv=NUM_CONV, upscale=SCALE, act_type="prelu"
91
  )
92
+ ensure_weights_downloaded()
93
+ state_dict = load_file(_weights_path())
94
  model.load_state_dict(state_dict, strict=True)
95
  model.to(device=device, dtype=dtype).eval()
96
+ if device.type == "cuda":
97
+ try:
98
+ # dynamic=True avoids a fresh recompile for every distinct tile shape (interior
99
+ # tiles vs. the last row/column, clipped against the image edge) — there are only a
100
+ # handful of those per video, but recompiling per-shape would otherwise eat into the
101
+ # first frame's latency. Guarded: torch.compile can fail to initialize (e.g. missing
102
+ # or incompatible triton on the target GPU image), in which case the eager model is
103
+ # used as-is.
104
+ model = cast(torch.nn.Module, torch.compile(model, dynamic=True))
105
+ except Exception as e:
106
+ print(f"[upscale] torch.compile failed to initialize, using eager model: {e!r}", flush=True)
107
  return model
108
 
109
 
 
112
 
113
 
114
  @torch.no_grad()
115
+ def _tile_process(model: torch.nn.Module, img: torch.Tensor) -> torch.Tensor:
116
  batch, channel, height, width = img.shape
117
  output = img.new_zeros((batch, channel, height * SCALE, width * SCALE))
118
  tiles_x = math.ceil(width / TILE_SIZE)
119
  tiles_y = math.ceil(height / TILE_SIZE)
120
 
121
+ # Every tile job needs its own input patch (pad_*) and output placement (in_*/trim_*), but
122
+ # patches share the same H/W across the batch dim and across interior tile positions — only
123
+ # the last row/column of tiles is smaller, clipped against the image edge. Group jobs by
124
+ # patch shape so each model() call is a single batched forward pass over same-shaped patches
125
+ # (chunked to MAX_TILE_BATCH to bound memory) instead of one tile at a time.
126
+ jobs_by_shape: dict[tuple[int, int], list[tuple[int, int, int, int, int, int, int, int, int]]] = {}
127
+ for b in range(batch):
128
+ for y in range(tiles_y):
129
+ for x in range(tiles_x):
130
+ in_x0, in_x1 = x * TILE_SIZE, min((x + 1) * TILE_SIZE, width)
131
+ in_y0, in_y1 = y * TILE_SIZE, min((y + 1) * TILE_SIZE, height)
132
+ pad_x0, pad_x1 = max(in_x0 - TILE_PAD, 0), min(in_x1 + TILE_PAD, width)
133
+ pad_y0, pad_y1 = max(in_y0 - TILE_PAD, 0), min(in_y1 + TILE_PAD, height)
134
+ shape = (pad_y1 - pad_y0, pad_x1 - pad_x0)
135
+ jobs_by_shape.setdefault(shape, []).append(
136
+ (b, in_x0, in_y0, in_x1, in_y1, pad_x0, pad_y0, pad_x1, pad_y1)
137
+ )
138
+
139
+ for jobs in jobs_by_shape.values():
140
+ for chunk_start in range(0, len(jobs), MAX_TILE_BATCH):
141
+ chunk = jobs[chunk_start:chunk_start + MAX_TILE_BATCH]
142
+ patch = torch.cat(
143
+ [img[b:b + 1, :, pad_y0:pad_y1, pad_x0:pad_x1] for b, _, _, _, _, pad_x0, pad_y0, pad_x1, pad_y1 in chunk],
144
+ dim=0,
145
  )
146
+ tile_out = model(patch)
147
+
148
+ for i, (b, in_x0, in_y0, in_x1, in_y1, pad_x0, pad_y0, pad_x1, pad_y1) in enumerate(chunk):
149
+ trim_x0, trim_y0 = (in_x0 - pad_x0) * SCALE, (in_y0 - pad_y0) * SCALE
150
+ trim_x1 = trim_x0 + (in_x1 - in_x0) * SCALE
151
+ trim_y1 = trim_y0 + (in_y1 - in_y0) * SCALE
152
+ output[b:b + 1, :, in_y0 * SCALE:in_y1 * SCALE, in_x0 * SCALE:in_x1 * SCALE] = (
153
+ tile_out[i:i + 1, :, trim_y0:trim_y1, trim_x0:trim_x1]
154
+ )
155
  return output
156
 
157
 
158
+ def _frame_hw(frame: FrameArray | torch.Tensor) -> tuple[int, int]:
159
+ # numpy frames are (H, W, C); GPU tensor frames (from RIFE's as_tensor=True path) are (C, H, W).
160
+ h, w = frame.shape[-2:] if isinstance(frame, torch.Tensor) else frame.shape[:2]
161
+ return int(h), int(w)
162
+
163
+
164
+ def _frames_to_tensor(frames: list[FrameArray | torch.Tensor]) -> torch.Tensor:
165
+ if isinstance(frames[0], torch.Tensor):
166
+ # Already (C, H, W) fp16 GPU tensors (RIFE's as_tensor=True output) — just batch them,
167
+ # no host round trip.
168
+ return torch.stack(cast("list[torch.Tensor]", frames), dim=0).to(device=device, dtype=dtype)
169
+ array = np.stack(cast("list[FrameArray]", frames))
170
+ return torch.from_numpy(array).permute(0, 3, 1, 2).to(device=device, dtype=dtype)
171
+
172
+
173
  def upscale_frames(
174
+ frames: list[FrameArray] | list[torch.Tensor], progress_callback: ProgressCallback | None = None
175
  ) -> list[Image.Image]:
176
  """Upscale every frame 4x with `4xLSDIRCompact`, tiled to bound peak memory.
177
 
178
+ frames are either numpy float32 arrays in `[0,1]`, shape `(H,W,C)`, or (C,H,W) fp16 GPU
179
+ tensors — the same representations `_apply_interpolation` can produce, so callers don't
180
+ need to round-trip through `PIL.Image`, or through the CPU at all, just to cross this
181
+ boundary.
182
+
183
+ Frames are processed in batches (of up to FRAME_BATCH_SIZE, split wherever frame size
184
+ changes) rather than one at a time, so the model sees a real batch dimension instead of
185
+ batch=1 on every call. progress_callback(done, total), if given, fires after each frame
186
+ finishes.
187
  """
188
  if not frames:
189
  return []
190
  model = _load_model()
191
+ out_frames: list[Image.Image] = []
192
+
193
+ i = 0
194
+ while i < len(frames):
195
+ h, w = _frame_hw(frames[i])
196
+ batch: list[FrameArray | torch.Tensor] = [frames[i]]
197
+ i += 1
198
+ while i < len(frames) and len(batch) < FRAME_BATCH_SIZE and _frame_hw(frames[i]) == (h, w):
199
+ batch.append(frames[i])
200
+ i += 1
201
+
202
+ tensor = _frames_to_tensor(batch)
203
  padded = _pre_pad(tensor, TILE_PAD)
204
  upscaled = _tile_process(model, padded)
205
  # crop the pre-pad border (scaled) back off
206
  upscaled = upscaled[:, :, : h * SCALE, : w * SCALE]
207
+ results = upscaled.clamp(0, 1).permute(0, 2, 3, 1).float().cpu().numpy()
208
+ for result in results:
209
+ out_frames.append(Image.fromarray((result * 255.0).round().astype(np.uint8)))
210
+ if progress_callback is not None:
211
+ progress_callback(len(out_frames), len(frames))
212
  return out_frames