Update app.py
Browse files
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
|
| 5 |
-
# - Step 2: Upscale
|
| 6 |
-
# - Step 3: Re-encode frames
|
| 7 |
-
# - Quick Mode: One-click
|
| 8 |
-
# - Previews
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 294 |
else:
|
| 295 |
-
last_html = render_progress(0.0, f"Extracting
|
| 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
|
|
|
|
|
|
|
| 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
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 341 |
-
|
| 342 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 353 |
done = 0
|
| 354 |
-
|
| 355 |
-
|
| 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 |
-
|
| 364 |
-
except Exception
|
| 365 |
-
|
| 366 |
-
|
| 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 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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
|
| 407 |
-
|
|
|
|
|
|
|
| 408 |
|
| 409 |
-
cmd = build_ffmpeg_encode(frames_dir, prefix, fps, fmt, include_audio,
|
| 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
|
|
|
|
|
|
|
| 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
|
| 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 |
-
#
|
| 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 |
-
#
|
| 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 |
-
#
|
| 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 |
-
#
|
| 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
|
| 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 |
-
#
|
| 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 |
-
|
| 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
|
| 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 |
-
#
|
| 632 |
-
with gr.Tab("Step 2 · Upscale
|
| 633 |
if not HAVE_REALESRGAN:
|
| 634 |
-
gr.Markdown("⚠️ Install
|
|
|
|
|
|
|
| 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
|
| 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,
|
| 654 |
outputs=[gallery_up, zip_up, details2, prog2],
|
| 655 |
)
|
| 656 |
|
| 657 |
-
#
|
| 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
|
| 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 |
-
#
|
| 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()
|
|
|