JS6969 commited on
Commit
06fe358
·
verified ·
1 Parent(s): 817be8c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +135 -94
app.py CHANGED
@@ -1,15 +1,16 @@
1
  # app.py
2
- # A Gradio Space that (1) extracts frames with FFmpeg and (2) upscales frames with Real-ESRGAN.
3
- # NEW: You can now also upload existing images directly to the Upscale tab (no video needed).
4
- # - CPU-friendly (GPU optional)
5
- # - Exact ffmpeg command preview
6
- # - ZIP download for results
 
 
7
 
8
  import os
9
- import io
10
  import re
11
- import math
12
  import json
 
13
  import time
14
  import shutil
15
  import zipfile
@@ -23,7 +24,7 @@ import numpy as np
23
  from PIL import Image
24
 
25
  # ─────────────────────────────────────────────────────────────
26
- # FFmpeg detection
27
  # ─────────────────────────────────────────────────────────────
28
 
29
  def _which(name: str) -> Optional[str]:
@@ -35,15 +36,38 @@ FFPROBE = _which("ffprobe")
35
 
36
  if not FFMPEG or not FFPROBE:
37
  MISSING_MSG = (
38
- "⚠️ FFmpeg not found. Add a 'packages.txt' with just: \nffmpeg\nlibsm6\nlibxext6\nThen restart the Space."
 
 
 
 
39
  )
40
  else:
41
  MISSING_MSG = ""
42
 
 
 
 
 
 
 
 
 
43
  # ─────────────────────────────────────────────────────────────
44
- # ffprobe helpers
45
  # ─────────────────────────────────────────────────────────────
46
 
 
 
 
 
 
 
 
 
 
 
 
47
  def ffprobe_json(input_path: str) -> dict:
48
  if not FFPROBE:
49
  return {}
@@ -82,9 +106,6 @@ def parse_video_info(meta: dict) -> dict:
82
  info["height"] = v.get("height")
83
  return info
84
 
85
- # ─────────────────────────────────────────────────────────────
86
- # FFmpeg command builder
87
- # ─────────────────────────────────────────────────────────────
88
 
89
  def build_ffmpeg_command(
90
  input_path: str,
@@ -143,21 +164,10 @@ def build_ffmpeg_command(
143
  cmd += ["-frame_pts", "1", out_pattern]
144
  return cmd
145
 
146
- # ─────────────────────────────────────────────────────────────
147
- # Real-ESRGAN setup (CPU/GPU)
148
- # ─────────────────────────────────────────────────────────────
149
- try:
150
- from realesrgan import RealESRGANer
151
- from basicsr.archs.rrdbnet_arch import RRDBNet
152
- _HAVE_REALESRGAN = True
153
- except Exception:
154
- _HAVE_REALESRGAN = False
155
-
156
 
157
  def get_realesrganer(model_name: str, scale: int, tile: int, half: bool, device: str = "cpu"):
158
  if not _HAVE_REALESRGAN:
159
  raise RuntimeError("realesrgan is not installed. Check requirements.txt")
160
- # Model selection
161
  if model_name in ("x4plus", "x4plus-anime"):
162
  model = RRDBNet(num_in_ch=3, num_out_ch=3, num_feat=64, num_block=23, num_grow_ch=32, scale=4)
163
  model_scale = 4
@@ -172,7 +182,7 @@ def get_realesrganer(model_name: str, scale: int, tile: int, half: bool, device:
172
 
173
  upsampler = RealESRGANer(
174
  scale=model_scale,
175
- model_path=None, # let library fetch weights
176
  model=model,
177
  tile=tile or 0,
178
  tile_pad=10,
@@ -180,13 +190,15 @@ def get_realesrganer(model_name: str, scale: int, tile: int, half: bool, device:
180
  half=half,
181
  device=device,
182
  )
183
- return upsampler, model_scale
184
 
185
 
186
  def upscale_images(img_paths: List[Path], out_dir: Path, model_name: str, scale: int, tile: int, precision: str, progress=gr.Progress(track_tqdm=True)) -> List[str]:
 
 
187
  device = "cuda" if os.environ.get("CUDA_VISIBLE_DEVICES") else "cpu"
188
  half = (precision == "half") and (device == "cuda")
189
- upsampler, model_scale = get_realesrganer(model_name, scale, tile, half, device=device)
190
 
191
  out_paths: List[str] = []
192
  for i, p in enumerate(img_paths, 1):
@@ -194,10 +206,7 @@ def upscale_images(img_paths: List[Path], out_dir: Path, model_name: str, scale:
194
  img = Image.open(p).convert("RGB")
195
  output, _ = upsampler.enhance(np.array(img), outscale=scale)
196
  out_img = Image.fromarray(output)
197
- out_file = out_dir / p.name
198
- # Save as JPG by extension; keep name but force .jpg to be consistent
199
- if out_file.suffix.lower() not in [".jpg", ".jpeg"]:
200
- out_file = out_file.with_suffix(".jpg")
201
  out_img.save(out_file, quality=95)
202
  out_paths.append(str(out_file))
203
  except Exception as e:
@@ -209,7 +218,7 @@ def upscale_images(img_paths: List[Path], out_dir: Path, model_name: str, scale:
209
  # Pipelines
210
  # ─────────────────────────────────────────────────────────────
211
 
212
- def run_video_pipeline(
213
  video: gr.File | None,
214
  mode: str,
215
  every_seconds: float,
@@ -224,25 +233,16 @@ def run_video_pipeline(
224
  scene_detect: bool,
225
  scene_thresh: float,
226
  prefix: str,
227
- do_upscale: bool,
228
- model_name: str,
229
- scale: int,
230
- tile: int,
231
- precision: str,
232
  ):
233
  if not video or not video.name:
234
- return None, None, None, "Upload a video.", ""
235
  if not FFMPEG or not FFPROBE:
236
- return None, None, None, "FFmpeg missing. See note below.", MISSING_MSG
237
- if do_upscale and not _HAVE_REALESRGAN:
238
- return None, None, None, "realesrgan is not installed (see requirements.txt)", ""
239
 
240
  # Work dirs
241
- work = Path(tempfile.mkdtemp(prefix="vid2up_"))
242
  raw_dir = work / "frames_raw"
243
- up_dir = work / "frames_upscaled"
244
  raw_dir.mkdir(parents=True, exist_ok=True)
245
- up_dir.mkdir(parents=True, exist_ok=True)
246
 
247
  # Default prefix from input filename if blank
248
  if not prefix or not prefix.strip():
@@ -266,35 +266,62 @@ def run_video_pipeline(
266
  scene_thresh=scene_thresh,
267
  out_pattern=pattern,
268
  )
269
-
270
  cmd_preview = " ".join([s if " " not in s else f'"{s}"' for s in cmd])
271
 
272
  proc = subprocess.run(cmd, capture_output=True, text=True)
273
  if proc.returncode != 0:
274
- return None, None, None, f"FFmpeg error:\n{proc.stderr}", cmd_preview
 
275
 
276
  frames = sorted(raw_dir.glob(f"{prefix}_*.{out_format}"))
277
  if not frames:
278
- return None, None, None, "No frames extracted.", cmd_preview
279
-
280
- # Optionally upscale
281
- if do_upscale:
282
- up_paths = upscale_images(frames, up_dir, model_name, scale, tile, precision)
283
- gallery = up_paths[:60]
284
- final_zip_dir = up_dir
285
- detail = f"Frames extracted: {len(frames)} | Upscaled: {len(up_paths)} | Model: {model_name} | Scale: x{scale} | Tile: {tile} | Precision: {precision}"
286
- else:
287
- gallery = [str(p) for p in frames[:60]]
288
- final_zip_dir = raw_dir
289
- detail = f"Frames extracted: {len(frames)} | (Upscale: off)"
290
 
291
  # Zip
292
- zip_path = work / ("upscaled_frames.zip" if do_upscale else "frames.zip")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
294
- for p in sorted(final_zip_dir.glob("*")):
295
  zf.write(p, p.name)
296
 
297
- return gallery, str(zip_path), detail, "", cmd_preview
 
298
 
299
 
300
  def run_image_upscale_pipeline(
@@ -316,20 +343,18 @@ def run_image_upscale_pipeline(
316
  in_dir.mkdir(parents=True, exist_ok=True)
317
  out_dir.mkdir(parents=True, exist_ok=True)
318
 
319
- # Save uploads into a temp folder with cleaned names
320
  img_paths: List[Path] = []
321
  for f in images:
322
  p = Path(f.name)
323
  name = p.name
324
  if prefix and prefix.strip():
325
- # keep original number if present else add incremental index
326
  name = f"{prefix}_{name}"
327
  dst = in_dir / name
328
  shutil.copy2(p, dst)
329
  img_paths.append(dst)
330
 
331
  up_paths = upscale_images(img_paths, out_dir, model_name, scale, tile, precision)
332
- gallery = up_paths[:60]
333
 
334
  zip_path = work / "upscaled_images.zip"
335
  with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
@@ -349,13 +374,18 @@ def build_ui():
349
  .cmdbox textarea { font-family: ui-monospace, Menlo, monospace; font-size: 12px; }
350
  """) as demo:
351
  gr.Markdown("""
352
- <div class="cf-title">Video → JPG → Upscale (FFmpeg + Real-ESRGAN)</div>
353
- Extract frames from video or upload images directly, then upscale with Real-ESRGAN.
354
  """)
355
 
 
 
 
 
 
356
  with gr.Tabs():
357
- # Tab 1: Video → Frames (Upscale)
358
- with gr.Tab("Video pipeline"):
359
  with gr.Row():
360
  video = gr.File(label="Upload video", file_types=[".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v"], type="filepath")
361
  with gr.Accordion("Extraction", open=True):
@@ -376,18 +406,10 @@ def build_ui():
376
  scene_detect = gr.Checkbox(False, label="Scene-change detect")
377
  scene_thresh = gr.Slider(0.0, 1.0, value=0.3, step=0.01, label="Scene threshold")
378
  prefix_vid = gr.Textbox(value="", label="Filename prefix (defaults to input file name)")
379
- with gr.Accordion("Upscaling", open=True):
380
- with gr.Row():
381
- do_upscale = gr.Checkbox(True, label="Upscale frames with Real-ESRGAN")
382
- model_name = gr.Dropdown(["x4plus", "x4plus-anime", "x2plus"], value="x4plus", label="Model")
383
- scale = gr.Dropdown([2, 4], value=4, label="Output scale")
384
- with gr.Row():
385
- tile = gr.Number(value=0, label="Tile size (0 = auto)") # tiling reduces RAM; try 256/512 on GPU, 0 for auto
386
- precision = gr.Dropdown(["auto", "half", "full"], value="auto", label="Precision (GPU=half, CPU=full)")
387
 
388
- run_btn = gr.Button("Run: Extract → (Upscale) → ZIP", variant="primary")
389
- gallery = gr.Gallery(label="Preview (first 60)", columns=6, height=300)
390
- zip_out = gr.File(label="Download ZIP")
391
  details = gr.Markdown("Ready.")
392
  with gr.Accordion("Show FFmpeg command", open=False):
393
  cmd_preview = gr.Textbox(label="ffmpeg command", lines=4, elem_classes=["cmdbox"])
@@ -407,39 +429,58 @@ def build_ui():
407
  out_format.change(_toggle, [mode, out_format], [every_seconds, nth_frame, exact_fps, jpg_quality, png_level])
408
  demo.load(_toggle, [mode, out_format], [every_seconds, nth_frame, exact_fps, jpg_quality, png_level])
409
 
410
- run_btn.click(
411
- run_video_pipeline,
 
 
 
 
412
  inputs=[
413
  video, mode, every_seconds, nth_frame, exact_fps,
414
  start_time, end_time, long_side, out_format, jpg_quality, png_level,
415
  scene_detect, scene_thresh, prefix_vid,
416
- do_upscale, model_name, scale, tile, precision,
417
  ],
418
- outputs=[gallery, zip_out, details, gr.Textbox(), cmd_preview],
419
- api_name="extract_and_upscale",
420
  )
421
 
422
- # Tab 2: Upscale images directly
423
- with gr.Tab("Upscale images"):
424
- with gr.Row():
425
- imgs = gr.Files(label="Upload images (JPG/PNG)", file_types=[".jpg", ".jpeg", ".png"], type="filepath")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
  with gr.Row():
427
  model_name_i = gr.Dropdown(["x4plus", "x4plus-anime", "x2plus"], value="x4plus", label="Model")
428
  scale_i = gr.Dropdown([2, 4], value=4, label="Output scale")
429
  tile_i = gr.Number(value=0, label="Tile size (0 = auto)")
430
  precision_i = gr.Dropdown(["auto", "half", "full"], value="auto", label="Precision (GPU=half, CPU=full)")
431
- with gr.Row():
432
- prefix_img = gr.Textbox(value="", label="Optional filename prefix (adds prefix_ to each output)")
433
- run_btn_i = gr.Button("Run: Upscale → ZIP", variant="primary")
434
- gallery_i = gr.Gallery(label="Preview (first 60)", columns=6, height=300)
435
  zip_out_i = gr.File(label="Download ZIP")
436
- details_i = gr.Markdown("Ready.")
437
 
438
  run_btn_i.click(
439
  run_image_upscale_pipeline,
440
  inputs=[imgs, model_name_i, scale_i, tile_i, precision_i, prefix_img],
441
  outputs=[gallery_i, zip_out_i, details_i, gr.Textbox()],
442
- api_name="upscale_images_only",
443
  )
444
 
445
  return demo
 
1
  # app.py
2
+ # Two-step UI:
3
+ # 1) Extract frames (Video JPG/PNG)
4
+ # 2) After extraction completes, the Upscale section becomes visible and can be run separately
5
+ # Extras:
6
+ # - Preview shows 30 frames sampled evenly across the whole video (scrollable gallery)
7
+ # - Prefix defaults to the input video filename if left blank
8
+ # - Separate tab for directly upscaling uploaded images (optional)
9
 
10
  import os
 
11
  import re
 
12
  import json
13
+ import math
14
  import time
15
  import shutil
16
  import zipfile
 
24
  from PIL import Image
25
 
26
  # ─────────────────────────────────────────────────────────────
27
+ # System checks
28
  # ─────────────────────────────────────────────────────────────
29
 
30
  def _which(name: str) -> Optional[str]:
 
36
 
37
  if not FFMPEG or not FFPROBE:
38
  MISSING_MSG = (
39
+ "⚠️ FFmpeg not found. Add a 'packages.txt' with exactly:
40
+ ffmpeg
41
+ libsm6
42
+ libxext6
43
+ Then restart the Space."
44
  )
45
  else:
46
  MISSING_MSG = ""
47
 
48
+ # Real-ESRGAN (optional but needed for step 2)
49
+ try:
50
+ from realesrgan import RealESRGANer
51
+ from basicsr.archs.rrdbnet_arch import RRDBNet
52
+ _HAVE_REALESRGAN = True
53
+ except Exception:
54
+ _HAVE_REALESRGAN = False
55
+
56
  # ─────────────────────────────────────────────────────────────
57
+ # Helpers
58
  # ─────────────────────────────────────────────────────────────
59
 
60
+ def sample_paths(paths, n=30):
61
+ """Return up to n items sampled evenly across the list, preserving order."""
62
+ if not paths:
63
+ return []
64
+ n = max(1, min(n, len(paths)))
65
+ idxs = np.linspace(0, len(paths) - 1, num=n, dtype=int).tolist()
66
+ # Ensure uniq & sorted
67
+ idxs = sorted(dict.fromkeys(idxs))
68
+ return [paths[i] for i in idxs]
69
+
70
+
71
  def ffprobe_json(input_path: str) -> dict:
72
  if not FFPROBE:
73
  return {}
 
106
  info["height"] = v.get("height")
107
  return info
108
 
 
 
 
109
 
110
  def build_ffmpeg_command(
111
  input_path: str,
 
164
  cmd += ["-frame_pts", "1", out_pattern]
165
  return cmd
166
 
 
 
 
 
 
 
 
 
 
 
167
 
168
  def get_realesrganer(model_name: str, scale: int, tile: int, half: bool, device: str = "cpu"):
169
  if not _HAVE_REALESRGAN:
170
  raise RuntimeError("realesrgan is not installed. Check requirements.txt")
 
171
  if model_name in ("x4plus", "x4plus-anime"):
172
  model = RRDBNet(num_in_ch=3, num_out_ch=3, num_feat=64, num_block=23, num_grow_ch=32, scale=4)
173
  model_scale = 4
 
182
 
183
  upsampler = RealESRGANer(
184
  scale=model_scale,
185
+ model_path=None,
186
  model=model,
187
  tile=tile or 0,
188
  tile_pad=10,
 
190
  half=half,
191
  device=device,
192
  )
193
+ return upsampler
194
 
195
 
196
  def upscale_images(img_paths: List[Path], out_dir: Path, model_name: str, scale: int, tile: int, precision: str, progress=gr.Progress(track_tqdm=True)) -> List[str]:
197
+ if not _HAVE_REALESRGAN:
198
+ raise RuntimeError("realesrgan not available")
199
  device = "cuda" if os.environ.get("CUDA_VISIBLE_DEVICES") else "cpu"
200
  half = (precision == "half") and (device == "cuda")
201
+ upsampler = get_realesrganer(model_name, scale, tile, half, device=device)
202
 
203
  out_paths: List[str] = []
204
  for i, p in enumerate(img_paths, 1):
 
206
  img = Image.open(p).convert("RGB")
207
  output, _ = upsampler.enhance(np.array(img), outscale=scale)
208
  out_img = Image.fromarray(output)
209
+ out_file = out_dir / (p.stem + ".jpg")
 
 
 
210
  out_img.save(out_file, quality=95)
211
  out_paths.append(str(out_file))
212
  except Exception as e:
 
218
  # Pipelines
219
  # ─────────────────────────────────────────────────────────────
220
 
221
+ def run_video_extract(
222
  video: gr.File | None,
223
  mode: str,
224
  every_seconds: float,
 
233
  scene_detect: bool,
234
  scene_thresh: float,
235
  prefix: str,
 
 
 
 
 
236
  ):
237
  if not video or not video.name:
238
+ return None, None, "Upload a video.", "", gr.update(visible=False), [], "", ""
239
  if not FFMPEG or not FFPROBE:
240
+ return None, None, "FFmpeg missing. See note below.", MISSING_MSG, gr.update(visible=False), [], "", ""
 
 
241
 
242
  # Work dirs
243
+ work = Path(tempfile.mkdtemp(prefix="vid2img_"))
244
  raw_dir = work / "frames_raw"
 
245
  raw_dir.mkdir(parents=True, exist_ok=True)
 
246
 
247
  # Default prefix from input filename if blank
248
  if not prefix or not prefix.strip():
 
266
  scene_thresh=scene_thresh,
267
  out_pattern=pattern,
268
  )
 
269
  cmd_preview = " ".join([s if " " not in s else f'"{s}"' for s in cmd])
270
 
271
  proc = subprocess.run(cmd, capture_output=True, text=True)
272
  if proc.returncode != 0:
273
+ return None, None, f"FFmpeg error:
274
+ {proc.stderr}", cmd_preview, gr.update(visible=False), [], "", ""
275
 
276
  frames = sorted(raw_dir.glob(f"{prefix}_*.{out_format}"))
277
  if not frames:
278
+ return None, None, "No frames extracted.", cmd_preview, gr.update(visible=False), [], "", ""
279
+
280
+ # Preview (30 sampled)
281
+ gallery = [str(p) for p in sample_paths(frames, n=30)]
 
 
 
 
 
 
 
 
282
 
283
  # Zip
284
+ zip_path = work / "frames.zip"
285
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
286
+ for p in frames:
287
+ zf.write(p, p.name)
288
+
289
+ details = f"Frames extracted: {len(frames)} | Saved to: {raw_dir}"
290
+
291
+ # Make upscale section visible and return state for next step
292
+ return gallery, str(zip_path), details, cmd_preview, gr.update(visible=True), [str(p) for p in frames], str(raw_dir), prefix
293
+
294
+
295
+ def run_upscale_from_extracted(
296
+ frames_list: List[str] | None,
297
+ frames_dir: str,
298
+ prefix: str,
299
+ model_name: str,
300
+ scale: int,
301
+ tile: int,
302
+ precision: str,
303
+ ):
304
+ if not _HAVE_REALESRGAN:
305
+ return None, None, "realesrgan is not installed (see requirements.txt)", ""
306
+ if not frames_list:
307
+ return None, None, "No extracted frames state found. Please run extraction first.", ""
308
+
309
+ work = Path(tempfile.mkdtemp(prefix="up_"))
310
+ out_dir = work / "upscaled"
311
+ out_dir.mkdir(parents=True, exist_ok=True)
312
+
313
+ img_paths = [Path(p) for p in frames_list]
314
+ up_paths = upscale_images(img_paths, out_dir, model_name, scale, tile, precision)
315
+
316
+ gallery = sample_paths([Path(p) for p in up_paths], n=30)
317
+
318
+ zip_path = work / "upscaled_frames.zip"
319
  with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
320
+ for p in sorted(out_dir.glob("*")):
321
  zf.write(p, p.name)
322
 
323
+ detail = f"Upscaled: {len(up_paths)} | Model: {model_name} | Scale: x{scale} | Tile: {tile} | Precision: {precision}"
324
+ return gallery, str(zip_path), detail, ""
325
 
326
 
327
  def run_image_upscale_pipeline(
 
343
  in_dir.mkdir(parents=True, exist_ok=True)
344
  out_dir.mkdir(parents=True, exist_ok=True)
345
 
 
346
  img_paths: List[Path] = []
347
  for f in images:
348
  p = Path(f.name)
349
  name = p.name
350
  if prefix and prefix.strip():
 
351
  name = f"{prefix}_{name}"
352
  dst = in_dir / name
353
  shutil.copy2(p, dst)
354
  img_paths.append(dst)
355
 
356
  up_paths = upscale_images(img_paths, out_dir, model_name, scale, tile, precision)
357
+ gallery = [str(p) for p in sample_paths([Path(p) for p in up_paths], n=30)]
358
 
359
  zip_path = work / "upscaled_images.zip"
360
  with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
 
374
  .cmdbox textarea { font-family: ui-monospace, Menlo, monospace; font-size: 12px; }
375
  """) as demo:
376
  gr.Markdown("""
377
+ <div class=\"cf-title\">Video → JPG → Upscale (Two-step)</div>
378
+ Step 1 extracts frames. When it finishes, Step 2 (Upscale) appears.
379
  """)
380
 
381
+ # Shared state between steps
382
+ frames_state = gr.State([]) # list[str] of extracted frame paths
383
+ frames_dir_state = gr.State("")
384
+ prefix_state = gr.State("")
385
+
386
  with gr.Tabs():
387
+ # ── Tab 1: Video → Frames (Step 1)
388
+ with gr.Tab("Video → Frames"):
389
  with gr.Row():
390
  video = gr.File(label="Upload video", file_types=[".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v"], type="filepath")
391
  with gr.Accordion("Extraction", open=True):
 
406
  scene_detect = gr.Checkbox(False, label="Scene-change detect")
407
  scene_thresh = gr.Slider(0.0, 1.0, value=0.3, step=0.01, label="Scene threshold")
408
  prefix_vid = gr.Textbox(value="", label="Filename prefix (defaults to input file name)")
 
 
 
 
 
 
 
 
409
 
410
+ run_extract_btn = gr.Button("Step 1: Extract frames", variant="primary")
411
+ gallery = gr.Gallery(label="Preview (30 sampled)", columns=6, height=480)
412
+ zip_out = gr.File(label="Download frames ZIP")
413
  details = gr.Markdown("Ready.")
414
  with gr.Accordion("Show FFmpeg command", open=False):
415
  cmd_preview = gr.Textbox(label="ffmpeg command", lines=4, elem_classes=["cmdbox"])
 
429
  out_format.change(_toggle, [mode, out_format], [every_seconds, nth_frame, exact_fps, jpg_quality, png_level])
430
  demo.load(_toggle, [mode, out_format], [every_seconds, nth_frame, exact_fps, jpg_quality, png_level])
431
 
432
+ # Run extraction → also reveal upscale group and set states
433
+ show_upscale_group = gr.State(False)
434
+ upscale_group = gr.Group(visible=False)
435
+
436
+ run_extract_btn.click(
437
+ run_video_extract,
438
  inputs=[
439
  video, mode, every_seconds, nth_frame, exact_fps,
440
  start_time, end_time, long_side, out_format, jpg_quality, png_level,
441
  scene_detect, scene_thresh, prefix_vid,
 
442
  ],
443
+ outputs=[gallery, zip_out, details, cmd_preview, upscale_group, frames_state, frames_dir_state, prefix_state],
 
444
  )
445
 
446
+ # ── Tab 2: Upscale (Step 2 — appears after extraction)
447
+ with gr.Tab("Upscale extracted", visible=True):
448
+ with gr.Group() as upscale_group: # rebind same name for clarity
449
+ gr.Markdown("**Step 2:** Upscale the frames you just extracted.")
450
+ with gr.Row():
451
+ model_name = gr.Dropdown(["x4plus", "x4plus-anime", "x2plus"], value="x4plus", label="Model")
452
+ scale = gr.Dropdown([2, 4], value=4, label="Output scale")
453
+ tile = gr.Number(value=0, label="Tile size (0 = auto)")
454
+ precision = gr.Dropdown(["auto", "half", "full"], value="auto", label="Precision (GPU=half, CPU=full)")
455
+ run_upscale_btn = gr.Button("Step 2: Upscale extracted frames", variant="primary")
456
+ gallery_up = gr.Gallery(label="Upscaled preview (30 sampled)", columns=6, height=480)
457
+ zip_up = gr.File(label="Download upscaled ZIP")
458
+ details_up = gr.Markdown("")
459
+
460
+ run_upscale_btn.click(
461
+ run_upscale_from_extracted,
462
+ inputs=[frames_state, frames_dir_state, prefix_state, model_name, scale, tile, precision],
463
+ outputs=[gallery_up, zip_up, details_up, gr.Textbox()],
464
+ )
465
+
466
+ # ── Optional: direct image upscale
467
+ with gr.Tab("Upscale images (upload)"):
468
+ imgs = gr.Files(label="Upload images (JPG/PNG)", file_types=[".jpg", ".jpeg", ".png"], type="filepath")
469
  with gr.Row():
470
  model_name_i = gr.Dropdown(["x4plus", "x4plus-anime", "x2plus"], value="x4plus", label="Model")
471
  scale_i = gr.Dropdown([2, 4], value=4, label="Output scale")
472
  tile_i = gr.Number(value=0, label="Tile size (0 = auto)")
473
  precision_i = gr.Dropdown(["auto", "half", "full"], value="auto", label="Precision (GPU=half, CPU=full)")
474
+ prefix_img = gr.Textbox(value="", label="Optional filename prefix (adds prefix_ to each output)")
475
+ run_btn_i = gr.Button("Upscale uploaded images", variant="secondary")
476
+ gallery_i = gr.Gallery(label="Preview (30 sampled)", columns=6, height=480)
 
477
  zip_out_i = gr.File(label="Download ZIP")
478
+ details_i = gr.Markdown("")
479
 
480
  run_btn_i.click(
481
  run_image_upscale_pipeline,
482
  inputs=[imgs, model_name_i, scale_i, tile_i, precision_i, prefix_img],
483
  outputs=[gallery_i, zip_out_i, details_i, gr.Textbox()],
 
484
  )
485
 
486
  return demo