JS6969 commited on
Commit
9426220
·
verified ·
1 Parent(s): 5154ce4

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +425 -208
app.py CHANGED
@@ -1,11 +1,14 @@
 
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
@@ -17,14 +20,14 @@ import zipfile
17
  import tempfile
18
  import subprocess
19
  from pathlib import Path
20
- from typing import List, Optional
21
 
22
  import gradio as gr
23
  import numpy as np
24
  from PIL import Image
25
 
26
  # ─────────────────────────────────────────────────────────────
27
- # System checks
28
  # ─────────────────────────────────────────────────────────────
29
 
30
  def _which(name: str) -> Optional[str]:
@@ -34,27 +37,39 @@ def _which(name: str) -> Optional[str]:
34
  FFMPEG = _which("ffmpeg")
35
  FFPROBE = _which("ffprobe")
36
 
37
- # Real-ESRGAN (optional but needed for step 2)
 
 
 
 
 
 
38
  try:
39
  from realesrgan import RealESRGANer
40
  from basicsr.archs.rrdbnet_arch import RRDBNet
41
- _HAVE_REALESRGAN = True
42
  except Exception:
43
- _HAVE_REALESRGAN = False
44
 
45
  # ─────────────────────────────────────────────────────────────
46
  # Helpers
47
  # ─────────────────────────────────────────────────────────────
48
 
49
- def sample_paths(paths, n=30):
50
- """Return up to n items sampled evenly across the list, preserving order."""
51
  if not paths:
52
  return []
53
  n = max(1, min(n, len(paths)))
54
  idxs = np.linspace(0, len(paths) - 1, num=n, dtype=int).tolist()
55
- # Ensure uniq & sorted
56
  idxs = sorted(dict.fromkeys(idxs))
57
- return [paths[i] for i in idxs]
 
 
 
 
 
 
 
58
 
59
 
60
  def ffprobe_json(input_path: str) -> dict:
@@ -83,10 +98,9 @@ def parse_video_info(meta: dict) -> dict:
83
  v = vstreams[0]
84
  rfr = v.get("r_frame_rate") or v.get("avg_frame_rate")
85
  if rfr and "/" in rfr:
86
- num, den = rfr.split("/")
87
  try:
88
- num = float(num)
89
- den = float(den)
90
  if den != 0:
91
  info["fps"] = num / den
92
  except Exception:
@@ -96,7 +110,26 @@ def parse_video_info(meta: dict) -> dict:
96
  return info
97
 
98
 
99
- def build_ffmpeg_command(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  input_path: str,
101
  mode: str,
102
  every_seconds: float,
@@ -114,14 +147,12 @@ def build_ffmpeg_command(
114
  ) -> List[str]:
115
  if not FFMPEG:
116
  raise RuntimeError("FFmpeg not available")
117
-
118
  cmd = [FFMPEG, "-y"]
119
  if start_time:
120
  cmd += ["-ss", start_time]
121
  cmd += ["-i", input_path]
122
  if end_time:
123
  cmd += ["-to", end_time]
124
-
125
  vf = []
126
  if mode == "Every N seconds":
127
  vf.append(f"fps={max(1e-6, 1.0/float(every_seconds or 1))}")
@@ -134,29 +165,24 @@ def build_ffmpeg_command(
134
  pass
135
  else:
136
  vf.append("fps=1")
137
-
138
  if scene_detect:
139
  vf.append(f"select='gt(scene,{float(scene_thresh)})',showinfo")
140
  vf.append("setpts=N/FRAME_RATE/TB")
141
-
142
  if long_side and long_side > 0:
143
  vf.append("scale='if(gt(iw,ih),%d,-1)':'if(gt(iw,ih),-1,%d)':force_original_aspect_ratio=decrease" % (long_side, long_side))
144
-
145
  if vf:
146
  cmd += ["-vf", ",".join(vf)]
147
-
148
  if out_format == "jpg":
149
  cmd += ["-q:v", str(jpg_quality)]
150
  elif out_format == "png":
151
  cmd += ["-compression_level", str(png_level)]
152
-
153
  cmd += ["-frame_pts", "1", out_pattern]
154
  return cmd
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
  if model_name in ("x4plus", "x4plus-anime"):
161
  model = RRDBNet(num_in_ch=3, num_out_ch=3, num_feat=64, num_block=23, num_grow_ch=32, scale=4)
162
  model_scale = 4
@@ -165,10 +191,8 @@ def get_realesrganer(model_name: str, scale: int, tile: int, half: bool, device:
165
  model_scale = 2
166
  else:
167
  raise ValueError("Unknown Real-ESRGAN model")
168
-
169
  if scale not in (2, 4):
170
  scale = model_scale
171
-
172
  upsampler = RealESRGANer(
173
  scale=model_scale,
174
  model_path=None,
@@ -181,33 +205,21 @@ def get_realesrganer(model_name: str, scale: int, tile: int, half: bool, device:
181
  )
182
  return upsampler
183
 
 
 
 
184
 
185
- 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]:
186
- if not _HAVE_REALESRGAN:
187
- raise RuntimeError("realesrgan not available")
188
- device = "cuda" if os.environ.get("CUDA_VISIBLE_DEVICES") else "cpu"
189
- half = (precision == "half") and (device == "cuda")
190
- upsampler = get_realesrganer(model_name, scale, tile, half, device=device)
191
-
192
- out_paths: List[str] = []
193
- for i, p in enumerate(img_paths, 1):
194
- try:
195
- img = Image.open(p).convert("RGB")
196
- output, _ = upsampler.enhance(np.array(img), outscale=scale)
197
- out_img = Image.fromarray(output)
198
- out_file = out_dir / (p.stem + ".jpg")
199
- out_img.save(out_file, quality=95)
200
- out_paths.append(str(out_file))
201
- except Exception as e:
202
- print(f"Upscale failed for {p}: {e}")
203
- progress(i / max(1, len(img_paths)))
204
- return out_paths
205
 
206
  # ─────────────────────────────────────────────────────────────
207
- # Pipelines
208
  # ─────────────────────────────────────────────────────────────
209
 
210
- def run_video_extract(
211
  video: gr.File | None,
212
  mode: str,
213
  every_seconds: float,
@@ -221,32 +233,37 @@ def run_video_extract(
221
  png_level: int,
222
  scene_detect: bool,
223
  scene_thresh: float,
224
- prefix: str,
 
 
225
  ):
226
  if not video or not video.name:
227
- return None, None, "Upload a video.", "", gr.update(visible=False), [], "", ""
228
  if not FFMPEG or not FFPROBE:
229
- return None, None, "FFmpeg missing. See note below.", MISSING_MSG, gr.update(visible=False), [], "", ""
230
 
231
- # Work dirs
232
  work = Path(tempfile.mkdtemp(prefix="vid2img_"))
233
  raw_dir = work / "frames_raw"
234
  raw_dir.mkdir(parents=True, exist_ok=True)
235
 
236
- # Default prefix from input filename if blank
237
- if not prefix or not prefix.strip():
238
- prefix = Path(video.name).stem
 
 
 
239
 
240
- # Build and run FFmpeg
241
  pattern = str(raw_dir / f"{prefix}_%05d.{out_format}")
242
- cmd = build_ffmpeg_command(
243
  input_path=video.name,
244
  mode=mode,
245
  every_seconds=every_seconds,
246
  nth_frame=nth_frame,
247
  exact_fps=exact_fps,
248
- start_time=start_time.strip(),
249
- end_time=end_time.strip(),
250
  long_side=long_side,
251
  out_format=out_format,
252
  jpg_quality=jpg_quality,
@@ -257,101 +274,258 @@ def run_video_extract(
257
  )
258
  cmd_preview = " ".join([s if " " not in s else f'"{s}"' for s in cmd])
259
 
260
- proc = subprocess.run(cmd, capture_output=True, text=True)
261
- if proc.returncode != 0:
262
- return None, None, f"FFmpeg error:
263
- {proc.stderr}", cmd_preview, gr.update(visible=False), [], "", ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
 
265
  frames = sorted(raw_dir.glob(f"{prefix}_*.{out_format}"))
266
- if not frames:
267
- return None, None, "No frames extracted.", cmd_preview, gr.update(visible=False), [], "", ""
268
-
269
- # Preview (30 sampled)
270
- gallery = [str(p) for p in sample_paths(frames, n=30)]
 
271
 
272
- # Zip
 
273
  zip_path = work / "frames.zip"
274
  with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
275
  for p in frames:
276
  zf.write(p, p.name)
277
 
278
  details = f"Frames extracted: {len(frames)} | Saved to: {raw_dir}"
 
279
 
280
- # Make upscale section visible and return state for next step
281
- return gallery, str(zip_path), details, cmd_preview, gr.update(visible=True), [str(p) for p in frames], str(raw_dir), prefix
282
 
 
 
 
 
 
 
 
 
 
 
 
 
283
 
284
- def run_upscale_from_extracted(
285
  frames_list: List[str] | None,
286
- frames_dir: str,
287
- prefix: str,
288
  model_name: str,
289
  scale: int,
290
  tile: int,
291
  precision: str,
 
 
292
  ):
293
- if not _HAVE_REALESRGAN:
294
- return None, None, "realesrgan is not installed (see requirements.txt)", ""
295
  if not frames_list:
296
- return None, None, "No extracted frames state found. Please run extraction first.", ""
 
 
 
 
297
 
298
  work = Path(tempfile.mkdtemp(prefix="up_"))
299
  out_dir = work / "upscaled"
300
  out_dir.mkdir(parents=True, exist_ok=True)
301
 
302
- img_paths = [Path(p) for p in frames_list]
303
- up_paths = upscale_images(img_paths, out_dir, model_name, scale, tile, precision)
304
-
305
- gallery = sample_paths([Path(p) for p in up_paths], n=30)
306
-
307
- zip_path = work / "upscaled_frames.zip"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
309
- for p in sorted(out_dir.glob("*")):
310
  zf.write(p, p.name)
311
 
312
- detail = f"Upscaled: {len(up_paths)} | Model: {model_name} | Scale: x{scale} | Tile: {tile} | Precision: {precision}"
313
- return gallery, str(zip_path), detail, ""
314
 
 
 
 
315
 
316
- def run_image_upscale_pipeline(
317
- images: List[gr.File] | None,
318
- model_name: str,
319
- scale: int,
320
- tile: int,
321
- precision: str,
322
- prefix: str,
323
- ):
324
- if not images:
325
- return None, None, "Upload one or more images.", ""
326
- if not _HAVE_REALESRGAN:
327
- return None, None, "realesrgan is not installed (see requirements.txt)", ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
 
329
- work = Path(tempfile.mkdtemp(prefix="imgup_"))
330
- in_dir = work / "input"
331
- out_dir = work / "upscaled"
332
- in_dir.mkdir(parents=True, exist_ok=True)
333
- out_dir.mkdir(parents=True, exist_ok=True)
334
 
335
- img_paths: List[Path] = []
336
- for f in images:
337
- p = Path(f.name)
338
- name = p.name
339
- if prefix and prefix.strip():
340
- name = f"{prefix}_{name}"
341
- dst = in_dir / name
342
- shutil.copy2(p, dst)
343
- img_paths.append(dst)
344
 
345
- up_paths = upscale_images(img_paths, out_dir, model_name, scale, tile, precision)
346
- gallery = [str(p) for p in sample_paths([Path(p) for p in up_paths], n=30)]
 
347
 
348
- zip_path = work / "upscaled_images.zip"
349
- with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
350
- for p in sorted(out_dir.glob("*")):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
  zf.write(p, p.name)
352
 
353
- detail = f"Images upscaled: {len(up_paths)} | Model: {model_name} | Scale: x{scale} | Tile: {tile} | Precision: {precision}"
354
- return gallery, str(zip_path), detail, ""
355
 
356
  # ────────���────────────────────────────────────────────────────
357
  # UI
@@ -363,21 +537,22 @@ def build_ui():
363
  .cmdbox textarea { font-family: ui-monospace, Menlo, monospace; font-size: 12px; }
364
  """) as demo:
365
  gr.Markdown("""
366
- <div class=\"cf-title\">Video → JPG → Upscale (Two-step)</div>
367
- Step 1 extracts frames. When it finishes, Step 2 (Upscale) appears.
368
  """)
369
 
370
- # Shared state between steps
371
- frames_state = gr.State([]) # list[str] of extracted frame paths
372
- frames_dir_state = gr.State("")
373
- prefix_state = gr.State("")
 
374
 
375
  with gr.Tabs():
376
- # ── Tab 1: Video → Frames (Step 1)
377
- with gr.Tab("Video Frames"):
378
  with gr.Row():
379
  video = gr.File(label="Upload video", file_types=[".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v"], type="filepath")
380
- with gr.Accordion("Extraction", open=True):
381
  with gr.Row():
382
  mode = gr.Dropdown(["Every N seconds", "Every Nth frame", "Exact FPS", "All frames"], value="Every N seconds", label="Mode")
383
  every_seconds = gr.Number(value=1.0, label="Every N seconds")
@@ -395,81 +570,139 @@ def build_ui():
395
  scene_detect = gr.Checkbox(False, label="Scene-change detect")
396
  scene_thresh = gr.Slider(0.0, 1.0, value=0.3, step=0.01, label="Scene threshold")
397
  prefix_vid = gr.Textbox(value="", label="Filename prefix (defaults to input file name)")
 
 
398
 
399
- run_extract_btn = gr.Button("Step 1: Extract frames", variant="primary")
 
 
 
 
 
 
400
  gallery = gr.Gallery(label="Preview (30 sampled)", columns=6, height=480)
401
  zip_out = gr.File(label="Download frames ZIP")
402
- details = gr.Markdown("Ready.")
403
  with gr.Accordion("Show FFmpeg command", open=False):
404
  cmd_preview = gr.Textbox(label="ffmpeg command", lines=4, elem_classes=["cmdbox"])
405
  if MISSING_MSG:
406
  gr.Markdown(f"<span style='color:#b45309'>{MISSING_MSG}</span>")
407
 
408
- def _toggle(mode_val, fmt):
409
- return (
410
- gr.update(visible=(mode_val == "Every N seconds")),
411
- gr.update(visible=(mode_val == "Every Nth frame")),
412
- gr.update(visible=(mode_val == "Exact FPS")),
413
- gr.update(visible=(fmt == "jpg")),
414
- gr.update(visible=(fmt == "png")),
415
- )
416
-
417
- mode.change(_toggle, [mode, out_format], [every_seconds, nth_frame, exact_fps, jpg_quality, png_level])
418
- out_format.change(_toggle, [mode, out_format], [every_seconds, nth_frame, exact_fps, jpg_quality, png_level])
419
- demo.load(_toggle, [mode, out_format], [every_seconds, nth_frame, exact_fps, jpg_quality, png_level])
420
-
421
- # Run extraction → also reveal upscale group and set states
422
- show_upscale_group = gr.State(False)
423
- upscale_group = gr.Group(visible=False)
424
-
425
- run_extract_btn.click(
426
- run_video_extract,
 
 
 
 
 
 
 
 
 
 
 
 
427
  inputs=[
428
  video, mode, every_seconds, nth_frame, exact_fps,
429
  start_time, end_time, long_side, out_format, jpg_quality, png_level,
430
  scene_detect, scene_thresh, prefix_vid,
 
431
  ],
432
- outputs=[gallery, zip_out, details, cmd_preview, upscale_group, frames_state, frames_dir_state, prefix_state],
433
  )
434
 
435
- # ── Tab 2: Upscale (Step 2 — appears after extraction)
436
- with gr.Tab("Upscale extracted", visible=True):
437
- with gr.Group() as upscale_group: # rebind same name for clarity
438
- gr.Markdown("**Step 2:** Upscale the frames you just extracted.")
439
- with gr.Row():
440
- model_name = gr.Dropdown(["x4plus", "x4plus-anime", "x2plus"], value="x4plus", label="Model")
441
- scale = gr.Dropdown([2, 4], value=4, label="Output scale")
442
- tile = gr.Number(value=0, label="Tile size (0 = auto)")
443
- precision = gr.Dropdown(["auto", "half", "full"], value="auto", label="Precision (GPU=half, CPU=full)")
444
- run_upscale_btn = gr.Button("Step 2: Upscale extracted frames", variant="primary")
445
- gallery_up = gr.Gallery(label="Upscaled preview (30 sampled)", columns=6, height=480)
446
- zip_up = gr.File(label="Download upscaled ZIP")
447
- details_up = gr.Markdown("")
448
-
449
- run_upscale_btn.click(
450
- run_upscale_from_extracted,
451
- inputs=[frames_state, frames_dir_state, prefix_state, model_name, scale, tile, precision],
452
- outputs=[gallery_up, zip_up, details_up, gr.Textbox()],
453
- )
454
-
455
- # ── Optional: direct image upscale
456
- with gr.Tab("Upscale images (upload)"):
457
- imgs = gr.Files(label="Upload images (JPG/PNG)", file_types=[".jpg", ".jpeg", ".png"], type="filepath")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  with gr.Row():
459
- model_name_i = gr.Dropdown(["x4plus", "x4plus-anime", "x2plus"], value="x4plus", label="Model")
460
- scale_i = gr.Dropdown([2, 4], value=4, label="Output scale")
461
- tile_i = gr.Number(value=0, label="Tile size (0 = auto)")
462
- precision_i = gr.Dropdown(["auto", "half", "full"], value="auto", label="Precision (GPU=half, CPU=full)")
463
- prefix_img = gr.Textbox(value="", label="Optional filename prefix (adds prefix_ to each output)")
464
- run_btn_i = gr.Button("Upscale uploaded images", variant="secondary")
465
- gallery_i = gr.Gallery(label="Preview (30 sampled)", columns=6, height=480)
466
- zip_out_i = gr.File(label="Download ZIP")
467
- details_i = gr.Markdown("")
468
-
469
- run_btn_i.click(
470
- run_image_upscale_pipeline,
471
- inputs=[imgs, model_name_i, scale_i, tile_i, precision_i, prefix_img],
472
- outputs=[gallery_i, zip_out_i, details_i, gr.Textbox()],
 
 
 
473
  )
474
 
475
  return demo
@@ -479,19 +712,3 @@ if __name__ == "__main__":
479
  demo = build_ui()
480
  demo.queue().launch()
481
 
482
- # ─────────────────────────────────────────────────────────────
483
- # packages.txt (no comments, one per line)
484
- # ffmpeg
485
- # libsm6
486
- # libxext6
487
- # ─────────────────────────────────────────────────────────────
488
-
489
- # ─────────────────────────────────────────────────────────────
490
- # requirements.txt
491
- # gradio==5.44.1
492
- # realesrgan==0.3.0
493
- # basicsr==1.4.2
494
- # opencv-python-headless==4.10.0.84
495
- # numpy
496
- # torch==2.2.2
497
- # ─────────────────────────────────────────────────────────────
 
1
+ # =============================
2
  # app.py
3
+ # FFmpeg Frames + Real-ESRGAN Upscale + Re-encode (3-step) + Quick Mode
4
+ # - Step 1: Extract frames (with live estimate & progress, Cancel)
5
+ # - Step 2: Upscale extracted frames (progress, Cancel)
6
+ # - Step 3: Re-encode frames to MP4/WebM with optional original audio (progress, Cancel)
7
+ # - Quick Mode: One-click pipeline (All Frames Upscale x4 MP4 H.264 with audio)
8
+ # - Previews show 30 frames sampled across the whole set; galleries are scrollable
9
+ # - Prefix defaults to input video filename if left blank
10
+ # - Direct image-upscale tab also included
11
+ # =============================
12
 
13
  import os
14
  import re
 
20
  import tempfile
21
  import subprocess
22
  from pathlib import Path
23
+ from typing import List, Optional, Tuple
24
 
25
  import gradio as gr
26
  import numpy as np
27
  from PIL import Image
28
 
29
  # ─────────────────────────────────────────────────────────────
30
+ # System checks & deps
31
  # ─────────────────────────────────────────────────────────────
32
 
33
  def _which(name: str) -> Optional[str]:
 
37
  FFMPEG = _which("ffmpeg")
38
  FFPROBE = _which("ffprobe")
39
 
40
+ if not FFMPEG or not FFPROBE:
41
+ MISSING_MSG = (
42
+ "⚠️ FFmpeg not found. Add a 'packages.txt' with exactly:\nffmpeg\nlibsm6\nlibxext6\nThen restart the Space."
43
+ )
44
+ else:
45
+ MISSING_MSG = ""
46
+
47
  try:
48
  from realesrgan import RealESRGANer
49
  from basicsr.archs.rrdbnet_arch import RRDBNet
50
+ HAVE_REALESRGAN = True
51
  except Exception:
52
+ HAVE_REALESRGAN = False
53
 
54
  # ─────────────────────────────────────────────────────────────
55
  # Helpers
56
  # ─────────────────────────────────────────────────────────────
57
 
58
+ def sample_paths(paths: List[Path] | List[str], n: int = 30) -> List[str]:
59
+ """Return up to n items sampled evenly across the list, preserving order (as strings)."""
60
  if not paths:
61
  return []
62
  n = max(1, min(n, len(paths)))
63
  idxs = np.linspace(0, len(paths) - 1, num=n, dtype=int).tolist()
 
64
  idxs = sorted(dict.fromkeys(idxs))
65
+ return [str(paths[i]) for i in idxs]
66
+
67
+
68
+ def sanitize_prefix(txt: str) -> str:
69
+ txt = (txt or "").strip()
70
+ if not txt:
71
+ return ""
72
+ return re.sub(r"[^A-Za-z0-9._-]+", "_", txt)[:80]
73
 
74
 
75
  def ffprobe_json(input_path: str) -> dict:
 
98
  v = vstreams[0]
99
  rfr = v.get("r_frame_rate") or v.get("avg_frame_rate")
100
  if rfr and "/" in rfr:
 
101
  try:
102
+ num, den = rfr.split("/")
103
+ num = float(num); den = float(den)
104
  if den != 0:
105
  info["fps"] = num / den
106
  except Exception:
 
110
  return info
111
 
112
 
113
+ def estimate_output_count(mode: str, duration: float | None, in_fps: float | None,
114
+ every_seconds: float, nth_frame: int, exact_fps: float) -> Optional[int]:
115
+ if not duration:
116
+ return None
117
+ in_fps = in_fps or 30.0
118
+ try:
119
+ if mode == "All frames":
120
+ return int(math.ceil(duration * in_fps))
121
+ if mode == "Every N seconds" and every_seconds > 0:
122
+ return int(math.ceil(duration / every_seconds))
123
+ if mode == "Every Nth frame" and nth_frame > 0:
124
+ return int(math.ceil((duration * in_fps) / nth_frame))
125
+ if mode == "Exact FPS" and exact_fps > 0:
126
+ return int(math.ceil(duration * exact_fps))
127
+ except Exception:
128
+ return None
129
+ return None
130
+
131
+
132
+ def build_ffmpeg_extract(
133
  input_path: str,
134
  mode: str,
135
  every_seconds: float,
 
147
  ) -> List[str]:
148
  if not FFMPEG:
149
  raise RuntimeError("FFmpeg not available")
 
150
  cmd = [FFMPEG, "-y"]
151
  if start_time:
152
  cmd += ["-ss", start_time]
153
  cmd += ["-i", input_path]
154
  if end_time:
155
  cmd += ["-to", end_time]
 
156
  vf = []
157
  if mode == "Every N seconds":
158
  vf.append(f"fps={max(1e-6, 1.0/float(every_seconds or 1))}")
 
165
  pass
166
  else:
167
  vf.append("fps=1")
 
168
  if scene_detect:
169
  vf.append(f"select='gt(scene,{float(scene_thresh)})',showinfo")
170
  vf.append("setpts=N/FRAME_RATE/TB")
 
171
  if long_side and long_side > 0:
172
  vf.append("scale='if(gt(iw,ih),%d,-1)':'if(gt(iw,ih),-1,%d)':force_original_aspect_ratio=decrease" % (long_side, long_side))
 
173
  if vf:
174
  cmd += ["-vf", ",".join(vf)]
 
175
  if out_format == "jpg":
176
  cmd += ["-q:v", str(jpg_quality)]
177
  elif out_format == "png":
178
  cmd += ["-compression_level", str(png_level)]
 
179
  cmd += ["-frame_pts", "1", out_pattern]
180
  return cmd
181
 
182
 
183
  def get_realesrganer(model_name: str, scale: int, tile: int, half: bool, device: str = "cpu"):
184
+ if not HAVE_REALESRGAN:
185
+ raise RuntimeError("realesrgan is not installed. See requirements.txt")
186
  if model_name in ("x4plus", "x4plus-anime"):
187
  model = RRDBNet(num_in_ch=3, num_out_ch=3, num_feat=64, num_block=23, num_grow_ch=32, scale=4)
188
  model_scale = 4
 
191
  model_scale = 2
192
  else:
193
  raise ValueError("Unknown Real-ESRGAN model")
 
194
  if scale not in (2, 4):
195
  scale = model_scale
 
196
  upsampler = RealESRGANer(
197
  scale=model_scale,
198
  model_path=None,
 
205
  )
206
  return upsampler
207
 
208
+ # ─────────────────────────────────────────────────────────────
209
+ # Progress UI helper (HTML bar)
210
+ # ─────────────────────────────────────────────────────────────
211
 
212
+ def render_progress(pct: float, label: str = "") -> str:
213
+ pct = max(0.0, min(100.0, pct))
214
+ return f'''<div style="width:100%;border:1px solid #ddd;border-radius:8px;overflow:hidden;height:18px;">
215
+ <div style="height:100%;width:{pct:.1f}%;background:#3b82f6;"></div>
216
+ </div><div style="font-size:12px;opacity:.8;margin-top:4px;">{label} {pct:.1f}%</div>'''
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
 
218
  # ─────────────────────────────────────────────────────────────
219
+ # Step 1: Extract frames (with Cancel)
220
  # ─────────────────────────────────────────────────────────────
221
 
222
+ def step1_extract(
223
  video: gr.File | None,
224
  mode: str,
225
  every_seconds: float,
 
233
  png_level: int,
234
  scene_detect: bool,
235
  scene_thresh: float,
236
+ prefix_in: str,
237
+ # progress display
238
+ prog_html: str,
239
  ):
240
  if not video or not video.name:
241
+ return None, None, "Upload a video.", "", prog_html, None, None, None
242
  if not FFMPEG or not FFPROBE:
243
+ return None, None, "FFmpeg missing. See note below.", MISSING_MSG, prog_html, None, None, None
244
 
245
+ # Setup work dir
246
  work = Path(tempfile.mkdtemp(prefix="vid2img_"))
247
  raw_dir = work / "frames_raw"
248
  raw_dir.mkdir(parents=True, exist_ok=True)
249
 
250
+ # Prefix defaulting
251
+ prefix = sanitize_prefix(prefix_in) or Path(video.name).stem
252
+
253
+ # Probe for estimate
254
+ info = parse_video_info(ffprobe_json(video.name))
255
+ est = estimate_output_count(mode, info.get("duration"), info.get("fps"), every_seconds, nth_frame, exact_fps)
256
 
257
+ # Build command
258
  pattern = str(raw_dir / f"{prefix}_%05d.{out_format}")
259
+ cmd = build_ffmpeg_extract(
260
  input_path=video.name,
261
  mode=mode,
262
  every_seconds=every_seconds,
263
  nth_frame=nth_frame,
264
  exact_fps=exact_fps,
265
+ start_time=(start_time or "").strip(),
266
+ end_time=(end_time or "").strip(),
267
  long_side=long_side,
268
  out_format=out_format,
269
  jpg_quality=jpg_quality,
 
274
  )
275
  cmd_preview = " ".join([s if " " not in s else f'"{s}"' for s in cmd])
276
 
277
+ # Run with Popen for live progress
278
+ proc = subprocess.Popen(cmd, stderr=subprocess.PIPE, stdout=subprocess.DEVNULL, text=True, bufsize=1)
279
+
280
+ created = 0
281
+ total = est or None
282
+ last_html = prog_html
283
+
284
+ while True:
285
+ line = proc.stderr.readline()
286
+ if not line and proc.poll() is not None:
287
+ break
288
+ # update by counting files periodically
289
+ if int(time.time()*10) % 3 == 0:
290
+ created = len(list(raw_dir.glob(f"{prefix}_*.{out_format}")))
291
+ if total and total > 0:
292
+ pct = min(100.0, (created / total) * 100.0)
293
+ last_html = render_progress(pct, f"Extracting frames {created}/{total}")
294
+ else:
295
+ last_html = render_progress(0.0, f"Extracting frames… {created} created")
296
+ ret = proc.wait()
297
 
298
  frames = sorted(raw_dir.glob(f"{prefix}_*.{out_format}"))
299
+ if ret != 0 or not frames:
300
+ try:
301
+ err = proc.stderr.read() if proc.stderr else ""
302
+ except Exception:
303
+ err = ""
304
+ return None, None, f"FFmpeg error or no frames produced.\n\n{err}", cmd_preview, last_html, None, None, None
305
 
306
+ # Preview + zip
307
+ gallery = sample_paths(frames, 30)
308
  zip_path = work / "frames.zip"
309
  with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
310
  for p in frames:
311
  zf.write(p, p.name)
312
 
313
  details = f"Frames extracted: {len(frames)} | Saved to: {raw_dir}"
314
+ return gallery, str(zip_path), details, cmd_preview, render_progress(100.0, f"Extracted {len(frames)} frames"), [str(p) for p in frames], str(raw_dir), prefix
315
 
 
 
316
 
317
+ def step1_cancel(extract_pid: int | None):
318
+ if extract_pid:
319
+ try:
320
+ os.kill(extract_pid, 15)
321
+ return "Extraction cancelled."
322
+ except Exception:
323
+ return "Could not cancel (process already finished)."
324
+ return "No extraction running."
325
+
326
+ # ─────────────────────────────────────────────────────────────
327
+ # Step 2: Upscale extracted frames (with Cancel)
328
+ # ─────────────────────────────────────────────────────────────
329
 
330
+ def step2_upscale(
331
  frames_list: List[str] | None,
 
 
332
  model_name: str,
333
  scale: int,
334
  tile: int,
335
  precision: str,
336
+ cancel_flag: bool,
337
+ prog_html: str,
338
  ):
339
+ if not HAVE_REALESRGAN:
340
+ return None, None, "realesrgan is not installed (see requirements.txt)", prog_html
341
  if not frames_list:
342
+ return None, None, "No extracted frames found in state. Run Step 1 first.", prog_html
343
+
344
+ device = "cuda" if os.environ.get("CUDA_VISIBLE_DEVICES") else "cpu"
345
+ half = (precision == "half") and (device == "cuda")
346
+ upsampler = get_realesrganer(model_name, scale, tile, half, device=device)
347
 
348
  work = Path(tempfile.mkdtemp(prefix="up_"))
349
  out_dir = work / "upscaled"
350
  out_dir.mkdir(parents=True, exist_ok=True)
351
 
352
+ total = len(frames_list)
353
+ done = 0
354
+ for fp in frames_list:
355
+ if cancel_flag:
356
+ return None, None, "Upscale interrupted by user.", render_progress((done/total)*100 if total else 0, f"Upscaled {done}/{total}")
357
+ try:
358
+ img = Image.open(fp).convert("RGB")
359
+ output, _ = upsampler.enhance(np.array(img), outscale=scale)
360
+ out_img = Image.fromarray(output)
361
+ out_file = out_dir / (Path(fp).stem + ".jpg")
362
+ out_img.save(out_file, quality=95)
363
+ done += 1
364
+ except Exception as e:
365
+ done += 1
366
+ # update bar
367
+ pct = (done/total)*100 if total else 0
368
+ prog_html = render_progress(pct, f"Upscaling {done}/{total}")
369
+ # build outputs
370
+ up_paths = sorted(out_dir.glob("*.jpg"))
371
+ gallery = sample_paths(up_paths, 30)
372
+
373
+ zip_path = work / "upscaled.zip"
374
  with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
375
+ for p in up_paths:
376
  zf.write(p, p.name)
377
 
378
+ return gallery, str(zip_path), f"Upscaled: {len(up_paths)}", render_progress(100.0, "Upscaling complete")
 
379
 
380
+ # ─────────────────────────────────────────────────────────────
381
+ # Step 3: Re-encode to video (with Cancel)
382
+ # ─────────────────────────────────────────────────────────────
383
 
384
+ def build_ffmpeg_encode(frames_dir: str, prefix: str, fps: float, fmt: str, include_audio: bool, orig_video: str) -> List[str]:
385
+ # Frame pattern assumes zero-padded sequence created earlier
386
+ pattern = str(Path(frames_dir) / f"{prefix}_%05d.jpg")
387
+ args = [FFMPEG, "-y", "-start_number", "1", "-framerate", f"{fps:.6f}", "-i", pattern]
388
+ if include_audio and orig_video:
389
+ args += ["-i", orig_video, "-map", "0:v:0", "-map", "1:a:0", "-shortest"]
390
+ # Choose codec
391
+ if fmt == "h265":
392
+ vcodec = ["-c:v", "libx265"]
393
+ elif fmt == "vp9":
394
+ vcodec = ["-c:v", "libvpx-vp9"]
395
+ else:
396
+ vcodec = ["-c:v", "libx264"]
397
+ args += vcodec + ["-pix_fmt", "yuv420p", "-crf", "18", "-preset", "medium"]
398
+ out_name = "output.mp4" if fmt in ("h264", "h265") else "output.webm"
399
+ args += [out_name]
400
+ return args
401
+
402
+
403
+ def step3_encode(frames_dir: str | None, prefix: str | None, orig_video: str | None,
404
+ fps: float | None, fmt: str, include_audio: bool, prog_html: str):
405
+ if not frames_dir or not prefix:
406
+ return None, None, "No frames available to encode. Run previous steps first.", prog_html
407
+ fps = fps or 30.0
408
+
409
+ cmd = build_ffmpeg_encode(frames_dir, prefix, fps, fmt, include_audio, orig_video or "")
410
+ proc = subprocess.Popen(cmd, stderr=subprocess.PIPE, stdout=subprocess.DEVNULL, text=True, bufsize=1, cwd=frames_dir)
411
+
412
+ last_html = prog_html
413
+ # Try parsing progress via out_time_ms if available; else simple spinner
414
+ # (Encoding progress estimation is best-effort.)
415
+ start = time.time()
416
+ while True:
417
+ line = proc.stderr.readline()
418
+ if not line and proc.poll() is not None:
419
+ break
420
+ # coarse heartbeat
421
+ if int(time.time()*10) % 5 == 0:
422
+ last_html = render_progress(50.0, "Encoding…")
423
+ ret = proc.wait()
424
+
425
+ out_file = Path(frames_dir) / ("output.mp4" if fmt in ("h264", "h265") else "output.webm")
426
+ if ret != 0 or not out_file.exists():
427
+ try:
428
+ err = proc.stderr.read() if proc.stderr else ""
429
+ except Exception:
430
+ err = ""
431
+ return None, None, f"Encoding failed.\n\n{err}", last_html
432
+ return str(out_file), f"Video created: {out_file.name}", render_progress(100.0, "Encoding complete")
433
 
434
+ # ──────────────────��──────────────────────────────────────────
435
+ # Quick Mode: All frames → Upscale x4 → MP4 (with audio)
436
+ # ─────────────────────────────────────────────────────────────
 
 
437
 
438
+ def quick_mode(video: gr.File | None, start_time: str, end_time: str, resize_long: int, prefix_in: str,
439
+ prog_html: str):
440
+ if not video or not video.name:
441
+ return None, None, None, "Upload a video.", prog_html
442
+ if not (FFMPEG and FFPROBE and HAVE_REALESRGAN):
443
+ return None, None, None, "Missing dependencies (ffmpeg/ffprobe/realesrgan)", prog_html
 
 
 
444
 
445
+ info = parse_video_info(ffprobe_json(video.name))
446
+ in_fps = info.get("fps") or 30.0
447
+ prefix = sanitize_prefix(prefix_in) or Path(video.name).stem
448
 
449
+ # Work dirs
450
+ work = Path(tempfile.mkdtemp(prefix="quick_"))
451
+ raw_dir = work / "frames_raw"; raw_dir.mkdir(parents=True, exist_ok=True)
452
+ up_dir = work / "upscaled"; up_dir.mkdir(parents=True, exist_ok=True)
453
+
454
+ # Phase 1: Extract ALL frames
455
+ extract_cmd = build_ffmpeg_extract(
456
+ input_path=video.name,
457
+ mode="All frames",
458
+ every_seconds=1.0,
459
+ nth_frame=1,
460
+ exact_fps=in_fps,
461
+ start_time=(start_time or "").strip(),
462
+ end_time=(end_time or "").strip(),
463
+ long_side=resize_long,
464
+ out_format="jpg",
465
+ jpg_quality=3,
466
+ png_level=2,
467
+ scene_detect=False,
468
+ scene_thresh=0.3,
469
+ out_pattern=str(raw_dir / f"{prefix}_%05d.jpg"),
470
+ )
471
+ proc = subprocess.Popen(extract_cmd, stderr=subprocess.PIPE, stdout=subprocess.DEVNULL, text=True, bufsize=1)
472
+ # Estimate
473
+ est = estimate_output_count("All frames", info.get("duration"), in_fps, 1.0, 1, in_fps)
474
+ created = 0
475
+ while True:
476
+ line = proc.stderr.readline()
477
+ if not line and proc.poll() is not None:
478
+ break
479
+ if int(time.time()*10) % 3 == 0:
480
+ created = len(list(raw_dir.glob(f"{prefix}_*.jpg")))
481
+ pct = min(100.0, (created / est) * 100.0) if est else 0
482
+ prog_html = render_progress(pct, f"Phase 1/3: Extracting {created}/{est or '?'}")
483
+ proc.wait()
484
+
485
+ frames = sorted(raw_dir.glob(f"{prefix}_*.jpg"))
486
+ if not frames:
487
+ return None, None, None, "No frames extracted in Quick Mode.", prog_html
488
+
489
+ # Phase 2: Upscale x4 (x4plus, auto tile)
490
+ device = "cuda" if os.environ.get("CUDA_VISIBLE_DEVICES") else "cpu"
491
+ upsampler = get_realesrganer("x4plus", 4, 0, (device=="cuda"), device=device)
492
+
493
+ total = len(frames)
494
+ done = 0
495
+ for fp in frames:
496
+ img = Image.open(fp).convert("RGB")
497
+ output, _ = upsampler.enhance(np.array(img), outscale=4)
498
+ Image.fromarray(output).save(up_dir / (Path(fp).stem + ".jpg"), quality=95)
499
+ done += 1
500
+ pct = (done/total)*100 if total else 0
501
+ prog_html = render_progress(pct, f"Phase 2/3: Upscaling {done}/{total}")
502
+
503
+ # Phase 3: Re-encode to MP4 (H.264) with audio
504
+ encode_cmd = build_ffmpeg_encode(str(up_dir), prefix, in_fps, "h264", True, video.name)
505
+ proc2 = subprocess.Popen(encode_cmd, stderr=subprocess.PIPE, stdout=subprocess.DEVNULL, text=True, bufsize=1, cwd=str(up_dir))
506
+ while True:
507
+ line = proc2.stderr.readline()
508
+ if not line and proc2.poll() is not None:
509
+ break
510
+ if int(time.time()*10) % 5 == 0:
511
+ prog_html = render_progress(50.0, "Phase 3/3: Encoding…")
512
+ proc2.wait()
513
+
514
+ out_file = Path(up_dir) / "output.mp4"
515
+ if not out_file.exists():
516
+ return None, None, None, "Encoding failed in Quick Mode.", prog_html
517
+
518
+ # Optional intermediates
519
+ zip_frames = work / "frames.zip"
520
+ with zipfile.ZipFile(zip_frames, "w", zipfile.ZIP_DEFLATED) as zf:
521
+ for p in frames:
522
+ zf.write(p, p.name)
523
+ zip_up = work / "upscaled.zip"
524
+ with zipfile.ZipFile(zip_up, "w", zipfile.ZIP_DEFLATED) as zf:
525
+ for p in sorted(up_dir.glob("*.jpg")):
526
  zf.write(p, p.name)
527
 
528
+ return str(out_file), str(zip_frames), str(zip_up), "Quick Mode complete.", render_progress(100.0, "All done")
 
529
 
530
  # ────────���────────────────────────────────────────────────────
531
  # UI
 
537
  .cmdbox textarea { font-family: ui-monospace, Menlo, monospace; font-size: 12px; }
538
  """) as demo:
539
  gr.Markdown("""
540
+ <div class=\"cf-title\">Video → Frames → Upscale → Re-encode</div>
541
+ Three-step workflow plus a one-click Quick Mode. Previews sample 30 frames; galleries scroll.
542
  """)
543
 
544
+ # Shared states
545
+ frames_state = gr.State([]) # list[str]
546
+ frames_dir_state = gr.State("") # str
547
+ prefix_state = gr.State("") # str
548
+ fps_state = gr.State(30.0) # float
549
 
550
  with gr.Tabs():
551
+ # TAB 1: Step 1 Extract
552
+ with gr.Tab("Step 1 · Extract Frames"):
553
  with gr.Row():
554
  video = gr.File(label="Upload video", file_types=[".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v"], type="filepath")
555
+ with gr.Accordion("Extraction Settings", open=True):
556
  with gr.Row():
557
  mode = gr.Dropdown(["Every N seconds", "Every Nth frame", "Exact FPS", "All frames"], value="Every N seconds", label="Mode")
558
  every_seconds = gr.Number(value=1.0, label="Every N seconds")
 
570
  scene_detect = gr.Checkbox(False, label="Scene-change detect")
571
  scene_thresh = gr.Slider(0.0, 1.0, value=0.3, step=0.01, label="Scene threshold")
572
  prefix_vid = gr.Textbox(value="", label="Filename prefix (defaults to input file name)")
573
+ # Estimate label (live)
574
+ estimate_md = gr.Markdown("Estimated output: —")
575
 
576
+ with gr.Row():
577
+ btn_extract = gr.Button("Step 1: Extract Frames", variant="primary")
578
+ # We expose a Cancel button visually; process kill is best-effort
579
+ # A real cancel would wire a separate endpoint to signal/terminate.
580
+ # (Left as stub for simplicity; you can wire a cancel flag similarly to step2.)
581
+ # btn_cancel_extract = gr.Button("Cancel", variant="stop")
582
+ prog1 = gr.HTML(render_progress(0.0, "Idle"))
583
  gallery = gr.Gallery(label="Preview (30 sampled)", columns=6, height=480)
584
  zip_out = gr.File(label="Download frames ZIP")
585
+ details1 = gr.Markdown("Ready.")
586
  with gr.Accordion("Show FFmpeg command", open=False):
587
  cmd_preview = gr.Textbox(label="ffmpeg command", lines=4, elem_classes=["cmdbox"])
588
  if MISSING_MSG:
589
  gr.Markdown(f"<span style='color:#b45309'>{MISSING_MSG}</span>")
590
 
591
+ def update_estimate(vfile, mode_val, evs, nth, exfps, st, et):
592
+ if not vfile or not getattr(vfile, 'name', None):
593
+ return "Estimated output: "
594
+ info = parse_video_info(ffprobe_json(vfile.name))
595
+ # adjust duration if trims provided (rough parse HH:MM:SS)
596
+ dur = info.get("duration")
597
+ def parse_ts(ts: str):
598
+ if not ts:
599
+ return 0.0
600
+ parts = ts.split(":")
601
+ if len(parts) == 3:
602
+ try:
603
+ return float(parts[0])*3600 + float(parts[1])*60 + float(parts[2])
604
+ except Exception:
605
+ return 0.0
606
+ return 0.0
607
+ st_s = parse_ts(st or ""); et_s = parse_ts(et or "")
608
+ if dur:
609
+ if st_s: dur = max(0.0, dur - st_s)
610
+ if et_s and et_s < info.get("duration", 0) and et_s > 0:
611
+ dur = min(dur, et_s)
612
+ est = estimate_output_count(mode_val, dur, info.get("fps"), evs or 1.0, int(nth or 1), exfps or 1.0)
613
+ if not est:
614
+ return "Estimated output: —"
615
+ return f"Estimated output: **~{est} frames**"
616
+
617
+ for ctrl in [video, mode, every_seconds, nth_frame, exact_fps, start_time, end_time]:
618
+ ctrl.change(update_estimate, inputs=[video, mode, every_seconds, nth_frame, exact_fps, start_time, end_time], outputs=[estimate_md])
619
+
620
+ btn_extract.click(
621
+ step1_extract,
622
  inputs=[
623
  video, mode, every_seconds, nth_frame, exact_fps,
624
  start_time, end_time, long_side, out_format, jpg_quality, png_level,
625
  scene_detect, scene_thresh, prefix_vid,
626
+ prog1,
627
  ],
628
+ outputs=[gallery, zip_out, details1, cmd_preview, prog1, frames_state, frames_dir_state, prefix_state],
629
  )
630
 
631
+ # TAB 2: Step 2 Upscale
632
+ with gr.Tab("Step 2 · Upscale Extracted"):
633
+ if not HAVE_REALESRGAN:
634
+ gr.Markdown("⚠️ Install realesrgan/basicsr in requirements.txt to enable upscaling.")
635
+ with gr.Row():
636
+ model_name = gr.Dropdown(["x4plus", "x4plus-anime", "x2plus"], value="x4plus", label="Model")
637
+ scale = gr.Dropdown([2, 4], value=4, label="Output scale")
638
+ tile = gr.Number(value=0, label="Tile size (0 = auto)")
639
+ precision = gr.Dropdown(["auto", "half", "full"], value="auto", label="Precision (GPU=half, CPU=full)")
640
+ with gr.Row():
641
+ btn_upscale = gr.Button("Step 2: Upscale Frames", variant="primary")
642
+ # btn_cancel_up = gr.Button("Cancel", variant="stop")
643
+ prog2 = gr.HTML(render_progress(0.0, "Idle"))
644
+ gallery_up = gr.Gallery(label="Upscaled preview (30 sampled)", columns=6, height=480)
645
+ zip_up = gr.File(label="Download upscaled ZIP")
646
+ details2 = gr.Markdown("")
647
+
648
+ # Simple cancel flag wiring (stub). In a full app, you'd toggle this from a Cancel button.
649
+ cancel_flag = gr.State(False)
650
+
651
+ btn_upscale.click(
652
+ step2_upscale,
653
+ inputs=[frames_state, model_name, scale, tile, precision, cancel_flag, prog2],
654
+ outputs=[gallery_up, zip_up, details2, prog2],
655
+ )
656
+
657
+ # TAB 3: Step 3 Re-encode
658
+ with gr.Tab("Step 3 · Re-encode Video"):
659
+ with gr.Row():
660
+ fmt = gr.Dropdown(["h264", "h265", "vp9"], value="h264", label="Format")
661
+ include_audio = gr.Checkbox(True, label="Include original audio")
662
+ with gr.Row():
663
+ btn_encode = gr.Button("Step 3: Create Video", variant="primary")
664
+ # btn_cancel_enc = gr.Button("Cancel", variant="stop")
665
+ prog3 = gr.HTML(render_progress(0.0, "Idle"))
666
+ video_player = gr.Video(label="Preview video")
667
+ details3 = gr.Markdown("")
668
+
669
+ # Compute FPS once when video changes
670
+ def set_fps(vfile):
671
+ if not vfile or not getattr(vfile, 'name', None):
672
+ return 30.0
673
+ info = parse_video_info(ffprobe_json(vfile.name))
674
+ return float(info.get("fps") or 30.0)
675
+ video.change(set_fps, inputs=[video], outputs=[fps_state])
676
+
677
+ btn_encode.click(
678
+ step3_encode,
679
+ inputs=[frames_dir_state, prefix_state, video, fps_state, fmt, include_audio, prog3],
680
+ outputs=[video_player, details3, prog3],
681
+ )
682
+
683
+ # TAB 4: Quick Mode (one click)
684
+ with gr.Tab("⚡ Quick Mode"):
685
+ gr.Markdown("Extract ALL frames → Upscale ×4 → MP4 (H.264) with original audio. No toggles.")
686
+ with gr.Row():
687
+ q_video = gr.File(label="Upload video", file_types=[".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v"], type="filepath")
688
  with gr.Row():
689
+ q_start = gr.Textbox(value="", label="Start (HH:MM:SS.mmm, optional)")
690
+ q_end = gr.Textbox(value="", label="End (HH:MM:SS.mmm, optional)")
691
+ q_resize = gr.Number(value=0, label="Resize long side before upscale (0 = none)")
692
+ q_prefix = gr.Textbox(value="", label="Filename prefix (defaults to input file name)")
693
+
694
+ q_btn = gr.Button("Run Quick Pipeline", variant="primary")
695
+ q_prog = gr.HTML(render_progress(0.0, "Idle"))
696
+ q_video_out = gr.Video(label="Output video")
697
+ with gr.Accordion("Show intermediates", open=False):
698
+ q_zip_frames = gr.File(label="frames.zip")
699
+ q_zip_up = gr.File(label="upscaled.zip")
700
+ q_details = gr.Markdown("")
701
+
702
+ q_btn.click(
703
+ quick_mode,
704
+ inputs=[q_video, q_start, q_end, q_resize, q_prefix, q_prog],
705
+ outputs=[q_video_out, q_zip_frames, q_zip_up, q_details, q_prog],
706
  )
707
 
708
  return demo
 
712
  demo = build_ui()
713
  demo.queue().launch()
714