Spaces:
Running
Running
Add Extend prompt + custom start image controls (app.py)
Browse files
app.py
CHANGED
|
@@ -33,6 +33,9 @@ DEFAULT_NEGATIVE = (
|
|
| 33 |
"静止不动的画面, 杂乱的背景, 三条腿, 背景人很多, 倒着走"
|
| 34 |
)
|
| 35 |
|
|
|
|
|
|
|
|
|
|
| 36 |
WORK = Path(tempfile.gettempdir()) / "wan22_extend"
|
| 37 |
WORK.mkdir(parents=True, exist_ok=True)
|
| 38 |
|
|
@@ -261,6 +264,41 @@ def estimate_total(seg_duration: float, num_segments: int) -> float:
|
|
| 261 |
return float(seg_duration) * int(num_segments)
|
| 262 |
|
| 263 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
def do_generate(
|
| 265 |
image,
|
| 266 |
prompt,
|
|
@@ -325,6 +363,9 @@ def do_generate(
|
|
| 325 |
|
| 326 |
def do_extend(
|
| 327 |
prompt,
|
|
|
|
|
|
|
|
|
|
| 328 |
seg_duration,
|
| 329 |
steps,
|
| 330 |
negative,
|
|
@@ -336,22 +377,20 @@ def do_extend(
|
|
| 336 |
state: dict,
|
| 337 |
progress=gr.Progress(track_tqdm=False),
|
| 338 |
):
|
| 339 |
-
if not state or not state.get("video")
|
| 340 |
raise gr.Error("Generate a clip first, then use Extend.")
|
| 341 |
if len(state.get("segments", [])) >= 6:
|
| 342 |
raise gr.Error("Segment cap reached (6). Reset and start a new chain.")
|
| 343 |
|
| 344 |
sess = Path(state["dir"])
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
last = extract_last_frame(state["video"], sess / "last_frame")
|
| 348 |
-
state["last_frame"] = str(last)
|
| 349 |
|
| 350 |
-
|
| 351 |
-
progress(0.05, desc="Extending
|
| 352 |
try:
|
| 353 |
clip = call_wan_i2v(
|
| 354 |
-
|
| 355 |
use_prompt,
|
| 356 |
float(seg_duration),
|
| 357 |
int(steps),
|
|
@@ -373,6 +412,8 @@ def do_extend(
|
|
| 373 |
except Exception as e:
|
| 374 |
raise gr.Error(f"Concat failed (ffmpeg): {e}") from e
|
| 375 |
|
|
|
|
|
|
|
| 376 |
last = extract_last_frame(out, sess / "last_frame")
|
| 377 |
dur = video_duration_seconds(out)
|
| 378 |
state["segments"] = [str(p) for p in segs]
|
|
@@ -392,6 +433,9 @@ def do_extend(
|
|
| 392 |
def do_auto_extend(
|
| 393 |
target_seconds,
|
| 394 |
prompt,
|
|
|
|
|
|
|
|
|
|
| 395 |
seg_duration,
|
| 396 |
steps,
|
| 397 |
negative,
|
|
@@ -414,6 +458,7 @@ def do_auto_extend(
|
|
| 414 |
state.get("last_frame"),
|
| 415 |
state,
|
| 416 |
)
|
|
|
|
| 417 |
while len(state.get("segments", [])) < max_segs:
|
| 418 |
dur = video_duration_seconds(state["video"])
|
| 419 |
if dur >= target:
|
|
@@ -422,8 +467,15 @@ def do_auto_extend(
|
|
| 422 |
len(state["segments"]) / max_segs,
|
| 423 |
desc=f"Auto-extend: {dur:.1f}s / {target:.0f}s…",
|
| 424 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
video, download, status, last_preview, state = do_extend(
|
| 426 |
prompt,
|
|
|
|
|
|
|
|
|
|
| 427 |
seg_duration,
|
| 428 |
steps,
|
| 429 |
negative,
|
|
@@ -435,6 +487,7 @@ def do_auto_extend(
|
|
| 435 |
state,
|
| 436 |
progress,
|
| 437 |
)
|
|
|
|
| 438 |
time.sleep(0.3)
|
| 439 |
return video, download, status, last_preview, state
|
| 440 |
|
|
@@ -453,6 +506,11 @@ def update_estimate(seg_duration, num_planned):
|
|
| 453 |
return f"Planned length ≈ **{total:.1f}s** ({num_planned} × {seg_duration:.1f}s segments). Cap: 6 segments."
|
| 454 |
|
| 455 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
CSS = """
|
| 457 |
.big-btn button { font-size: 1.15rem !important; min-height: 3rem !important; }
|
| 458 |
#status-md { font-size: 1rem; }
|
|
@@ -465,7 +523,7 @@ def build_ui():
|
|
| 465 |
gr.Markdown(
|
| 466 |
"""
|
| 467 |
# Wan 2.2 Image → Video + Extend
|
| 468 |
-
Upload a still, generate a short Wan 2.2 clip on **free ZeroGPU**, then **Extend** from the last frame to chain ~10–20s videos.
|
| 469 |
|
| 470 |
ZeroGPU is free but **queued and quota-limited** — each segment often takes 1–3 minutes when busy.
|
| 471 |
"""
|
|
@@ -476,11 +534,34 @@ ZeroGPU is free but **queued and quota-limited** — each segment often takes 1
|
|
| 476 |
with gr.Column(scale=1):
|
| 477 |
image = gr.Image(label="Still image", type="pil", height=320)
|
| 478 |
prompt = gr.Textbox(
|
| 479 |
-
label="
|
| 480 |
value=DEFAULT_PROMPT,
|
| 481 |
lines=3,
|
| 482 |
placeholder="Describe the motion you want…",
|
| 483 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
with gr.Row():
|
| 485 |
seg_duration = gr.Slider(
|
| 486 |
2.0,
|
|
@@ -543,6 +624,7 @@ ZeroGPU is free but **queued and quota-limited** — each segment often takes 1
|
|
| 543 |
|
| 544 |
seg_duration.change(update_estimate, [seg_duration, num_planned], estimate)
|
| 545 |
num_planned.change(update_estimate, [seg_duration, num_planned], estimate)
|
|
|
|
| 546 |
|
| 547 |
gen_inputs = [
|
| 548 |
image,
|
|
@@ -559,6 +641,9 @@ ZeroGPU is free but **queued and quota-limited** — each segment often takes 1
|
|
| 559 |
]
|
| 560 |
ext_inputs = [
|
| 561 |
prompt,
|
|
|
|
|
|
|
|
|
|
| 562 |
seg_duration,
|
| 563 |
steps,
|
| 564 |
negative,
|
|
@@ -583,8 +668,8 @@ ZeroGPU is free but **queued and quota-limited** — each segment often takes 1
|
|
| 583 |
gr.Markdown(
|
| 584 |
f"""
|
| 585 |
### How Extend works
|
| 586 |
-
1. **Generate** — still image + prompt → short Wan 2.2 I2V clip (~{3.5}s default).
|
| 587 |
-
2. **Extend** —
|
| 588 |
3. Repeat until you hit ~10–20s (or use **Auto-extend**). Soft cap: **6 segments**.
|
| 589 |
|
| 590 |
### Limits
|
|
|
|
| 33 |
"静止不动的画面, 杂乱的背景, 三条腿, 背景人很多, 倒着走"
|
| 34 |
)
|
| 35 |
|
| 36 |
+
EXTEND_START_LAST_FRAME = "Last frame from video"
|
| 37 |
+
EXTEND_START_UPLOAD = "Upload custom image"
|
| 38 |
+
|
| 39 |
WORK = Path(tempfile.gettempdir()) / "wan22_extend"
|
| 40 |
WORK.mkdir(parents=True, exist_ok=True)
|
| 41 |
|
|
|
|
| 264 |
return float(seg_duration) * int(num_segments)
|
| 265 |
|
| 266 |
|
| 267 |
+
def resolve_extend_prompt(extend_prompt: str | None, generate_prompt: str | None, state: dict) -> str:
|
| 268 |
+
"""Prefer dedicated Extend prompt; else Generate prompt / saved state / default."""
|
| 269 |
+
if extend_prompt and str(extend_prompt).strip():
|
| 270 |
+
return str(extend_prompt).strip()
|
| 271 |
+
if generate_prompt and str(generate_prompt).strip():
|
| 272 |
+
return str(generate_prompt).strip()
|
| 273 |
+
return state.get("prompt") or DEFAULT_PROMPT
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def resolve_extend_start_image(
|
| 277 |
+
extend_start: str,
|
| 278 |
+
custom_image,
|
| 279 |
+
state: dict,
|
| 280 |
+
sess: Path,
|
| 281 |
+
) -> Path:
|
| 282 |
+
"""Pick I2V start image for an Extend segment."""
|
| 283 |
+
mode = (extend_start or EXTEND_START_LAST_FRAME).strip()
|
| 284 |
+
if mode == EXTEND_START_UPLOAD:
|
| 285 |
+
if custom_image is None:
|
| 286 |
+
raise gr.Error("Upload a custom extend start image, or switch to “Last frame from video”.")
|
| 287 |
+
saved = save_image(custom_image, sess / f"extend_custom_{uuid.uuid4().hex[:8]}")
|
| 288 |
+
if saved is None:
|
| 289 |
+
raise gr.Error("Could not read the custom extend start image.")
|
| 290 |
+
return saved
|
| 291 |
+
|
| 292 |
+
# Default: last frame from current video
|
| 293 |
+
last = Path(state["last_frame"]) if state.get("last_frame") else None
|
| 294 |
+
if last is None or not last.exists():
|
| 295 |
+
if not state.get("video"):
|
| 296 |
+
raise gr.Error("Generate a clip first, then use Extend.")
|
| 297 |
+
last = extract_last_frame(state["video"], sess / "last_frame")
|
| 298 |
+
state["last_frame"] = str(last)
|
| 299 |
+
return last
|
| 300 |
+
|
| 301 |
+
|
| 302 |
def do_generate(
|
| 303 |
image,
|
| 304 |
prompt,
|
|
|
|
| 363 |
|
| 364 |
def do_extend(
|
| 365 |
prompt,
|
| 366 |
+
extend_prompt,
|
| 367 |
+
extend_start,
|
| 368 |
+
custom_extend_image,
|
| 369 |
seg_duration,
|
| 370 |
steps,
|
| 371 |
negative,
|
|
|
|
| 377 |
state: dict,
|
| 378 |
progress=gr.Progress(track_tqdm=False),
|
| 379 |
):
|
| 380 |
+
if not state or not state.get("video"):
|
| 381 |
raise gr.Error("Generate a clip first, then use Extend.")
|
| 382 |
if len(state.get("segments", [])) >= 6:
|
| 383 |
raise gr.Error("Segment cap reached (6). Reset and start a new chain.")
|
| 384 |
|
| 385 |
sess = Path(state["dir"])
|
| 386 |
+
start_img = resolve_extend_start_image(extend_start, custom_extend_image, state, sess)
|
| 387 |
+
use_prompt = resolve_extend_prompt(extend_prompt, prompt, state)
|
|
|
|
|
|
|
| 388 |
|
| 389 |
+
mode_label = (extend_start or EXTEND_START_LAST_FRAME).strip()
|
| 390 |
+
progress(0.05, desc=f"Extending ({mode_label})…")
|
| 391 |
try:
|
| 392 |
clip = call_wan_i2v(
|
| 393 |
+
start_img,
|
| 394 |
use_prompt,
|
| 395 |
float(seg_duration),
|
| 396 |
int(steps),
|
|
|
|
| 412 |
except Exception as e:
|
| 413 |
raise gr.Error(f"Concat failed (ffmpeg): {e}") from e
|
| 414 |
|
| 415 |
+
# Always refresh last_frame from the new concatenated video so later
|
| 416 |
+
# “Last frame from video” extends stay correct.
|
| 417 |
last = extract_last_frame(out, sess / "last_frame")
|
| 418 |
dur = video_duration_seconds(out)
|
| 419 |
state["segments"] = [str(p) for p in segs]
|
|
|
|
| 433 |
def do_auto_extend(
|
| 434 |
target_seconds,
|
| 435 |
prompt,
|
| 436 |
+
extend_prompt,
|
| 437 |
+
extend_start,
|
| 438 |
+
custom_extend_image,
|
| 439 |
seg_duration,
|
| 440 |
steps,
|
| 441 |
negative,
|
|
|
|
| 458 |
state.get("last_frame"),
|
| 459 |
state,
|
| 460 |
)
|
| 461 |
+
first = True
|
| 462 |
while len(state.get("segments", [])) < max_segs:
|
| 463 |
dur = video_duration_seconds(state["video"])
|
| 464 |
if dur >= target:
|
|
|
|
| 467 |
len(state["segments"]) / max_segs,
|
| 468 |
desc=f"Auto-extend: {dur:.1f}s / {target:.0f}s…",
|
| 469 |
)
|
| 470 |
+
# Custom image only applies to the first auto-extend segment;
|
| 471 |
+
# later segments chain from the updated video last frame.
|
| 472 |
+
this_start = extend_start if first else EXTEND_START_LAST_FRAME
|
| 473 |
+
this_custom = custom_extend_image if first else None
|
| 474 |
video, download, status, last_preview, state = do_extend(
|
| 475 |
prompt,
|
| 476 |
+
extend_prompt,
|
| 477 |
+
this_start,
|
| 478 |
+
this_custom,
|
| 479 |
seg_duration,
|
| 480 |
steps,
|
| 481 |
negative,
|
|
|
|
| 487 |
state,
|
| 488 |
progress,
|
| 489 |
)
|
| 490 |
+
first = False
|
| 491 |
time.sleep(0.3)
|
| 492 |
return video, download, status, last_preview, state
|
| 493 |
|
|
|
|
| 506 |
return f"Planned length ≈ **{total:.1f}s** ({num_planned} × {seg_duration:.1f}s segments). Cap: 6 segments."
|
| 507 |
|
| 508 |
|
| 509 |
+
def toggle_custom_extend_image(mode: str):
|
| 510 |
+
visible = (mode or "").strip() == EXTEND_START_UPLOAD
|
| 511 |
+
return gr.update(visible=visible)
|
| 512 |
+
|
| 513 |
+
|
| 514 |
CSS = """
|
| 515 |
.big-btn button { font-size: 1.15rem !important; min-height: 3rem !important; }
|
| 516 |
#status-md { font-size: 1rem; }
|
|
|
|
| 523 |
gr.Markdown(
|
| 524 |
"""
|
| 525 |
# Wan 2.2 Image → Video + Extend
|
| 526 |
+
Upload a still, generate a short Wan 2.2 clip on **free ZeroGPU**, then **Extend** from the last frame (or a custom start image) to chain ~10–20s videos.
|
| 527 |
|
| 528 |
ZeroGPU is free but **queued and quota-limited** — each segment often takes 1–3 minutes when busy.
|
| 529 |
"""
|
|
|
|
| 534 |
with gr.Column(scale=1):
|
| 535 |
image = gr.Image(label="Still image", type="pil", height=320)
|
| 536 |
prompt = gr.Textbox(
|
| 537 |
+
label="Generate prompt",
|
| 538 |
value=DEFAULT_PROMPT,
|
| 539 |
lines=3,
|
| 540 |
placeholder="Describe the motion you want…",
|
| 541 |
)
|
| 542 |
+
|
| 543 |
+
with gr.Accordion("Extend options", open=True):
|
| 544 |
+
extend_prompt = gr.Textbox(
|
| 545 |
+
label="Extend prompt",
|
| 546 |
+
value="",
|
| 547 |
+
lines=3,
|
| 548 |
+
placeholder="Leave blank to reuse Generate prompt",
|
| 549 |
+
info="Optional. Used only for Extend / Auto-extend.",
|
| 550 |
+
)
|
| 551 |
+
extend_start = gr.Radio(
|
| 552 |
+
choices=[EXTEND_START_LAST_FRAME, EXTEND_START_UPLOAD],
|
| 553 |
+
value=EXTEND_START_LAST_FRAME,
|
| 554 |
+
label="Extend start",
|
| 555 |
+
info="Default keeps current behavior (auto last frame).",
|
| 556 |
+
)
|
| 557 |
+
custom_extend_image = gr.Image(
|
| 558 |
+
label="Custom extend start image",
|
| 559 |
+
type="pil",
|
| 560 |
+
height=220,
|
| 561 |
+
visible=False,
|
| 562 |
+
info="Replaces the auto last frame as the I2V start for the next segment.",
|
| 563 |
+
)
|
| 564 |
+
|
| 565 |
with gr.Row():
|
| 566 |
seg_duration = gr.Slider(
|
| 567 |
2.0,
|
|
|
|
| 624 |
|
| 625 |
seg_duration.change(update_estimate, [seg_duration, num_planned], estimate)
|
| 626 |
num_planned.change(update_estimate, [seg_duration, num_planned], estimate)
|
| 627 |
+
extend_start.change(toggle_custom_extend_image, [extend_start], [custom_extend_image])
|
| 628 |
|
| 629 |
gen_inputs = [
|
| 630 |
image,
|
|
|
|
| 641 |
]
|
| 642 |
ext_inputs = [
|
| 643 |
prompt,
|
| 644 |
+
extend_prompt,
|
| 645 |
+
extend_start,
|
| 646 |
+
custom_extend_image,
|
| 647 |
seg_duration,
|
| 648 |
steps,
|
| 649 |
negative,
|
|
|
|
| 668 |
gr.Markdown(
|
| 669 |
f"""
|
| 670 |
### How Extend works
|
| 671 |
+
1. **Generate** — still image + Generate prompt → short Wan 2.2 I2V clip (~{3.5}s default).
|
| 672 |
+
2. **Extend** — start from the **last frame** (or a **custom uploaded image**), optional separate **Extend prompt**, run I2V again, **concatenate**.
|
| 673 |
3. Repeat until you hit ~10–20s (or use **Auto-extend**). Soft cap: **6 segments**.
|
| 674 |
|
| 675 |
### Limits
|