JS6969 commited on
Commit
d1a47c8
·
verified ·
1 Parent(s): 8fc9e1e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +144 -127
app.py CHANGED
@@ -1,13 +1,12 @@
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
@@ -39,17 +38,23 @@ 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
@@ -182,7 +187,7 @@ def build_ffmpeg_extract(
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
@@ -205,19 +210,12 @@ def get_realesrganer(model_name: str, scale: int, tile: int, half: bool, device:
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,
@@ -234,7 +232,6 @@ def step1_extract(
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:
@@ -242,19 +239,15 @@ def step1_extract(
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,
@@ -274,25 +267,22 @@ def step1_extract(
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}"))
@@ -301,9 +291,10 @@ def step1_extract(
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:
@@ -313,19 +304,22 @@ def step1_extract(
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,
@@ -333,43 +327,51 @@ def step2_upscale(
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:
@@ -377,17 +379,44 @@ def step2_upscale(
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":
@@ -400,24 +429,30 @@ def build_ffmpeg_encode(frames_dir: str, prefix: str, fps: float, fmt: str, incl
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()
@@ -428,30 +463,28 @@ def step3_encode(frames_dir: str | None, prefix: str | None, orig_video: str | N
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",
@@ -469,7 +502,6 @@ def quick_mode(video: gr.File | None, start_time: str, end_time: str, resize_lon
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:
@@ -486,7 +518,7 @@ def quick_mode(video: gr.File | None, start_time: str, end_time: str, resize_lon
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
 
@@ -500,7 +532,7 @@ def quick_mode(video: gr.File | None, start_time: str, end_time: str, resize_lon
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:
@@ -515,7 +547,7 @@ def quick_mode(video: gr.File | None, start_time: str, end_time: str, resize_lon
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:
@@ -527,9 +559,7 @@ def quick_mode(video: gr.File | None, start_time: str, end_time: str, resize_lon
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
532
- # ─────────────────────────────────────────────────────────────
533
 
534
  def build_ui():
535
  with gr.Blocks(theme=gr.themes.Soft(), css="""
@@ -538,17 +568,17 @@ def build_ui():
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")
@@ -570,15 +600,9 @@ def build_ui():
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")
@@ -592,17 +616,13 @@ def build_ui():
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:
@@ -610,9 +630,7 @@ def build_ui():
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])
@@ -628,59 +646,59 @@ def build_ui():
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():
@@ -710,5 +728,4 @@ def build_ui():
710
 
711
  if __name__ == "__main__":
712
  demo = build_ui()
713
- demo.queue().launch()
714
-
 
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)
5
+ # - Step 2: Upscale frames (now supports uploading your own images directly)
6
+ # - Step 3: Re-encode frames (now supports uploading your own frames/ZIP and optional audio source)
7
+ # - Quick Mode: One-click (All Frames → Upscale x4 → MP4 H.264 with audio)
8
+ # - Previews: 30 frames sampled evenly; scrollable galleries
9
  # - Prefix defaults to input video filename if left blank
 
10
  # =============================
11
 
12
  import os
 
38
 
39
  if not FFMPEG or not FFPROBE:
40
  MISSING_MSG = (
41
+ "⚠️ FFmpeg not found. Add a 'packages.txt' with exactly:
42
+ ffmpeg
43
+ libsm6
44
+ libxext6
45
+ Then restart the Space."
46
  )
47
  else:
48
  MISSING_MSG = ""
49
 
50
+ # Try to import Real-ESRGAN stack
51
  try:
52
  from realesrgan import RealESRGANer
53
  from basicsr.archs.rrdbnet_arch import RRDBNet
54
  HAVE_REALESRGAN = True
55
+ except Exception as e:
56
  HAVE_REALESRGAN = False
57
+ REAL_ERR = str(e)
58
 
59
  # ─────────────────────────────────────────────────────────────
60
  # Helpers
 
187
 
188
  def get_realesrganer(model_name: str, scale: int, tile: int, half: bool, device: str = "cpu"):
189
  if not HAVE_REALESRGAN:
190
+ raise RuntimeError("realesrgan is not installed. See requirements.txt (realesrgan, basicsr, torch, numpy, scipy, scikit-image).")
191
  if model_name in ("x4plus", "x4plus-anime"):
192
  model = RRDBNet(num_in_ch=3, num_out_ch=3, num_feat=64, num_block=23, num_grow_ch=32, scale=4)
193
  model_scale = 4
 
210
  )
211
  return upsampler
212
 
 
 
 
213
 
214
  def render_progress(pct: float, label: str = "") -> str:
215
  pct = max(0.0, min(100.0, pct))
216
+ return f'''<div style="width:100%;border:1px solid #ddd;border-radius:8px;overflow:hidden;height:18px;"><div style="height:100%;width:{pct:.1f}%;background:#3b82f6;"></div></div><div style="font-size:12px;opacity:.8;margin-top:4px;">{label} {pct:.1f}%</div>'''
 
 
217
 
218
+ # ───────────────── Extraction (Step 1)
 
 
219
 
220
  def step1_extract(
221
  video: gr.File | None,
 
232
  scene_detect: bool,
233
  scene_thresh: float,
234
  prefix_in: str,
 
235
  prog_html: str,
236
  ):
237
  if not video or not video.name:
 
239
  if not FFMPEG or not FFPROBE:
240
  return None, None, "FFmpeg missing. See note below.", MISSING_MSG, prog_html, None, None, None
241
 
 
242
  work = Path(tempfile.mkdtemp(prefix="vid2img_"))
243
  raw_dir = work / "frames_raw"
244
  raw_dir.mkdir(parents=True, exist_ok=True)
245
 
 
246
  prefix = sanitize_prefix(prefix_in) or Path(video.name).stem
247
 
 
248
  info = parse_video_info(ffprobe_json(video.name))
249
  est = estimate_output_count(mode, info.get("duration"), info.get("fps"), every_seconds, nth_frame, exact_fps)
250
 
 
251
  pattern = str(raw_dir / f"{prefix}_%05d.{out_format}")
252
  cmd = build_ffmpeg_extract(
253
  input_path=video.name,
 
267
  )
268
  cmd_preview = " ".join([s if " " not in s else f'"{s}"' for s in cmd])
269
 
 
270
  proc = subprocess.Popen(cmd, stderr=subprocess.PIPE, stdout=subprocess.DEVNULL, text=True, bufsize=1)
271
 
272
  created = 0
273
  total = est or None
274
  last_html = prog_html
 
275
  while True:
276
  line = proc.stderr.readline()
277
  if not line and proc.poll() is not None:
278
  break
 
279
  if int(time.time()*10) % 3 == 0:
280
  created = len(list(raw_dir.glob(f"{prefix}_*.{out_format}")))
281
  if total and total > 0:
282
  pct = min(100.0, (created / total) * 100.0)
283
+ last_html = render_progress(pct, f"Extracting {created}/{total}")
284
  else:
285
+ last_html = render_progress(0.0, f"Extracting… {created} created")
286
  ret = proc.wait()
287
 
288
  frames = sorted(raw_dir.glob(f"{prefix}_*.{out_format}"))
 
291
  err = proc.stderr.read() if proc.stderr else ""
292
  except Exception:
293
  err = ""
294
+ return None, None, f"FFmpeg error or no frames produced.
295
+
296
+ {err}", cmd_preview, last_html, None, None, None
297
 
 
298
  gallery = sample_paths(frames, 30)
299
  zip_path = work / "frames.zip"
300
  with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
 
304
  details = f"Frames extracted: {len(frames)} | Saved to: {raw_dir}"
305
  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
306
 
307
+ # ───────────────── Upscale (Step 2) — supports uploaded images OR frames from Step 1
308
 
309
+ def save_uploaded_images(files: List[gr.File] | None, prefix: str = "upload") -> Tuple[List[Path], Path]:
310
+ tmp = Path(tempfile.mkdtemp(prefix="imgup_"))
311
+ in_dir = tmp / "input"; in_dir.mkdir(parents=True, exist_ok=True)
312
+ paths: List[Path] = []
313
+ if not files:
314
+ return paths, in_dir
315
+ for f in files:
316
+ src = Path(f.name)
317
+ name = f"{prefix}_{src.name}"
318
+ dst = in_dir / name
319
+ shutil.copy2(src, dst)
320
+ paths.append(dst)
321
+ return paths, in_dir
322
 
 
 
 
323
 
324
  def step2_upscale(
325
  frames_list: List[str] | None,
 
327
  scale: int,
328
  tile: int,
329
  precision: str,
 
330
  prog_html: str,
331
+ uploaded_imgs: List[gr.File] | None,
332
  ):
333
  if not HAVE_REALESRGAN:
334
+ msg = "Real-ESRGAN not available. Ensure requirements.txt includes: --prefer-binary, numpy==1.26.4, scipy==1.11.4, scikit-image==0.22.0, opencv-python-headless, torch==2.2.2, realesrgan==0.3.0, basicsr==1.4.2, pillow, gradio."
335
+ return None, None, msg, prog_html
336
+
337
+ # decide source: uploaded images take priority, else frames from step 1
338
+ if uploaded_imgs and len(uploaded_imgs) > 0:
339
+ img_paths, _ = save_uploaded_images(uploaded_imgs, prefix="up")
340
+ src_paths = [str(p) for p in img_paths]
341
+ else:
342
+ src_paths = frames_list or []
343
+
344
+ if not src_paths:
345
+ return None, None, "No images provided. Upload files or run Step 1 first.", prog_html
346
 
347
  device = "cuda" if os.environ.get("CUDA_VISIBLE_DEVICES") else "cpu"
348
  half = (precision == "half") and (device == "cuda")
349
  upsampler = get_realesrganer(model_name, scale, tile, half, device=device)
350
 
351
  work = Path(tempfile.mkdtemp(prefix="up_"))
352
+ out_dir = work / "upscaled"; out_dir.mkdir(parents=True, exist_ok=True)
 
353
 
354
+ total = len(src_paths)
355
  done = 0
356
+ up_paths: List[Path] = []
357
+ for fp in src_paths:
 
358
  try:
359
  img = Image.open(fp).convert("RGB")
360
  output, _ = upsampler.enhance(np.array(img), outscale=scale)
361
  out_img = Image.fromarray(output)
362
  out_file = out_dir / (Path(fp).stem + ".jpg")
363
  out_img.save(out_file, quality=95)
364
+ up_paths.append(out_file)
365
+ except Exception:
366
+ pass
367
+ done += 1
368
  pct = (done/total)*100 if total else 0
369
  prog_html = render_progress(pct, f"Upscaling {done}/{total}")
 
 
 
370
 
371
+ if not up_paths:
372
+ return None, None, "Upscaling produced no outputs.", prog_html
373
+
374
+ gallery = sample_paths(up_paths, 30)
375
  zip_path = work / "upscaled.zip"
376
  with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
377
  for p in up_paths:
 
379
 
380
  return gallery, str(zip_path), f"Upscaled: {len(up_paths)}", render_progress(100.0, "Upscaling complete")
381
 
382
+ # ───────────────── Encode (Step 3) — supports uploaded frames/ZIP & optional audio source
383
+
384
+ def prepare_frames_from_upload(files: List[gr.File] | None, prefix: str = "enc") -> Tuple[Optional[str], Optional[str]]:
385
+ if not files:
386
+ return None, None
387
+ work = Path(tempfile.mkdtemp(prefix="enc_"))
388
+ frames_dir = work / "frames"; frames_dir.mkdir(parents=True, exist_ok=True)
389
+ detected_prefix = None
390
+
391
+ # If a single ZIP is uploaded, unzip
392
+ if len(files) == 1 and Path(files[0].name).suffix.lower() == ".zip":
393
+ with zipfile.ZipFile(files[0].name, "r") as zf:
394
+ zf.extractall(frames_dir)
395
+ # try detect a prefix
396
+ imgs = sorted(frames_dir.glob("*.jpg")) + sorted(frames_dir.glob("*.png"))
397
+ if imgs:
398
+ detected_prefix = Path(imgs[0]).stem.split("_")[0]
399
+ return str(frames_dir), detected_prefix or prefix
400
+
401
+ # else, copy images directly
402
+ counter = 1
403
+ for f in files:
404
+ src = Path(f.name)
405
+ if src.suffix.lower() not in [".jpg", ".jpeg", ".png"]:
406
+ continue
407
+ dst = frames_dir / f"{prefix}_{counter:05d}{src.suffix.lower()}"
408
+ shutil.copy2(src, dst)
409
+ counter += 1
410
+ return str(frames_dir), prefix
411
+
412
+
413
+ def build_ffmpeg_encode(frames_dir: str, prefix: str, fps: float, fmt: str, include_audio: bool, orig_video: str | None) -> List[str]:
414
+ pattern_jpg = Path(frames_dir) / f"{prefix}_%05d.jpg"
415
+ pattern_png = Path(frames_dir) / f"{prefix}_%05d.png"
416
+ pattern = str(pattern_jpg if pattern_jpg.exists() else pattern_png)
417
  args = [FFMPEG, "-y", "-start_number", "1", "-framerate", f"{fps:.6f}", "-i", pattern]
418
  if include_audio and orig_video:
419
  args += ["-i", orig_video, "-map", "0:v:0", "-map", "1:a:0", "-shortest"]
 
420
  if fmt == "h265":
421
  vcodec = ["-c:v", "libx265"]
422
  elif fmt == "vp9":
 
429
  return args
430
 
431
 
432
+ def step3_encode(frames_dir_state: str | None, prefix_state: str | None, orig_video: gr.File | None,
433
+ fps: float | None, fmt: str, include_audio: bool, prog_html: str,
434
+ uploaded_frames: List[gr.File] | None, uploaded_audio_video: gr.File | None):
435
+ # Choose frames source: uploaded takes priority
436
+ frames_dir = frames_dir_state
437
+ prefix = prefix_state
438
+ if uploaded_frames and len(uploaded_frames) > 0:
439
+ frames_dir, detected = prepare_frames_from_upload(uploaded_frames, prefix or "enc")
440
+ if detected:
441
+ prefix = detected
442
  if not frames_dir or not prefix:
443
+ return None, None, "No frames available. Upload frames (ZIP/images) or run Step 1.", prog_html
444
+
445
+ fps = float(fps or 30.0)
446
+ orig_path = uploaded_audio_video.name if uploaded_audio_video else (orig_video.name if orig_video else None)
447
 
448
+ cmd = build_ffmpeg_encode(frames_dir, prefix, fps, fmt, include_audio, orig_path)
449
  proc = subprocess.Popen(cmd, stderr=subprocess.PIPE, stdout=subprocess.DEVNULL, text=True, bufsize=1, cwd=frames_dir)
450
 
451
  last_html = prog_html
 
 
 
452
  while True:
453
  line = proc.stderr.readline()
454
  if not line and proc.poll() is not None:
455
  break
 
456
  if int(time.time()*10) % 5 == 0:
457
  last_html = render_progress(50.0, "Encoding…")
458
  ret = proc.wait()
 
463
  err = proc.stderr.read() if proc.stderr else ""
464
  except Exception:
465
  err = ""
466
+ return None, None, f"Encoding failed.
467
+
468
+ {err}", last_html
469
  return str(out_file), f"Video created: {out_file.name}", render_progress(100.0, "Encoding complete")
470
 
471
+ # ───────────────── Quick Mode — one click: All frames → Upscale ×4 → MP4 (audio)
 
 
472
 
473
+ def quick_mode(video: gr.File | None, start_time: str, end_time: str, resize_long: int, prefix_in: str, prog_html: str):
 
474
  if not video or not video.name:
475
  return None, None, None, "Upload a video.", prog_html
476
  if not (FFMPEG and FFPROBE and HAVE_REALESRGAN):
477
+ return None, None, None, "Missing deps (ffmpeg/ffprobe/realesrgan). See requirements.txt.", prog_html
478
 
479
  info = parse_video_info(ffprobe_json(video.name))
480
  in_fps = info.get("fps") or 30.0
481
  prefix = sanitize_prefix(prefix_in) or Path(video.name).stem
482
 
 
483
  work = Path(tempfile.mkdtemp(prefix="quick_"))
484
  raw_dir = work / "frames_raw"; raw_dir.mkdir(parents=True, exist_ok=True)
485
  up_dir = work / "upscaled"; up_dir.mkdir(parents=True, exist_ok=True)
486
 
487
+ # Extract all frames
488
  extract_cmd = build_ffmpeg_extract(
489
  input_path=video.name,
490
  mode="All frames",
 
502
  out_pattern=str(raw_dir / f"{prefix}_%05d.jpg"),
503
  )
504
  proc = subprocess.Popen(extract_cmd, stderr=subprocess.PIPE, stdout=subprocess.DEVNULL, text=True, bufsize=1)
 
505
  est = estimate_output_count("All frames", info.get("duration"), in_fps, 1.0, 1, in_fps)
506
  created = 0
507
  while True:
 
518
  if not frames:
519
  return None, None, None, "No frames extracted in Quick Mode.", prog_html
520
 
521
+ # Upscale x4
522
  device = "cuda" if os.environ.get("CUDA_VISIBLE_DEVICES") else "cpu"
523
  upsampler = get_realesrganer("x4plus", 4, 0, (device=="cuda"), device=device)
524
 
 
532
  pct = (done/total)*100 if total else 0
533
  prog_html = render_progress(pct, f"Phase 2/3: Upscaling {done}/{total}")
534
 
535
+ # Encode MP4 with audio
536
  encode_cmd = build_ffmpeg_encode(str(up_dir), prefix, in_fps, "h264", True, video.name)
537
  proc2 = subprocess.Popen(encode_cmd, stderr=subprocess.PIPE, stdout=subprocess.DEVNULL, text=True, bufsize=1, cwd=str(up_dir))
538
  while True:
 
547
  if not out_file.exists():
548
  return None, None, None, "Encoding failed in Quick Mode.", prog_html
549
 
550
+ # Intermediates
551
  zip_frames = work / "frames.zip"
552
  with zipfile.ZipFile(zip_frames, "w", zipfile.ZIP_DEFLATED) as zf:
553
  for p in frames:
 
559
 
560
  return str(out_file), str(zip_frames), str(zip_up), "Quick Mode complete.", render_progress(100.0, "All done")
561
 
562
+ # ───────────────── UI
 
 
563
 
564
  def build_ui():
565
  with gr.Blocks(theme=gr.themes.Soft(), css="""
 
568
  """) as demo:
569
  gr.Markdown("""
570
  <div class=\"cf-title\">Video → Frames → Upscale → Re-encode</div>
571
+ Three-step workflow + Quick Mode. Step 2/3 now accept your own uploaded files as inputs.
572
  """)
573
 
574
+ # Shared states (from Step 1)
575
  frames_state = gr.State([]) # list[str]
576
  frames_dir_state = gr.State("") # str
577
  prefix_state = gr.State("") # str
578
  fps_state = gr.State(30.0) # float
579
 
580
  with gr.Tabs():
581
+ # STEP 1
582
  with gr.Tab("Step 1 · Extract Frames"):
583
  with gr.Row():
584
  video = gr.File(label="Upload video", file_types=[".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v"], type="filepath")
 
600
  scene_detect = gr.Checkbox(False, label="Scene-change detect")
601
  scene_thresh = gr.Slider(0.0, 1.0, value=0.3, step=0.01, label="Scene threshold")
602
  prefix_vid = gr.Textbox(value="", label="Filename prefix (defaults to input file name)")
 
603
  estimate_md = gr.Markdown("Estimated output: —")
 
604
  with gr.Row():
605
  btn_extract = gr.Button("Step 1: Extract Frames", variant="primary")
 
 
 
 
606
  prog1 = gr.HTML(render_progress(0.0, "Idle"))
607
  gallery = gr.Gallery(label="Preview (30 sampled)", columns=6, height=480)
608
  zip_out = gr.File(label="Download frames ZIP")
 
616
  if not vfile or not getattr(vfile, 'name', None):
617
  return "Estimated output: —"
618
  info = parse_video_info(ffprobe_json(vfile.name))
 
619
  dur = info.get("duration")
620
  def parse_ts(ts: str):
621
+ if not ts: return 0.0
 
622
  parts = ts.split(":")
623
  if len(parts) == 3:
624
+ try: return float(parts[0])*3600 + float(parts[1])*60 + float(parts[2])
625
+ except Exception: return 0.0
 
 
626
  return 0.0
627
  st_s = parse_ts(st or ""); et_s = parse_ts(et or "")
628
  if dur:
 
630
  if et_s and et_s < info.get("duration", 0) and et_s > 0:
631
  dur = min(dur, et_s)
632
  est = estimate_output_count(mode_val, dur, info.get("fps"), evs or 1.0, int(nth or 1), exfps or 1.0)
633
+ return f"Estimated output: **~{est} frames**" if est else "Estimated output: —"
 
 
634
 
635
  for ctrl in [video, mode, every_seconds, nth_frame, exact_fps, start_time, end_time]:
636
  ctrl.change(update_estimate, inputs=[video, mode, every_seconds, nth_frame, exact_fps, start_time, end_time], outputs=[estimate_md])
 
646
  outputs=[gallery, zip_out, details1, cmd_preview, prog1, frames_state, frames_dir_state, prefix_state],
647
  )
648
 
649
+ # STEP 2 Upscale
650
+ with gr.Tab("Step 2 · Upscale Frames"):
651
  if not HAVE_REALESRGAN:
652
+ gr.Markdown("⚠️ Upscaling disabled. Install dependencies in requirements.txt (see notes in code). Error: " + (REAL_ERR if 'REAL_ERR' in globals() else ""))
653
+ gr.Markdown("Use frames from Step 1 **or** upload images below.")
654
+ imgs_override = gr.Files(label="Upload images to upscale (JPG/PNG)", file_types=[".jpg", ".jpeg", ".png"], type="filepath")
655
  with gr.Row():
656
  model_name = gr.Dropdown(["x4plus", "x4plus-anime", "x2plus"], value="x4plus", label="Model")
657
  scale = gr.Dropdown([2, 4], value=4, label="Output scale")
658
  tile = gr.Number(value=0, label="Tile size (0 = auto)")
659
  precision = gr.Dropdown(["auto", "half", "full"], value="auto", label="Precision (GPU=half, CPU=full)")
660
  with gr.Row():
661
+ btn_upscale = gr.Button("Step 2: Upscale", variant="primary")
 
662
  prog2 = gr.HTML(render_progress(0.0, "Idle"))
663
  gallery_up = gr.Gallery(label="Upscaled preview (30 sampled)", columns=6, height=480)
664
  zip_up = gr.File(label="Download upscaled ZIP")
665
  details2 = gr.Markdown("")
666
 
 
 
 
667
  btn_upscale.click(
668
  step2_upscale,
669
+ inputs=[frames_state, model_name, scale, tile, precision, prog2, imgs_override],
670
  outputs=[gallery_up, zip_up, details2, prog2],
671
  )
672
 
673
+ # STEP 3 Re-encode
674
  with gr.Tab("Step 3 · Re-encode Video"):
675
+ gr.Markdown("Use frames from Step 1 **or** upload a frames ZIP / images. Optionally provide a video for audio track.")
676
+ uploaded_frames = gr.Files(label="Upload frames (ZIP or images)", type="filepath")
677
+ uploaded_audio = gr.File(label="Optional: video/audio source for audio track", file_types=[".mp4", ".mov", ".mkv", ".webm", ".mp3", ".wav"], type="filepath")
678
  with gr.Row():
679
  fmt = gr.Dropdown(["h264", "h265", "vp9"], value="h264", label="Format")
680
+ include_audio = gr.Checkbox(True, label="Include audio if available")
681
  with gr.Row():
682
  btn_encode = gr.Button("Step 3: Create Video", variant="primary")
 
683
  prog3 = gr.HTML(render_progress(0.0, "Idle"))
684
  video_player = gr.Video(label="Preview video")
685
  details3 = gr.Markdown("")
686
 
 
687
  def set_fps(vfile):
688
  if not vfile or not getattr(vfile, 'name', None):
689
  return 30.0
690
  info = parse_video_info(ffprobe_json(vfile.name))
691
  return float(info.get("fps") or 30.0)
692
+ # capture FPS from the original step1 video when it changes
693
  video.change(set_fps, inputs=[video], outputs=[fps_state])
694
 
695
  btn_encode.click(
696
  step3_encode,
697
+ inputs=[frames_dir_state, prefix_state, video, fps_state, fmt, include_audio, prog3, uploaded_frames, uploaded_audio],
698
  outputs=[video_player, details3, prog3],
699
  )
700
 
701
+ # QUICK MODE
702
  with gr.Tab("⚡ Quick Mode"):
703
  gr.Markdown("Extract ALL frames → Upscale ×4 → MP4 (H.264) with original audio. No toggles.")
704
  with gr.Row():
 
728
 
729
  if __name__ == "__main__":
730
  demo = build_ui()
731
+ demo.queue().launch()