Bils commited on
Commit
c2640c7
·
verified ·
1 Parent(s): 443ce5f

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +286 -0
app.py ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, json, tempfile, subprocess, shutil, time, uuid
2
+ from pathlib import Path
3
+ from typing import Optional, Tuple, List
4
+
5
+ import gradio as gr
6
+ import spaces
7
+ from huggingface_hub import snapshot_download
8
+
9
+ # ========= Paths & Repo =========
10
+ ROOT = Path(__file__).parent.resolve()
11
+ REPO_DIR = ROOT / "HunyuanVideo-Foley"
12
+ WEIGHTS_DIR = ROOT / "weights"
13
+ CACHE_DIR = ROOT / "cache"
14
+ OUT_DIR = ROOT / "outputs"
15
+ ASSETS = ROOT / "assets"
16
+ ASSETS.mkdir(exist_ok=True)
17
+
18
+ BILS_BRAND = os.environ.get("BILS_BRAND", "Bilsimaging · Foley Studio")
19
+ PRIMARY_COLOR = os.environ.get("PRIMARY_COLOR", "#6B5BFF") # purple-ish
20
+
21
+ MAX_SECS = int(os.environ.get("MAX_SECS", "22")) # ZeroGPU-friendly
22
+ TARGET_H = int(os.environ.get("TARGET_H", "480")) # downscale target height
23
+ SR = int(os.environ.get("TARGET_SR", "48000")) # target audio sample rate
24
+
25
+ def sh(cmd: str):
26
+ print(">>", cmd)
27
+ subprocess.run(cmd, shell=True, check=True)
28
+
29
+ def ffprobe_duration(path: str) -> float:
30
+ try:
31
+ out = subprocess.check_output([
32
+ "ffprobe", "-v", "error", "-show_entries", "format=duration",
33
+ "-of", "default=noprint_wrappers=1:nokey=1", path
34
+ ]).decode().strip()
35
+ return float(out)
36
+ except Exception:
37
+ return 0.0
38
+
39
+ def prepare_once():
40
+ """Clone repo + download weights on cold start."""
41
+ REPO_DIR.exists() or sh("git clone https://github.com/Tencent-Hunyuan/HunyuanVideo-Foley.git")
42
+ WEIGHTS_DIR.mkdir(parents=True, exist_ok=True)
43
+ snapshot_download(
44
+ repo_id="tencent/HunyuanVideo-Foley",
45
+ local_dir=str(WEIGHTS_DIR),
46
+ local_dir_use_symlinks=False,
47
+ repo_type="model",
48
+ )
49
+ os.environ["HIFI_FOLEY_MODEL_PATH"] = str(WEIGHTS_DIR)
50
+ CACHE_DIR.mkdir(exist_ok=True)
51
+ OUT_DIR.mkdir(exist_ok=True)
52
+
53
+ prepare_once()
54
+
55
+ # ========= Preprocessing =========
56
+ def preprocess_video(in_path: str) -> Tuple[str, float]:
57
+ """
58
+ - Validates duration (<= MAX_SECS). If longer, auto-trims to MAX_SECS.
59
+ - Downscales to TARGET_H height (keeping AR), H.264 baseline, AAC passthrough.
60
+ - Returns path to processed mp4 and final duration.
61
+ """
62
+ dur = ffprobe_duration(in_path)
63
+ temp_dir = Path(tempfile.mkdtemp(prefix="pre_"))
64
+ trimmed = temp_dir / "trim.mp4"
65
+ processed = temp_dir / "proc.mp4"
66
+
67
+ # If longer than budget, trim to MAX_SECS (from start).
68
+ if dur == 0:
69
+ raise RuntimeError("Unable to read the video duration.")
70
+ trim_filter = []
71
+ if dur > MAX_SECS:
72
+ trim_filter = ["-t", str(MAX_SECS)]
73
+
74
+ # First, ensure we have a small, uniform container (mp4)
75
+ sh(" ".join([
76
+ "ffmpeg", "-y", "-i", f"\"{in_path}\"",
77
+ *trim_filter,
78
+ "-an", # remove original audio (we're generating new foley)
79
+ "-vcodec", "libx264", "-preset", "veryfast", "-crf", "23",
80
+ "-movflags", "+faststart",
81
+ f"\"{trimmed}\""
82
+ ]))
83
+
84
+ # Downscale to TARGET_H keeping AR; re-encode efficiently
85
+ # Use mod2 dimensions for compatibility
86
+ vf = f"scale=-2:{TARGET_H}:flags=bicubic"
87
+ sh(" ".join([
88
+ "ffmpeg", "-y", "-i", f"\"{trimmed}\"",
89
+ "-vf", f"\"{vf}\"",
90
+ "-an",
91
+ "-vcodec", "libx264", "-profile:v", "baseline", "-level", "3.1",
92
+ "-pix_fmt", "yuv420p",
93
+ "-preset", "veryfast", "-crf", "24",
94
+ "-movflags", "+faststart",
95
+ f"\"{processed}\""
96
+ ]))
97
+
98
+ final_dur = min(dur, float(MAX_SECS))
99
+ return str(processed), final_dur
100
+
101
+ # ========= Inference (ZeroGPU) =========
102
+ @spaces.GPU(duration=240) # ~4 minutes per call window
103
+ def run_model(video_path: str, prompt_text: str) -> str:
104
+ """
105
+ Run Tencent's infer.py on ZeroGPU. Returns path to WAV.
106
+ """
107
+ job_id = uuid.uuid4().hex[:8]
108
+ work_out = OUT_DIR / f"job_{job_id}"
109
+ work_out.mkdir(parents=True, exist_ok=True)
110
+
111
+ cmd = [
112
+ "python", f"{REPO_DIR}/infer.py",
113
+ "--model_path", str(WEIGHTS_DIR),
114
+ "--config_path", f"{REPO_DIR}/configs/hunyuanvideo-foley-xxl.yaml",
115
+ "--single_video", video_path,
116
+ "--single_prompt", json.dumps(prompt_text or ""),
117
+ "--output_dir", str(work_out),
118
+ "--device", "cuda"
119
+ ]
120
+ sh(" ".join(cmd))
121
+
122
+ # Find produced wav
123
+ wav = None
124
+ for p in work_out.rglob("*.wav"):
125
+ wav = p
126
+ break
127
+ if not wav:
128
+ raise RuntimeError("No audio produced by the model.")
129
+
130
+ # Normalize / resample to SR (safeguard)
131
+ fixed = work_out / "foley_48k.wav"
132
+ sh(" ".join([
133
+ "ffmpeg", "-y", "-i", f"\"{str(wav)}\"",
134
+ "-ar", str(SR), "-ac", "2",
135
+ f"\"{str(fixed)}\""
136
+ ]))
137
+ return str(fixed)
138
+
139
+ # ========= Post: optional mux back to the video =========
140
+ def mux_audio_with_video(video_path: str, audio_path: str) -> str:
141
+ out_path = Path(tempfile.mkdtemp(prefix="mux_")) / "with_foley.mp4"
142
+ # Copy video, add foley audio as AAC
143
+ sh(" ".join([
144
+ "ffmpeg", "-y",
145
+ "-i", f"\"{video_path}\"",
146
+ "-i", f"\"{audio_path}\"",
147
+ "-map", "0:v:0", "-map", "1:a:0",
148
+ "-c:v", "copy", "-c:a", "aac", "-b:a", "192k",
149
+ "-shortest",
150
+ f"\"{out_path}\""
151
+ ]))
152
+ return str(out_path)
153
+
154
+ # ========= Gradio UI Logic =========
155
+ def single_generate(video: str, prompt: str, want_mux: bool, project_name: str) -> Tuple[Optional[str], Optional[str], str, list]:
156
+ """
157
+ Returns: (wav_path, muxed_video_path_or_None, status_markdown, history_list)
158
+ """
159
+ history = []
160
+ try:
161
+ if not video:
162
+ return None, None, "⚠️ Please upload a video.", history
163
+ # Preprocess
164
+ history.append(["Preprocess", "Downscaling / trimming…"])
165
+ pre_path, final_dur = preprocess_video(video)
166
+ # Run model (ZeroGPU)
167
+ history.append(["Inference", "Generating foley on GPU…"])
168
+ wav = run_model(pre_path, prompt or "")
169
+ # Optional Mux
170
+ muxed = None
171
+ if want_mux:
172
+ history.append(["Mux", "Combining foley with video…"])
173
+ muxed = mux_audio_with_video(pre_path, wav)
174
+ history.append(["Done", f"OK · Duration ~{final_dur:.1f}s"])
175
+ return wav, muxed, f"✅ Finished (≈ {final_dur:.1f}s)", history
176
+ except Exception as e:
177
+ history.append(["Error", str(e)])
178
+ return None, None, f"❌ {type(e).__name__}: {e}", history
179
+
180
+ def batch_lite_generate(files: List[str], prompt: str, want_mux: bool) -> Tuple[str, list]:
181
+ """
182
+ Run a tiny queue sequentially; ZeroGPU handles each call in series.
183
+ We enforce 3 items max to stay quota-friendly.
184
+ """
185
+ log = []
186
+ if not files:
187
+ return "⚠️ Please upload 1–3 videos.", log
188
+ if len(files) > 3:
189
+ files = files[:3]
190
+ log.append(["Info", "Limiting to first 3 videos."])
191
+
192
+ outputs = []
193
+ for i, f in enumerate(files, 1):
194
+ try:
195
+ log.append([f"Preprocess {i}", Path(f).name])
196
+ pre, final_dur = preprocess_video(f)
197
+ log.append([f"Run {i}", f"GPU infer ~{final_dur:.1f}s"])
198
+ wav = run_model(pre, prompt or "")
199
+ muxed = mux_audio_with_video(pre, wav) if want_mux else None
200
+ outputs.append((wav, muxed))
201
+ log.append([f"Done {i}", "OK"])
202
+ except Exception as e:
203
+ log.append([f"Error {i}", str(e)])
204
+ # Write a small manifest to outputs
205
+ manifest = OUT_DIR / f"batchlite_{uuid.uuid4().hex[:6]}.json"
206
+ manifest.write_text(json.dumps(
207
+ [{"wav": w, "video": v} for (w, v) in outputs], ensure_ascii=False, indent=2
208
+ ))
209
+ return f"✅ Batch-lite finished · items: {len(outputs)}", log
210
+
211
+ # ========= UI =========
212
+ THEME_CSS = f"""
213
+ :root {{
214
+ --brand: {PRIMARY_COLOR};
215
+ }}
216
+ .gradio-container {{
217
+ font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Cairo, Noto Sans, Arial, "Apple Color Emoji", "Segoe UI Emoji";
218
+ }}
219
+ #brandbar {{
220
+ background: linear-gradient(90deg, var(--brand), #222);
221
+ color: white; padding: 12px 16px; border-radius: 12px;
222
+ }}
223
+ #brandbar strong {{ letter-spacing: .3px; }}
224
+ footer, #footer {{}}
225
+ """
226
+
227
+ with gr.Blocks(
228
+ css=THEME_CSS,
229
+ title="Foley Studio · ZeroGPU"
230
+ ) as demo:
231
+ with gr.Row():
232
+ gr.HTML(f'<div id="brandbar"><strong>{BILS_BRAND}</strong> — HunyuanVideo-Foley on ZeroGPU</div>')
233
+
234
+ with gr.Tabs():
235
+ with gr.Tab("🎬 Single Clip"):
236
+ with gr.Group():
237
+ project_name = gr.Textbox(label="Project name (optional)", placeholder="e.g., JawharaFM Teaser 09-2025")
238
+ with gr.Row():
239
+ v_single = gr.Video(label="Video (≤ ~20s recommended)")
240
+ p_single = gr.Textbox(label="Sound prompt (optional)", placeholder="e.g., soft footsteps, indoor reverb, light rain outside")
241
+ with gr.Row():
242
+ want_mux_single = gr.Checkbox(value=True, label="Mux foley back into video (MP4)")
243
+ run_btn = gr.Button("Generate", variant="primary")
244
+ with gr.Row():
245
+ out_audio = gr.Audio(label="Generated Foley (48 kHz WAV)", type="filepath")
246
+ out_mux = gr.Video(label="Video + Foley (MP4)", visible=True)
247
+ status_md = gr.Markdown()
248
+ history_table = gr.Dataframe(headers=["Step", "Note"], datatype=["str","str"], interactive=False, wrap=True, label="Activity")
249
+
250
+ run_btn.click(
251
+ single_generate,
252
+ inputs=[v_single, p_single, want_mux_single, project_name],
253
+ outputs=[out_audio, out_mux, status_md, history_table]
254
+ )
255
+
256
+ with gr.Tab("📦 Batch-Lite (1–3 clips)"):
257
+ files = gr.Files(label="Upload 1–3 short videos", file_types=[".mp4",".mov"], file_count="multiple")
258
+ prompt_b = gr.Textbox(label="Global prompt (optional)")
259
+ want_mux_b = gr.Checkbox(value=True, label="Mux each output")
260
+ go_b = gr.Button("Run batch-lite")
261
+ batch_status = gr.Markdown()
262
+ batch_log = gr.Dataframe(headers=["Step","Note"], datatype=["str","str"], interactive=False, wrap=True, label="Batch Log")
263
+
264
+ go_b.click(
265
+ batch_lite_generate,
266
+ inputs=[files, prompt_b, want_mux_b],
267
+ outputs=[batch_status, batch_log]
268
+ )
269
+
270
+ with gr.Tab("⚙️ Settings / Tips"):
271
+ gr.Markdown(f"""
272
+ **ZeroGPU Budget Tips**
273
+ - Keep clips **≤ {MAX_SECS}s** (tool trims automatically if longer).
274
+ - Video is downscaled to **{TARGET_H}p** to speed up inference.
275
+ - If you hit a quota message, try again later; ZeroGPU limits GPU minutes per visitor.
276
+
277
+ **Branding**
278
+ - Change brand name / color via environment variables:
279
+ - `BILS_BRAND` → header text
280
+ - `PRIMARY_COLOR` → UI accent hex
281
+
282
+ **Outputs**
283
+ - WAV is 48 kHz stereo. Toggle **Mux** to get a ready MP4 with the foley track.
284
+ """)
285
+
286
+ demo.queue(max_size=24).launch()