Abdo96 commited on
Commit
fd7fa49
Β·
verified Β·
1 Parent(s): 327bd26

Upload 12 files

Browse files
Files changed (4) hide show
  1. DEPLOY_HF.md +9 -8
  2. app.py +56 -12
  3. config.py +7 -4
  4. pipeline.py +79 -68
DEPLOY_HF.md CHANGED
@@ -44,14 +44,15 @@ Do **not** upload `venv/`, `__pycache__/`, or model weights (downloaded at runti
44
 
45
  ## Troubleshooting
46
  - **Runtime `gradio.exceptions.Error: 'GPU task aborted'` / "ZeroGPU worker error"** β€”
47
- the GPU worker was killed, usually because a **video** run was too big for one
48
- 120s GPU turn (e.g. many frames Γ— 8K Γ— per-frame **face-enhance**). The app now:
49
- (a) **prefetches** imports+weights at startup; (b) caps **video** to ~4K output
50
- with a **frame budget** (β‰ˆ120 plain, 40 with face-enhance); and (c) has a
51
- **time safety net** that stops before the limit and assembles the frames done
52
- so far β€” so you get a (possibly shorter) video instead of an abort. For true 8K
53
- video use a dedicated GPU; 8K is meant for the **Image** tab. Any remaining error
54
- is printed to the logs via `traceback`.
 
55
  - **Build fails building `basicsr` with `KeyError: '__version__'` / `get_version`** β€”
56
  the Space built on **Python 3.13**, whose PEP 667 broke BasicSR's `setup.py`
57
  (`exec()` + `locals()`). Fixed by pinning **`python_version: "3.10"`** in the
 
44
 
45
  ## Troubleshooting
46
  - **Runtime `gradio.exceptions.Error: 'GPU task aborted'` / "ZeroGPU worker error"** β€”
47
+ the GPU worker was killed because a single call ran too long. **Video is now
48
+ processed in chunks**: the whole clip is split into batches
49
+ (β‰ˆ{`chunk_frames_plain`} frames, {`chunk_frames_face`} with face-enhance), each
50
+ upscaled in its **own GPU call**, then joined with audio β€” so the full clip gets
51
+ done across multiple GPU turns instead of one short window. Output is capped to
52
+ ~4K; total length up to `max_total_video_seconds` (less with face-enhance). More
53
+ chunks use more of your ZeroGPU quota. For true 8K video use a dedicated GPU;
54
+ 8K is meant for the **Image** tab. Startup **prefetches** imports+weights, and
55
+ any error is printed to the logs via `traceback`.
56
  - **Build fails building `basicsr` with `KeyError: '__version__'` / `get_version`** β€”
57
  the Space built on **Python 3.13**, whose PEP 667 broke BasicSR's `setup.py`
58
  (`exec()` + `locals()`). Fixed by pinning **`python_version: "3.10"`** in the
app.py CHANGED
@@ -175,8 +175,21 @@ def enhance_image_fn(image, scale_mode, model_name, denoise, face_enhance,
175
 
176
 
177
  @spaces.GPU(duration=PROCESS_DURATION)
 
 
 
 
 
 
 
 
 
 
178
  def enhance_video_fn(video_file, scale_mode, model_name, denoise, face_enhance,
179
  progress=gr.Progress()):
 
 
 
180
  if video_file is None:
181
  gr.Warning("⚠️ Please upload a video first!")
182
  return None, "❌ No video uploaded"
@@ -184,20 +197,49 @@ def enhance_video_fn(video_file, scale_mode, model_name, denoise, face_enhance,
184
  def gp(v, t):
185
  progress(v, desc=t)
186
 
 
187
  try:
188
  video_path = video_file if isinstance(video_file, str) else video_file.name
189
- out_dir = tempfile.mkdtemp(prefix="enh_vid_")
190
- out_path = os.path.join(out_dir, f"enhanced_{Path(video_path).stem}.mp4")
191
- result, info = pipeline.enhance_video(
192
- video_path, out_path, scale_mode=scale_mode, model_name=model_name,
193
- denoise_strength=float(denoise), face_enhance=bool(face_enhance), progress=gp,
194
- )
195
- return result, info
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  except Exception as e:
197
  import traceback
198
  traceback.print_exc() # surface the real error in the Space logs
199
  gr.Warning(f"Error: {e}")
200
  return None, f"❌ {e}"
 
 
 
 
 
 
201
 
202
 
203
  # ─── UI ────────────────────────────────────────────────────────
@@ -275,11 +317,13 @@ def create_app():
275
  ### Reaching 8K
276
  - **Images** upscale to 8K easily. Pick **Max β†’ up to 8K** and the output is
277
  scaled to fill the 8K box (never larger). From ~1080p that's a 4Γ— pass.
278
- - **Video** is capped to **~4K output** and a **short frame budget** so a run
279
- finishes inside one GPU turn (about {config.max_video_frames} frames plain,
280
- **{config.max_video_frames_face} with face-enhance** since it's ~10Γ— slower).
281
- Longer input is auto-trimmed; if it still runs long it stops safely and
282
- returns the frames done so far. **For true 8K video, use a dedicated GPU.**
 
 
283
 
284
  ### Models
285
  - **General** β€” best for real-world photos/video; the **Denoise** dial trades grain vs. smoothness.
 
175
 
176
 
177
  @spaces.GPU(duration=PROCESS_DURATION)
178
+ def _gpu_upscale_batch(frame_paths, out_dir, out_start_index, outscale,
179
+ model_name, denoise, face_enhance):
180
+ """One GPU call: upscale a batch of frames. Kept small enough to fit the
181
+ ZeroGPU time budget; the orchestrator calls this once per chunk."""
182
+ return pipeline.upscale_frames(
183
+ frame_paths, out_dir, int(out_start_index), float(outscale),
184
+ model_name, float(denoise), bool(face_enhance),
185
+ )
186
+
187
+
188
  def enhance_video_fn(video_file, scale_mode, model_name, denoise, face_enhance,
189
  progress=gr.Progress()):
190
+ """Orchestrator (CPU): extract frames once, upscale them in batches β€” each
191
+ batch is a separate GPU call β€” then assemble. This processes the WHOLE clip
192
+ across multiple GPU turns instead of just one short window."""
193
  if video_file is None:
194
  gr.Warning("⚠️ Please upload a video first!")
195
  return None, "❌ No video uploaded"
 
197
  def gp(v, t):
198
  progress(v, desc=t)
199
 
200
+ prep = None
201
  try:
202
  video_path = video_file if isinstance(video_file, str) else video_file.name
203
+
204
+ # CPU: trim to overall cap + extract all frames.
205
+ prep = pipeline.prepare_video(video_path, scale_mode=scale_mode,
206
+ face_enhance=bool(face_enhance), progress=gp)
207
+ total = len(prep.files)
208
+ if total == 0:
209
+ return None, "❌ No frames found in the video"
210
+
211
+ batch = pipeline.batch_size(bool(face_enhance))
212
+ n_batches = (total + batch - 1) // batch
213
+
214
+ # GPU: process chunk by chunk (each a separate @spaces.GPU call).
215
+ out_idx = 0
216
+ for b, start in enumerate(range(0, total, batch)):
217
+ chunk = prep.files[start:start + batch]
218
+ end = min(start + batch, total)
219
+ gp(0.10 + 0.82 * (start / total),
220
+ f"πŸš€ Upscaling chunk {b + 1}/{n_batches} "
221
+ f"(frames {start + 1}–{end} of {total}) β†’ {prep.ow}Γ—{prep.oh}...")
222
+ out_idx = _gpu_upscale_batch(
223
+ chunk, prep.out_dir, out_idx, prep.outscale,
224
+ model_name, float(denoise), bool(face_enhance),
225
+ )
226
+
227
+ # CPU: join all upscaled frames + audio.
228
+ out_final = tempfile.mkdtemp(prefix="enh_vid_")
229
+ out_path = os.path.join(out_final, f"enhanced_{Path(video_path).stem}.mp4")
230
+ result, info = pipeline.finalize_video(prep, out_path, out_idx, progress=gp)
231
+ return result, info + f" Β· {n_batches} chunk(s)"
232
  except Exception as e:
233
  import traceback
234
  traceback.print_exc() # surface the real error in the Space logs
235
  gr.Warning(f"Error: {e}")
236
  return None, f"❌ {e}"
237
+ finally:
238
+ if prep is not None:
239
+ try:
240
+ pipeline.cleanup(prep)
241
+ except Exception:
242
+ pass
243
 
244
 
245
  # ─── UI ────────────────────────────────────────────────────────
 
317
  ### Reaching 8K
318
  - **Images** upscale to 8K easily. Pick **Max β†’ up to 8K** and the output is
319
  scaled to fill the 8K box (never larger). From ~1080p that's a 4Γ— pass.
320
+ - **Video** is processed **in chunks** β€” the whole clip is split into batches,
321
+ each upscaled in its own GPU turn, then joined back with audio. Output is
322
+ capped to **~4K**; total length up to **{config.max_total_video_seconds}s**
323
+ (**{config.max_total_video_seconds_face}s** with face-enhance, which is
324
+ ~10Γ— slower). Longer input is trimmed. **For true 8K video, use a dedicated GPU.**
325
+ - More chunks = more GPU turns (and more of your ZeroGPU quota), but the whole
326
+ clip gets done instead of just the first second.
327
 
328
  ### Models
329
  - **General** β€” best for real-world photos/video; the **Denoise** dial trades grain vs. smoothness.
config.py CHANGED
@@ -65,11 +65,14 @@ class AppConfig:
65
  max_video_duration: int = 8 # seconds (input; longer is auto-trimmed)
66
  max_output_long_edge: int = EIGHT_K_W # cap output so it never exceeds 8K
67
 
68
- # Video-specific caps so a run fits inside ONE ZeroGPU call:
 
 
69
  max_video_long_edge: int = 3840 # video output capped to ~4K (8K stays for images)
70
- max_video_frames: int = 120 # plain upscale frame budget
71
- max_video_frames_face: int = 40 # face-enhance is ~10Γ— slower per frame
72
- safe_gpu_seconds: int = 90 # stop before ZeroGPU's ~120s limit & save partial
 
73
 
74
  gpu_duration: int = 120 # seconds requested per @spaces.GPU call
75
  server_port: int = 7860
 
65
  max_video_duration: int = 8 # seconds (input; longer is auto-trimmed)
66
  max_output_long_edge: int = EIGHT_K_W # cap output so it never exceeds 8K
67
 
68
+ # Video is processed in CHUNKS β€” each chunk is one @spaces.GPU call that
69
+ # fits the time budget; chunks are then joined. This lets the WHOLE clip be
70
+ # processed across multiple GPU calls instead of just one short window.
71
  max_video_long_edge: int = 3840 # video output capped to ~4K (8K stays for images)
72
+ chunk_frames_plain: int = 120 # frames per GPU call (plain upscale)
73
+ chunk_frames_face: int = 30 # frames per GPU call (face-enhance is ~10Γ— slower)
74
+ max_total_video_seconds: int = 20 # overall clip cap (plain); longer is trimmed
75
+ max_total_video_seconds_face: int = 10 # overall clip cap when face-enhance is on
76
 
77
  gpu_duration: int = 120 # seconds requested per @spaces.GPU call
78
  server_port: int = 7860
pipeline.py CHANGED
@@ -2,20 +2,39 @@
2
  Enhancement pipeline.
3
 
4
  Image: decode -> Real-ESRGAN upscale -> encode.
5
- Video: (auto-trim if long) -> extract frames -> upscale each -> reassemble +audio.
 
 
 
6
  """
7
  import os
8
  import cv2
9
  import time
10
  import numpy as np
11
  from pathlib import Path
12
- from typing import Optional, Callable, Tuple
 
13
 
14
  from config import AppConfig, EIGHT_K_W, EIGHT_K_H
15
  from enhancer import RealESRGANEnhancer, compute_effective_scale
16
  from utils.video_processor import VideoProcessor
17
 
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  class EnhancePipeline:
20
  def __init__(self, config: AppConfig):
21
  self.config = config
@@ -43,30 +62,29 @@ class EnhancePipeline:
43
  f"{', face-enhanced' if face_enhance else ''})"
44
  return out, info
45
 
46
- # ---------- video ----------
47
- def enhance_video(
48
- self, video_path: str, output_path: str, scale_mode: str = "4x",
49
- model_name: str = "general", denoise_strength: float = 0.5,
50
- face_enhance: bool = False, progress: Optional[Callable] = None,
51
- ) -> Tuple[str, str]:
52
- start_t = time.time()
 
 
53
  info = self.video_processor.get_video_info(video_path)
54
  fps = info.fps or 30.0
55
 
56
- # Frame budget so ONE GPU call finishes in time. Face-enhance is far
57
- # slower per frame, so it gets a much smaller budget.
58
- budget = (getattr(self.config, "max_video_frames_face", 40) if face_enhance
59
- else getattr(self.config, "max_video_frames", 120))
60
- dur_cap = getattr(self.config, "max_video_duration", 8)
61
- target_seconds = min(dur_cap, budget / fps)
62
- if info.duration and info.duration > target_seconds:
63
  self._p(progress, 0.03,
64
- f"βœ‚οΈ Trimming to {target_seconds:.1f}s (~{int(target_seconds*fps)} frames) "
65
- f"to fit the GPU budget{' β€” face-enhance is slow' if face_enhance else ''}...")
66
- video_path = self.video_processor.trim(video_path, 0, target_seconds)
67
  info = self.video_processor.get_video_info(video_path)
68
 
69
- # Effective scale, capped to the 8K box AND to the video resolution ceiling.
70
  outscale = compute_effective_scale(info.width, info.height, scale_mode,
71
  EIGHT_K_W, EIGHT_K_H)
72
  max_le = getattr(self.config, "max_video_long_edge", 3840)
@@ -77,56 +95,49 @@ class EnhancePipeline:
77
  temp_dir = os.path.join(self.config.video.temp_dir, "processing")
78
  frames_dir = os.path.join(temp_dir, "frames")
79
  out_dir = os.path.join(temp_dir, "out")
80
- self.video_processor.cleanup_temp(temp_dir) # clear any stale run
81
  os.makedirs(out_dir, exist_ok=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  ext = self.config.video.frame_format
83
- safe_seconds = getattr(self.config, "safe_gpu_seconds", 90)
84
-
85
- try:
86
- self._p(progress, 0.08, "🎞️ Extracting frames...")
87
- self.video_processor.extract_frames_to_dir(video_path, frames_dir)
88
-
89
- self._p(progress, 0.12, f"Loading model ({model_name})...")
90
- self.enhancer.ensure_loaded(model_name, denoise_strength, progress=progress)
91
-
92
- files = sorted(f for f in os.listdir(frames_dir) if f.endswith(ext))
93
- total = len(files)
94
- self._p(progress, 0.15, f"Upscaling {total} frames Γ—{outscale:.2f} β†’ {ow}Γ—{oh}...")
95
-
96
- processed = 0
97
- stopped_early = False
98
- for i, fname in enumerate(files):
99
- # Safety net: stop before ZeroGPU's hard limit and keep what we have.
100
- if time.time() - start_t > safe_seconds:
101
- stopped_early = True
102
- print(f"⏱️ GPU time budget ({safe_seconds}s) reached at frame {i}/{total}; "
103
- f"assembling the {processed} frames done so far.")
104
- break
105
- frame = cv2.imread(os.path.join(frames_dir, fname), cv2.IMREAD_COLOR)
106
- if frame is None:
107
- continue
108
- up = self.enhancer.enhance(frame, outscale, face_enhance=face_enhance)
109
- processed += 1
110
- # Renumber sequentially so the output frames are always contiguous.
111
- cv2.imwrite(os.path.join(out_dir, f"{processed:06d}.{ext}"), up)
112
- if i % 2 == 0 or i == total - 1:
113
- p = 0.15 + (i / max(total, 1)) * 0.75
114
- self._p(progress, p, f"Upscaling frame {i+1}/{total}")
115
-
116
- if processed == 0:
117
- raise RuntimeError("No frames were processed.")
118
-
119
- self._p(progress, 0.92, "🎬 Assembling video (+audio)...")
120
- self.video_processor.assemble_video(out_dir, output_path, info.fps,
121
- original_video=video_path)
122
- elapsed = time.time() - start_t
123
- note = " ⚠️ partial (hit the GPU time limit)" if stopped_early else ""
124
- msg = (f"βœ… {info.width}Γ—{info.height} β†’ {ow}Γ—{oh} (Γ—{outscale:.2f}) Β· "
125
- f"{processed}/{total} frames Β· {elapsed:.0f}s{note}")
126
- self._p(progress, 1.0, msg)
127
- return output_path, msg
128
- finally:
129
- self.video_processor.cleanup_temp(temp_dir)
130
 
131
  @staticmethod
132
  def _p(cb, v, t):
 
2
  Enhancement pipeline.
3
 
4
  Image: decode -> Real-ESRGAN upscale -> encode.
5
+ Video (CHUNKED so the whole clip is processed across multiple GPU calls):
6
+ prepare_video() – CPU: (trim to total cap) + extract all frames
7
+ upscale_frames() – GPU: upscale ONE batch of frames (called per chunk)
8
+ finalize_video() – CPU: assemble all upscaled frames + original audio
9
  """
10
  import os
11
  import cv2
12
  import time
13
  import numpy as np
14
  from pathlib import Path
15
+ from dataclasses import dataclass
16
+ from typing import Optional, Callable, Tuple, List
17
 
18
  from config import AppConfig, EIGHT_K_W, EIGHT_K_H
19
  from enhancer import RealESRGANEnhancer, compute_effective_scale
20
  from utils.video_processor import VideoProcessor
21
 
22
 
23
+ @dataclass
24
+ class VideoPrep:
25
+ temp_dir: str
26
+ frames_dir: str
27
+ out_dir: str
28
+ files: List[str] # full paths to extracted source frames, in order
29
+ outscale: float
30
+ fps: float
31
+ width: int
32
+ height: int
33
+ ow: int
34
+ oh: int
35
+ trimmed_path: str # (possibly trimmed) source, used for audio
36
+
37
+
38
  class EnhancePipeline:
39
  def __init__(self, config: AppConfig):
40
  self.config = config
 
62
  f"{', face-enhanced' if face_enhance else ''})"
63
  return out, info
64
 
65
+ # ---------- video: chunked ----------
66
+ def batch_size(self, face_enhance: bool) -> int:
67
+ return (getattr(self.config, "chunk_frames_face", 30) if face_enhance
68
+ else getattr(self.config, "chunk_frames_plain", 120))
69
+
70
+ def prepare_video(self, video_path: str, scale_mode: str = "4x",
71
+ face_enhance: bool = False,
72
+ progress: Optional[Callable] = None) -> VideoPrep:
73
+ """CPU: trim to the overall cap and extract all frames."""
74
  info = self.video_processor.get_video_info(video_path)
75
  fps = info.fps or 30.0
76
 
77
+ max_total = getattr(self.config, "max_total_video_seconds", 20)
78
+ if face_enhance:
79
+ max_total = min(max_total, getattr(self.config, "max_total_video_seconds_face", 10))
80
+ if info.duration and info.duration > max_total:
 
 
 
81
  self._p(progress, 0.03,
82
+ f"βœ‚οΈ Trimming to {max_total}s (max total for "
83
+ f"{'face-enhance' if face_enhance else 'this'} mode)...")
84
+ video_path = self.video_processor.trim(video_path, 0, max_total)
85
  info = self.video_processor.get_video_info(video_path)
86
 
87
+ # Effective scale, capped to the 8K box AND the video resolution ceiling.
88
  outscale = compute_effective_scale(info.width, info.height, scale_mode,
89
  EIGHT_K_W, EIGHT_K_H)
90
  max_le = getattr(self.config, "max_video_long_edge", 3840)
 
95
  temp_dir = os.path.join(self.config.video.temp_dir, "processing")
96
  frames_dir = os.path.join(temp_dir, "frames")
97
  out_dir = os.path.join(temp_dir, "out")
98
+ self.video_processor.cleanup_temp(temp_dir)
99
  os.makedirs(out_dir, exist_ok=True)
100
+
101
+ self._p(progress, 0.06, "🎞️ Extracting frames...")
102
+ self.video_processor.extract_frames_to_dir(video_path, frames_dir)
103
+ ext = self.config.video.frame_format
104
+ files = [os.path.join(frames_dir, f)
105
+ for f in sorted(os.listdir(frames_dir)) if f.endswith(ext)]
106
+
107
+ return VideoPrep(temp_dir, frames_dir, out_dir, files, outscale, info.fps,
108
+ info.width, info.height, ow, oh, video_path)
109
+
110
+ def upscale_frames(self, frame_paths: List[str], out_dir: str,
111
+ out_start_index: int, outscale: float, model_name: str,
112
+ denoise_strength: float, face_enhance: bool) -> int:
113
+ """GPU: upscale one batch of frames into out_dir, numbered contiguously
114
+ from out_start_index. Runs the model β€” call inside a GPU context.
115
+ Returns the next output index."""
116
+ self.enhancer.ensure_loaded(model_name, denoise_strength)
117
  ext = self.config.video.frame_format
118
+ idx = out_start_index
119
+ for fp in frame_paths:
120
+ frame = cv2.imread(fp, cv2.IMREAD_COLOR)
121
+ if frame is None:
122
+ continue
123
+ up = self.enhancer.enhance(frame, outscale, face_enhance=face_enhance)
124
+ idx += 1
125
+ cv2.imwrite(os.path.join(out_dir, f"{idx:06d}.{ext}"), up)
126
+ return idx
127
+
128
+ def finalize_video(self, prep: VideoPrep, output_path: str, processed: int,
129
+ progress: Optional[Callable] = None) -> Tuple[str, str]:
130
+ """CPU: assemble all upscaled frames + original audio."""
131
+ self._p(progress, 0.94, "🎬 Assembling video (+audio)...")
132
+ self.video_processor.assemble_video(prep.out_dir, output_path, prep.fps,
133
+ original_video=prep.trimmed_path)
134
+ msg = (f"βœ… {prep.width}Γ—{prep.height} β†’ {prep.ow}Γ—{prep.oh} "
135
+ f"(Γ—{prep.outscale:.2f}) Β· {processed} frames")
136
+ self._p(progress, 1.0, msg)
137
+ return output_path, msg
138
+
139
+ def cleanup(self, prep: VideoPrep):
140
+ self.video_processor.cleanup_temp(prep.temp_dir)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
 
142
  @staticmethod
143
  def _p(cb, v, t):