Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -11,7 +11,6 @@ INFERENCE_URL = "https://moonmath-ai-dev--moonmath-i2v-backend-moonmathinference
|
|
| 11 |
|
| 12 |
# -------- settings --------
|
| 13 |
MAX_SLOTS = 12 # max image slots user can reveal
|
| 14 |
-
MAX_TIMELINE = 20 # max clips in the timeline
|
| 15 |
|
| 16 |
# -------- small helpers --------
|
| 17 |
def _save_video_bytes(data: bytes, tag: str) -> str:
|
|
@@ -32,6 +31,9 @@ def _download_to_bytes(url: str) -> bytes:
|
|
| 32 |
return r.content
|
| 33 |
|
| 34 |
def stitch_call(start_img: Image.Image, end_img: Image.Image, prompt: str, seed: Optional[int]) -> Optional[str]:
|
|
|
|
|
|
|
|
|
|
| 35 |
if start_img is None or end_img is None:
|
| 36 |
return None
|
| 37 |
|
|
@@ -56,11 +58,11 @@ def stitch_call(start_img: Image.Image, end_img: Image.Image, prompt: str, seed:
|
|
| 56 |
|
| 57 |
# JSON with url or base64
|
| 58 |
data = resp.json()
|
| 59 |
-
video_url = data.get("video_url") or data.get("url") or data.get("result")
|
| 60 |
-
if isinstance(video_url, str) and video_url.startswith("http"):
|
| 61 |
return _save_video_bytes(_download_to_bytes(video_url), "stitch")
|
| 62 |
|
| 63 |
-
video_b64 = data.get("video_b64")
|
| 64 |
if isinstance(video_b64, str):
|
| 65 |
pad = (-len(video_b64)) % 4
|
| 66 |
if pad:
|
|
@@ -93,21 +95,44 @@ def concat_many(videos: List[str]) -> Optional[str]:
|
|
| 93 |
print("concat_many error:", e)
|
| 94 |
return None
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
# =========================
|
| 97 |
# Gradio callbacks / state ops
|
| 98 |
# =========================
|
| 99 |
def add_image_slot(visible_slots: int):
|
| 100 |
"""Reveal one more upload slot (up to MAX_SLOTS)."""
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
def collect_choices(*imgs):
|
| 105 |
-
"""Build dropdown choices of available indices (1-based labels)."""
|
| 106 |
choices = []
|
| 107 |
for i, img in enumerate(imgs, start=1):
|
| 108 |
if img is not None:
|
| 109 |
choices.append(str(i))
|
| 110 |
-
# Return same list for both start/end dropdowns
|
| 111 |
return gr.update(choices=choices), gr.update(choices=choices)
|
| 112 |
|
| 113 |
def stitch_selected(prompt, seed, start_idx_str, end_idx_str, *imgs):
|
|
@@ -125,6 +150,7 @@ def stitch_selected(prompt, seed, start_idx_str, end_idx_str, *imgs):
|
|
| 125 |
if s < 0 or e < 0 or s >= len(imgs) or e >= len(imgs):
|
| 126 |
gr.Warning("Start/End out of range.")
|
| 127 |
return None
|
|
|
|
| 128 |
start_img = imgs[s]
|
| 129 |
end_img = imgs[e]
|
| 130 |
if start_img is None or end_img is None:
|
|
@@ -138,24 +164,13 @@ def stitch_selected(prompt, seed, start_idx_str, end_idx_str, *imgs):
|
|
| 138 |
return vid # path for preview
|
| 139 |
|
| 140 |
def add_to_timeline(preview_path, timeline_paths: List[str]):
|
| 141 |
-
"""Append
|
|
|
|
| 142 |
if not preview_path:
|
| 143 |
gr.Warning("Generate a clip first.")
|
| 144 |
-
return
|
| 145 |
-
|
| 146 |
-
# append if room
|
| 147 |
-
tl = list(timeline_paths or [])
|
| 148 |
-
if len(tl) >= MAX_TIMELINE:
|
| 149 |
-
gr.Warning("Timeline full.")
|
| 150 |
-
return tl, *([gr.update(value=None)] * MAX_TIMELINE)
|
| 151 |
-
|
| 152 |
tl.append(preview_path)
|
| 153 |
-
|
| 154 |
-
# map into video components
|
| 155 |
-
outputs = []
|
| 156 |
-
for i in range(MAX_TIMELINE):
|
| 157 |
-
outputs.append(gr.update(value=tl[i] if i < len(tl) else None))
|
| 158 |
-
return tl, *outputs
|
| 159 |
|
| 160 |
def stitch_all_from_timeline(timeline_paths: List[str]):
|
| 161 |
vids = list(timeline_paths or [])
|
|
@@ -176,17 +191,35 @@ CSS = """
|
|
| 176 |
.rounded textarea { border-radius: 16px !important; }
|
| 177 |
.gallery-row { display:flex; gap:16px; overflow-x:auto; padding:8px 4px; }
|
| 178 |
.gallery-row .gradio-image { min-width: 220px; }
|
| 179 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
"""
|
| 181 |
|
| 182 |
with gr.Blocks(css=CSS, title="StitchMaster") as demo:
|
| 183 |
-
gr.Markdown("## StitchMaster
|
| 184 |
|
| 185 |
# --- State ---
|
| 186 |
-
visible_slots = gr.State(value=3) #
|
| 187 |
timeline_state = gr.State(value=[]) # list[str] of video file paths (timeline)
|
| 188 |
|
| 189 |
-
# --- Image gallery (
|
| 190 |
with gr.Row(elem_classes=["gallery-row"]):
|
| 191 |
img_comps = []
|
| 192 |
for i in range(MAX_SLOTS):
|
|
@@ -202,20 +235,13 @@ with gr.Blocks(css=CSS, title="StitchMaster") as demo:
|
|
| 202 |
)
|
| 203 |
|
| 204 |
# reflect visibility changes whenever visible_slots changes
|
| 205 |
-
# (we re-render all image components with correct visibility)
|
| 206 |
-
def _reveal_slots(n, *imgs):
|
| 207 |
-
updates = []
|
| 208 |
-
for i in range(MAX_SLOTS):
|
| 209 |
-
updates.append(gr.update(visible=(i < int(n))))
|
| 210 |
-
return updates
|
| 211 |
-
|
| 212 |
visible_slots.change(
|
| 213 |
fn=_reveal_slots,
|
| 214 |
inputs=[visible_slots] + img_comps,
|
| 215 |
outputs=img_comps
|
| 216 |
)
|
| 217 |
|
| 218 |
-
#
|
| 219 |
seed = gr.Number(value=0, precision=0, label="Seed (0 = random)")
|
| 220 |
|
| 221 |
with gr.Row():
|
|
@@ -226,12 +252,12 @@ with gr.Blocks(css=CSS, title="StitchMaster") as demo:
|
|
| 226 |
placeholder="Describe the transition between the selected start and end frames…",
|
| 227 |
lines=3, label="Prompt", elem_classes=["rounded"]
|
| 228 |
)
|
| 229 |
-
run_btn = gr.Button("
|
| 230 |
add_tl_btn = gr.Button("Add to timeline", elem_classes=["pill"])
|
| 231 |
with gr.Column(scale=1, min_width=420):
|
| 232 |
preview = gr.Video(label="Video output", interactive=False)
|
| 233 |
|
| 234 |
-
# keep start/end dropdowns up to date based on which slots
|
| 235 |
for comp in img_comps:
|
| 236 |
comp.change(
|
| 237 |
fn=collect_choices,
|
|
@@ -246,15 +272,14 @@ with gr.Blocks(css=CSS, title="StitchMaster") as demo:
|
|
| 246 |
outputs=[preview]
|
| 247 |
)
|
| 248 |
|
| 249 |
-
#
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
timeline_videos = [gr.Video(label=f"Clip {i+1}", interactive=False) for i in range(MAX_TIMELINE)]
|
| 253 |
|
| 254 |
add_tl_btn.click(
|
| 255 |
fn=add_to_timeline,
|
| 256 |
inputs=[preview, timeline_state],
|
| 257 |
-
outputs=[timeline_state]
|
| 258 |
)
|
| 259 |
|
| 260 |
# final stitch all (concatenate in order)
|
|
|
|
| 11 |
|
| 12 |
# -------- settings --------
|
| 13 |
MAX_SLOTS = 12 # max image slots user can reveal
|
|
|
|
| 14 |
|
| 15 |
# -------- small helpers --------
|
| 16 |
def _save_video_bytes(data: bytes, tag: str) -> str:
|
|
|
|
| 31 |
return r.content
|
| 32 |
|
| 33 |
def stitch_call(start_img: Image.Image, end_img: Image.Image, prompt: str, seed: Optional[int]) -> Optional[str]:
|
| 34 |
+
"""
|
| 35 |
+
Calls your Modal backend with two images + prompt + seed and returns a local /tmp video path.
|
| 36 |
+
"""
|
| 37 |
if start_img is None or end_img is None:
|
| 38 |
return None
|
| 39 |
|
|
|
|
| 58 |
|
| 59 |
# JSON with url or base64
|
| 60 |
data = resp.json()
|
| 61 |
+
video_url = data.get("video_url") or data.get("url") or data.get("result") or data.get("output")
|
| 62 |
+
if isinstance(video_url, str) and video_url.startswith(("http://", "https://")):
|
| 63 |
return _save_video_bytes(_download_to_bytes(video_url), "stitch")
|
| 64 |
|
| 65 |
+
video_b64 = data.get("video_b64") or data.get("videoBase64")
|
| 66 |
if isinstance(video_b64, str):
|
| 67 |
pad = (-len(video_b64)) % 4
|
| 68 |
if pad:
|
|
|
|
| 95 |
print("concat_many error:", e)
|
| 96 |
return None
|
| 97 |
|
| 98 |
+
# -------- Timeline HTML renderer --------
|
| 99 |
+
def render_timeline_html(paths: List[str]):
|
| 100 |
+
vids = [p for p in (paths or []) if p]
|
| 101 |
+
if not vids:
|
| 102 |
+
return "<div class='tl-grid tl-empty'>No clips yet. Generate and click ‘Add to timeline’.</div>"
|
| 103 |
+
items = []
|
| 104 |
+
for i, p in enumerate(vids, 1):
|
| 105 |
+
items.append(
|
| 106 |
+
f"""
|
| 107 |
+
<div class="tl-item">
|
| 108 |
+
<video src="{p}" controls playsinline></video>
|
| 109 |
+
<div class="tl-label">Clip {i}</div>
|
| 110 |
+
</div>
|
| 111 |
+
"""
|
| 112 |
+
)
|
| 113 |
+
return f"<div class='tl-grid'>{''.join(items)}</div>"
|
| 114 |
+
|
| 115 |
# =========================
|
| 116 |
# Gradio callbacks / state ops
|
| 117 |
# =========================
|
| 118 |
def add_image_slot(visible_slots: int):
|
| 119 |
"""Reveal one more upload slot (up to MAX_SLOTS)."""
|
| 120 |
+
return min(MAX_SLOTS, int(visible_slots) + 1)
|
| 121 |
+
|
| 122 |
+
def _reveal_slots(n, *imgs):
|
| 123 |
+
"""Update visibility of image upload components based on visible_slots state."""
|
| 124 |
+
n = int(n)
|
| 125 |
+
updates = []
|
| 126 |
+
for i in range(MAX_SLOTS):
|
| 127 |
+
updates.append(gr.update(visible=(i < n)))
|
| 128 |
+
return updates
|
| 129 |
|
| 130 |
def collect_choices(*imgs):
|
| 131 |
+
"""Build dropdown choices of available indices (1-based labels) based on non-empty slots."""
|
| 132 |
choices = []
|
| 133 |
for i, img in enumerate(imgs, start=1):
|
| 134 |
if img is not None:
|
| 135 |
choices.append(str(i))
|
|
|
|
| 136 |
return gr.update(choices=choices), gr.update(choices=choices)
|
| 137 |
|
| 138 |
def stitch_selected(prompt, seed, start_idx_str, end_idx_str, *imgs):
|
|
|
|
| 150 |
if s < 0 or e < 0 or s >= len(imgs) or e >= len(imgs):
|
| 151 |
gr.Warning("Start/End out of range.")
|
| 152 |
return None
|
| 153 |
+
|
| 154 |
start_img = imgs[s]
|
| 155 |
end_img = imgs[e]
|
| 156 |
if start_img is None or end_img is None:
|
|
|
|
| 164 |
return vid # path for preview
|
| 165 |
|
| 166 |
def add_to_timeline(preview_path, timeline_paths: List[str]):
|
| 167 |
+
"""Append preview to timeline; return updated state and HTML."""
|
| 168 |
+
tl = list(timeline_paths or [])
|
| 169 |
if not preview_path:
|
| 170 |
gr.Warning("Generate a clip first.")
|
| 171 |
+
return tl, gr.update(value=render_timeline_html(tl))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
tl.append(preview_path)
|
| 173 |
+
return tl, gr.update(value=render_timeline_html(tl))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
def stitch_all_from_timeline(timeline_paths: List[str]):
|
| 176 |
vids = list(timeline_paths or [])
|
|
|
|
| 191 |
.rounded textarea { border-radius: 16px !important; }
|
| 192 |
.gallery-row { display:flex; gap:16px; overflow-x:auto; padding:8px 4px; }
|
| 193 |
.gallery-row .gradio-image { min-width: 220px; }
|
| 194 |
+
.tl-grid {
|
| 195 |
+
display: grid;
|
| 196 |
+
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
| 197 |
+
gap: 12px;
|
| 198 |
+
}
|
| 199 |
+
.tl-grid video {
|
| 200 |
+
width: 100%;
|
| 201 |
+
height: 120px;
|
| 202 |
+
object-fit: cover;
|
| 203 |
+
border-radius: 12px;
|
| 204 |
+
display: block;
|
| 205 |
+
}
|
| 206 |
+
.tl-label {
|
| 207 |
+
font-size: 12px;
|
| 208 |
+
color: #9aa0a6;
|
| 209 |
+
margin-top: 4px;
|
| 210 |
+
text-align: center;
|
| 211 |
+
}
|
| 212 |
+
.tl-empty { color: #9aa0a6; padding: 8px 4px; }
|
| 213 |
"""
|
| 214 |
|
| 215 |
with gr.Blocks(css=CSS, title="StitchMaster") as demo:
|
| 216 |
+
gr.Markdown("## StitchMaster")
|
| 217 |
|
| 218 |
# --- State ---
|
| 219 |
+
visible_slots = gr.State(value=3) # number of visible image slots
|
| 220 |
timeline_state = gr.State(value=[]) # list[str] of video file paths (timeline)
|
| 221 |
|
| 222 |
+
# --- Image gallery (horizontal, grows on demand) ---
|
| 223 |
with gr.Row(elem_classes=["gallery-row"]):
|
| 224 |
img_comps = []
|
| 225 |
for i in range(MAX_SLOTS):
|
|
|
|
| 235 |
)
|
| 236 |
|
| 237 |
# reflect visibility changes whenever visible_slots changes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
visible_slots.change(
|
| 239 |
fn=_reveal_slots,
|
| 240 |
inputs=[visible_slots] + img_comps,
|
| 241 |
outputs=img_comps
|
| 242 |
)
|
| 243 |
|
| 244 |
+
# Seed + Start/End selection + Prompt + Stitch + Preview
|
| 245 |
seed = gr.Number(value=0, precision=0, label="Seed (0 = random)")
|
| 246 |
|
| 247 |
with gr.Row():
|
|
|
|
| 252 |
placeholder="Describe the transition between the selected start and end frames…",
|
| 253 |
lines=3, label="Prompt", elem_classes=["rounded"]
|
| 254 |
)
|
| 255 |
+
run_btn = gr.Button("Generate", elem_classes=["pill"])
|
| 256 |
add_tl_btn = gr.Button("Add to timeline", elem_classes=["pill"])
|
| 257 |
with gr.Column(scale=1, min_width=420):
|
| 258 |
preview = gr.Video(label="Video output", interactive=False)
|
| 259 |
|
| 260 |
+
# keep start/end dropdowns up to date based on which slots have images
|
| 261 |
for comp in img_comps:
|
| 262 |
comp.change(
|
| 263 |
fn=collect_choices,
|
|
|
|
| 272 |
outputs=[preview]
|
| 273 |
)
|
| 274 |
|
| 275 |
+
# --- Dynamic timeline (no placeholders) ---
|
| 276 |
+
with gr.Row():
|
| 277 |
+
timeline_html = gr.HTML(value=render_timeline_html([]))
|
|
|
|
| 278 |
|
| 279 |
add_tl_btn.click(
|
| 280 |
fn=add_to_timeline,
|
| 281 |
inputs=[preview, timeline_state],
|
| 282 |
+
outputs=[timeline_state, timeline_html]
|
| 283 |
)
|
| 284 |
|
| 285 |
# final stitch all (concatenate in order)
|