Spaces:
Running
on
Zero
Running
on
Zero
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
-
# app.py — FLUX-only with temporal chaining + Aggressive follow + Video stitching (backend + ffmpeg)
|
| 2 |
-
import os, json, uuid, re, tempfile, subprocess, shlex
|
| 3 |
from datetime import datetime
|
| 4 |
|
| 5 |
import gradio as gr
|
|
@@ -403,7 +403,7 @@ def generate_keyframe_image(
|
|
| 403 |
return saved_path
|
| 404 |
|
| 405 |
# =========================
|
| 406 |
-
# Video stitching helpers (backend per pair + ffmpeg concat)
|
| 407 |
# =========================
|
| 408 |
def _pair_clip_path(pid: str, i: int, j: int) -> str:
|
| 409 |
return os.path.join(project_dir(pid), "clips", f"pair_{i:02d}_to_{j:02d}.mp4")
|
|
@@ -411,7 +411,7 @@ def _pair_clip_path(pid: str, i: int, j: int) -> str:
|
|
| 411 |
def _final_stitched_path(pid: str) -> str:
|
| 412 |
return os.path.join(project_dir(pid), "clips", "final_stitched.mp4")
|
| 413 |
|
| 414 |
-
def _call_i2v_backend(img_a_path: str, img_b_path: str, prompt: str, seed
|
| 415 |
"""
|
| 416 |
Calls Modal backend with two images to get a transition clip (mp4 bytes).
|
| 417 |
"""
|
|
@@ -419,7 +419,10 @@ def _call_i2v_backend(img_a_path: str, img_b_path: str, prompt: str, seed: int |
|
|
| 419 |
if prompt:
|
| 420 |
params["prompt"] = prompt
|
| 421 |
if seed is not None:
|
| 422 |
-
|
|
|
|
|
|
|
|
|
|
| 423 |
|
| 424 |
with open(img_a_path, "rb") as fa, open(img_b_path, "rb") as fb:
|
| 425 |
files = {
|
|
@@ -431,7 +434,7 @@ def _call_i2v_backend(img_a_path: str, img_b_path: str, prompt: str, seed: int |
|
|
| 431 |
raise gr.Error(f"I2V backend error {r.status_code}: {r.text[:400]}")
|
| 432 |
return r.content
|
| 433 |
|
| 434 |
-
def _build_all_pair_videos_backend(pid: str, shots: list, endpoint: str, prompt: str, seed
|
| 435 |
out_paths = []
|
| 436 |
for k in range(len(shots) - 1):
|
| 437 |
a = shots[k].get("image_path")
|
|
@@ -445,35 +448,138 @@ def _build_all_pair_videos_backend(pid: str, shots: list, endpoint: str, prompt:
|
|
| 445 |
out_paths.append(outp)
|
| 446 |
return out_paths
|
| 447 |
|
| 448 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
if not mp4_paths:
|
| 450 |
-
raise gr.Error("No clips to
|
| 451 |
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
for p in mp4_paths:
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
finally:
|
| 475 |
-
try:
|
| 476 |
-
|
|
|
|
|
|
|
| 477 |
|
| 478 |
# =========================
|
| 479 |
# Shots <-> DataFrame utils
|
|
@@ -723,7 +829,7 @@ with gr.Blocks() as demo:
|
|
| 723 |
|
| 724 |
approve_next_btn.click(on_approve_next, inputs=[project, current_idx, prompt_box, out_img], outputs=[project, current_idx, shot_info_md, prompt_box, prev_img, out_img, kf_status])
|
| 725 |
|
| 726 |
-
# ---- Videos tab handlers (backend + ffmpeg)
|
| 727 |
def on_build_pairs(p, fps, hold, xfade):
|
| 728 |
if p is None:
|
| 729 |
raise gr.Error("No project.")
|
|
@@ -764,7 +870,7 @@ with gr.Blocks() as demo:
|
|
| 764 |
if not pair_paths:
|
| 765 |
raise gr.Error("No pair clips found. Click 'Build pair clips' first.")
|
| 766 |
outp = _final_stitched_path(pid)
|
| 767 |
-
|
| 768 |
return {"pair_clips": pair_paths, "final": outp}
|
| 769 |
|
| 770 |
build_final_btn.click(
|
|
|
|
| 1 |
+
# app.py — FLUX-only with temporal chaining + Aggressive follow + Video stitching (backend + robust ffmpeg concat)
|
| 2 |
+
import os, json, uuid, re, tempfile, subprocess, shlex, shutil, json as _json
|
| 3 |
from datetime import datetime
|
| 4 |
|
| 5 |
import gradio as gr
|
|
|
|
| 403 |
return saved_path
|
| 404 |
|
| 405 |
# =========================
|
| 406 |
+
# Video stitching helpers (backend per pair + robust ffmpeg concat)
|
| 407 |
# =========================
|
| 408 |
def _pair_clip_path(pid: str, i: int, j: int) -> str:
|
| 409 |
return os.path.join(project_dir(pid), "clips", f"pair_{i:02d}_to_{j:02d}.mp4")
|
|
|
|
| 411 |
def _final_stitched_path(pid: str) -> str:
|
| 412 |
return os.path.join(project_dir(pid), "clips", "final_stitched.mp4")
|
| 413 |
|
| 414 |
+
def _call_i2v_backend(img_a_path: str, img_b_path: str, prompt: str, seed, endpoint: str) -> bytes:
|
| 415 |
"""
|
| 416 |
Calls Modal backend with two images to get a transition clip (mp4 bytes).
|
| 417 |
"""
|
|
|
|
| 419 |
if prompt:
|
| 420 |
params["prompt"] = prompt
|
| 421 |
if seed is not None:
|
| 422 |
+
try:
|
| 423 |
+
params["seed"] = str(int(seed))
|
| 424 |
+
except Exception:
|
| 425 |
+
pass
|
| 426 |
|
| 427 |
with open(img_a_path, "rb") as fa, open(img_b_path, "rb") as fb:
|
| 428 |
files = {
|
|
|
|
| 434 |
raise gr.Error(f"I2V backend error {r.status_code}: {r.text[:400]}")
|
| 435 |
return r.content
|
| 436 |
|
| 437 |
+
def _build_all_pair_videos_backend(pid: str, shots: list, endpoint: str, prompt: str, seed) -> list[str]:
|
| 438 |
out_paths = []
|
| 439 |
for k in range(len(shots) - 1):
|
| 440 |
a = shots[k].get("image_path")
|
|
|
|
| 448 |
out_paths.append(outp)
|
| 449 |
return out_paths
|
| 450 |
|
| 451 |
+
# ---------- robust concat (normalize + concat demuxer; fallback re-encode once)
|
| 452 |
+
def _ffprobe_stream(path):
|
| 453 |
+
ffmpeg = imageio_ffmpeg.get_ffmpeg_exe()
|
| 454 |
+
ffprobe = ffmpeg.replace("ffmpeg", "ffprobe")
|
| 455 |
+
cmd = [
|
| 456 |
+
ffprobe,
|
| 457 |
+
"-v", "error",
|
| 458 |
+
"-select_streams", "v:0",
|
| 459 |
+
"-show_entries", "stream=width,height,avg_frame_rate,pix_fmt,codec_name",
|
| 460 |
+
"-of", "json",
|
| 461 |
+
path,
|
| 462 |
+
]
|
| 463 |
+
try:
|
| 464 |
+
out = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
|
| 465 |
+
data = _json.loads(out.decode("utf-8"))
|
| 466 |
+
return (data.get("streams") or [{}])[0]
|
| 467 |
+
except Exception:
|
| 468 |
+
return {}
|
| 469 |
+
|
| 470 |
+
def _ffmpeg_safe_concat(mp4_paths: list[str], out_path: str, fps: int = 24, size=None):
|
| 471 |
if not mp4_paths:
|
| 472 |
+
raise gr.Error("No clips to stitch.")
|
| 473 |
|
| 474 |
+
clips_dir = os.path.dirname(out_path)
|
| 475 |
+
os.makedirs(clips_dir, exist_ok=True)
|
| 476 |
+
log_path = os.path.join(clips_dir, "ffmpeg_concat.log")
|
| 477 |
+
ffmpeg = imageio_ffmpeg.get_ffmpeg_exe()
|
| 478 |
+
|
| 479 |
+
# Determine reference WxH
|
| 480 |
+
ref_w, ref_h = None, None
|
| 481 |
+
if size and isinstance(size, (tuple, list)) and len(size) == 2:
|
| 482 |
+
ref_w, ref_h = int(size[0]), int(size[1])
|
| 483 |
+
else:
|
| 484 |
for p in mp4_paths:
|
| 485 |
+
st = _ffprobe_stream(p)
|
| 486 |
+
if st.get("width") and st.get("height"):
|
| 487 |
+
ref_w, ref_h = int(st["width"]), int(st["height"])
|
| 488 |
+
break
|
| 489 |
+
if ref_w and ref_h:
|
| 490 |
+
if ref_w % 2: ref_w += 1
|
| 491 |
+
if ref_h % 2: ref_h += 1
|
| 492 |
+
|
| 493 |
+
norm_paths = []
|
| 494 |
+
tmpdir = tempfile.mkdtemp(prefix="norm_")
|
| 495 |
+
try:
|
| 496 |
+
# Normalize each clip to consistent fps/size/codec/pixfmt
|
| 497 |
+
for i, inp in enumerate(mp4_paths):
|
| 498 |
+
if not os.path.exists(inp):
|
| 499 |
+
continue
|
| 500 |
+
norm = os.path.join(tmpdir, f"norm_{i:03d}.mp4")
|
| 501 |
+
|
| 502 |
+
vf = []
|
| 503 |
+
if ref_w and ref_h:
|
| 504 |
+
vf.append(f"scale=w={ref_w}:h={ref_h}:force_original_aspect_ratio=decrease")
|
| 505 |
+
vf.append(f"pad={ref_w}:{ref_h}:(ow-iw)/2:(oh-ih)/2:color=black")
|
| 506 |
+
vf.append(f"fps={int(fps)}")
|
| 507 |
+
vf_arg = ",".join(vf)
|
| 508 |
+
|
| 509 |
+
cmd = [
|
| 510 |
+
ffmpeg, "-y", "-i", inp,
|
| 511 |
+
"-vf", vf_arg,
|
| 512 |
+
"-an",
|
| 513 |
+
"-c:v", "libx264",
|
| 514 |
+
"-pix_fmt", "yuv420p",
|
| 515 |
+
"-profile:v", "main",
|
| 516 |
+
"-preset", "veryfast",
|
| 517 |
+
"-crf", "18",
|
| 518 |
+
"-movflags", "+faststart",
|
| 519 |
+
norm
|
| 520 |
+
]
|
| 521 |
+
with open(log_path, "ab") as lg:
|
| 522 |
+
lg.write(("NORMALIZE: " + " ".join(cmd) + "\n").encode())
|
| 523 |
+
subprocess.check_call(cmd, stdout=lg, stderr=lg)
|
| 524 |
+
norm_paths.append(norm)
|
| 525 |
+
|
| 526 |
+
if not norm_paths:
|
| 527 |
+
raise gr.Error("No inputs could be normalized for concat.")
|
| 528 |
+
|
| 529 |
+
# concat demuxer (stream copy)
|
| 530 |
+
listfile = os.path.join(tmpdir, "list.txt")
|
| 531 |
+
with open(listfile, "w") as f:
|
| 532 |
+
for p in norm_paths:
|
| 533 |
+
f.write(f"file '{p}'\n")
|
| 534 |
+
|
| 535 |
+
cmd_concat_copy = [
|
| 536 |
+
ffmpeg, "-y",
|
| 537 |
+
"-f", "concat", "-safe", "0", "-i", listfile,
|
| 538 |
+
"-c", "copy",
|
| 539 |
+
out_path
|
| 540 |
+
]
|
| 541 |
+
try:
|
| 542 |
+
with open(log_path, "ab") as lg:
|
| 543 |
+
lg.write(("CONCAT_COPY: " + " ".join(cmd_concat_copy) + "\n").encode())
|
| 544 |
+
subprocess.check_call(cmd_concat_copy, stdout=lg, stderr=lg)
|
| 545 |
+
return out_path
|
| 546 |
+
except subprocess.CalledProcessError:
|
| 547 |
+
pass # fallback
|
| 548 |
+
|
| 549 |
+
# filter_complex concat (re-encode once)
|
| 550 |
+
cmd = [ffmpeg, "-y"]
|
| 551 |
+
for p in norm_paths:
|
| 552 |
+
cmd += ["-i", p]
|
| 553 |
+
n = len(norm_paths)
|
| 554 |
+
inputs = "".join([f"[{i}:v]" for i in range(n)])
|
| 555 |
+
filtergraph = f"{inputs}concat=n={n}:v=1:a=0[outv]"
|
| 556 |
+
cmd += [
|
| 557 |
+
"-filter_complex", filtergraph,
|
| 558 |
+
"-map", "[outv]",
|
| 559 |
+
"-an",
|
| 560 |
+
"-c:v", "libx264",
|
| 561 |
+
"-pix_fmt", "yuv420p",
|
| 562 |
+
"-profile:v", "main",
|
| 563 |
+
"-preset", "veryfast",
|
| 564 |
+
"-crf", "18",
|
| 565 |
+
"-r", str(int(fps)),
|
| 566 |
+
"-movflags", "+faststart",
|
| 567 |
+
out_path
|
| 568 |
+
]
|
| 569 |
+
with open(log_path, "ab") as lg:
|
| 570 |
+
lg.write(("CONCAT_REENC: " + " ".join(cmd) + "\n").encode())
|
| 571 |
+
subprocess.check_call(cmd, stdout=lg, stderr=lg)
|
| 572 |
+
return out_path
|
| 573 |
+
|
| 574 |
+
except subprocess.CalledProcessError:
|
| 575 |
+
raise gr.Error("ffmpeg concat failed (copy and re-encode). See logs at: " + log_path)
|
| 576 |
+
except Exception as e:
|
| 577 |
+
raise gr.Error(f"Concat error: {e}")
|
| 578 |
finally:
|
| 579 |
+
try:
|
| 580 |
+
shutil.rmtree(tmpdir)
|
| 581 |
+
except Exception:
|
| 582 |
+
pass
|
| 583 |
|
| 584 |
# =========================
|
| 585 |
# Shots <-> DataFrame utils
|
|
|
|
| 829 |
|
| 830 |
approve_next_btn.click(on_approve_next, inputs=[project, current_idx, prompt_box, out_img], outputs=[project, current_idx, shot_info_md, prompt_box, prev_img, out_img, kf_status])
|
| 831 |
|
| 832 |
+
# ---- Videos tab handlers (backend + robust ffmpeg)
|
| 833 |
def on_build_pairs(p, fps, hold, xfade):
|
| 834 |
if p is None:
|
| 835 |
raise gr.Error("No project.")
|
|
|
|
| 870 |
if not pair_paths:
|
| 871 |
raise gr.Error("No pair clips found. Click 'Build pair clips' first.")
|
| 872 |
outp = _final_stitched_path(pid)
|
| 873 |
+
_ffmpeg_safe_concat(pair_paths, outp, fps=int(fps), size=None) # set size=(640,640) to force letterbox
|
| 874 |
return {"pair_clips": pair_paths, "final": outp}
|
| 875 |
|
| 876 |
build_final_btn.click(
|