ControlFoley / app.py
yasserrmd's picture
Update app.py
b7071af verified
Raw
History Blame Contribute Delete
19.9 kB
# ============================================================
# ControlFoley - Hugging Face ZeroGPU Gradio App
# Python 3.10
# PyTorch 2.8 / ZeroGPU compatible
# ============================================================
# IMPORTANT:
# Hugging Face ZeroGPU requires importing spaces BEFORE torch.
import spaces
import os
import sys
import time
import shutil
import logging
import subprocess
import tempfile
from pathlib import Path
from contextlib import contextmanager
import gradio as gr
from huggingface_hub import snapshot_download
import torch
import torchaudio
# ============================================================
# Paths / configuration
# ============================================================
APP_DIR = Path(__file__).resolve().parent
SOURCE_DIR = APP_DIR / "upstream_controlfoley"
MODEL_DIR = APP_DIR / "model_weights"
OUTPUT_DIR = APP_DIR / "outputs"
ASSET_DIR = APP_DIR / "sample_assets"
UPSTREAM_REPO = "https://github.com/xiaomi-research/controlfoley.git"
MODEL_REPO = "YJX-Xiaomi/ControlFoley"
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
ASSET_DIR.mkdir(parents=True, exist_ok=True)
# ControlFoley contains several relative paths internally.
# Make sure its expected working directory is the Space root.
os.chdir(APP_DIR)
# ============================================================
# Download official ControlFoley source
# ============================================================
def clone_upstream():
if (SOURCE_DIR / "controlfoley").exists():
print("ControlFoley source already available.")
return
print("Cloning official ControlFoley repository...")
subprocess.run(
[
"git",
"clone",
"--depth",
"1",
UPSTREAM_REPO,
str(SOURCE_DIR),
],
check=True,
)
# ============================================================
# Download official ControlFoley model weights
# ============================================================
def download_model_files():
required_main = MODEL_DIR / "weights" / "controlfoley.pth"
required_ext = [
MODEL_DIR / "ext_weights" / "v1-44.pth",
MODEL_DIR / "ext_weights" / "synchformer_state_dict.pth",
MODEL_DIR / "ext_weights" / "cav_mae_st.pth",
MODEL_DIR
/ "ext_weights"
/ "music_speech_audioset_epoch_15_esc_89.98.pt",
]
if required_main.exists() and all(x.exists() for x in required_ext):
print("ControlFoley model files already available.")
return
print("Downloading ControlFoley model weights...")
snapshot_download(
repo_id=MODEL_REPO,
local_dir=str(MODEL_DIR),
allow_patterns=[
"weights/*",
"ext_weights/*",
],
)
# ============================================================
# Startup downloads
# ============================================================
clone_upstream()
download_model_files()
# ============================================================
# Configure Python paths
# ============================================================
# ControlFoley imports modules from both repository root and lib/.
if str(SOURCE_DIR) not in sys.path:
sys.path.insert(0, str(SOURCE_DIR))
if str(SOURCE_DIR / "lib") not in sys.path:
sys.path.insert(0, str(SOURCE_DIR / "lib"))
# ============================================================
# Import ControlFoley
# ============================================================
from controlfoley.inference_utils import (
all_model_cfg,
generate,
load_video,
make_video,
setup_eval_logging,
)
from controlfoley.audio_model import (
create_audio_generation_model,
)
from controlfoley.feature_extractor import FeaturesUtils
from lib.flow_matching import FlowMatching
# ============================================================
# Logging
# ============================================================
setup_eval_logging()
log = logging.getLogger("controlfoley-space")
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
# ============================================================
# PyTorch 2.6+ compatibility
# ============================================================
#
# PyTorch >=2.6 changed:
#
# torch.load(..., weights_only=False)
#
# from effectively the old default behavior to:
#
# weights_only=True
#
# Some older libraries bundled/used by ControlFoley, especially
# LAION-CLAP and AudioCraft/MusicGen, call torch.load() without
# specifying weights_only.
#
# Their official checkpoints contain more than plain tensor
# state dictionaries, so weights_only=True fails.
#
# We DO NOT monkey-patch torch.load globally.
#
# Instead, this compatibility context is active ONLY while
# initializing trusted upstream ControlFoley feature models.
# ============================================================
@contextmanager
def legacy_checkpoint_loading():
original_torch_load = torch.load
def compatible_torch_load(*args, **kwargs):
# Preserve an explicit value supplied by a library.
#
# Only restore the old behavior when the caller does
# not specify weights_only at all.
if "weights_only" not in kwargs:
kwargs["weights_only"] = False
return original_torch_load(*args, **kwargs)
torch.load = compatible_torch_load
try:
yield
finally:
torch.load = original_torch_load
# ============================================================
# ControlFoley configuration
# ============================================================
MODEL_CFG = all_model_cfg["large_44k"]
SEQ_CFG = MODEL_CFG.seq_cfg
# ============================================================
# Main ControlFoley network
# ============================================================
print("=" * 60)
print("Loading ControlFoley main network...")
print("=" * 60)
NET = create_audio_generation_model(
MODEL_CFG.model_name
).to(
"cuda",
torch.float32,
).eval()
MAIN_CHECKPOINT = MODEL_DIR / "weights" / "controlfoley.pth"
# The main ControlFoley checkpoint is a normal weight state dict,
# therefore keeping weights_only=True is appropriate here.
main_state = torch.load(
MAIN_CHECKPOINT,
map_location="cuda",
weights_only=True,
)
NET.load_weights(main_state)
del main_state
print("ControlFoley main network loaded.")
# ============================================================
# Feature extractor stack
# ============================================================
#
# Includes:
#
# - DFN5B CLIP
# - Synchformer
# - CAV-MAE-ST
# - LAION CLAP
# - AudioCraft MusicGen Style
# - VAE / vocoder
#
# LAION CLAP and some AudioCraft checkpoints require the legacy
# PyTorch checkpoint-loading behavior.
# ============================================================
print("=" * 60)
print("Loading ControlFoley feature extractors...")
print("=" * 60)
with legacy_checkpoint_loading():
FEATURES = FeaturesUtils(
tod_vae_ckpt=str(
MODEL_DIR
/ "ext_weights"
/ "v1-44.pth"
),
synchformer_ckpt=str(
MODEL_DIR
/ "ext_weights"
/ "synchformer_state_dict.pth"
),
cav_mae_ckpt=str(
MODEL_DIR
/ "ext_weights"
/ "cav_mae_st.pth"
),
clap_ckpt=str(
MODEL_DIR
/ "ext_weights"
/ "music_speech_audioset_epoch_15_esc_89.98.pt"
),
mode=MODEL_CFG.mode,
enable_conditions=True,
need_vae_encoder=False,
)
FEATURES = FEATURES.to(
"cuda",
torch.float32,
).eval()
print("ControlFoley feature extractors loaded.")
# ============================================================
# Sample video
# ============================================================
def prepare_sample_video():
target = ASSET_DIR / "001.mp4"
if target.exists():
return target
upstream_sample = SOURCE_DIR / "assets" / "001.mp4"
if not upstream_sample.exists():
print("Sample video not found.")
return None
shutil.copy2(
upstream_sample,
target,
)
return target
SAMPLE_VIDEO = prepare_sample_video()
# ============================================================
# Dynamic ZeroGPU duration
# ============================================================
def gpu_budget(
video_path,
prompt,
negative_prompt,
duration,
cfg_strength,
steps,
seed,
):
"""
Allocate enough ZeroGPU time depending on inference settings.
"""
duration = float(duration)
steps = int(steps)
estimated = (
60
+ int(duration * 12)
+ int(steps * 3)
)
# ZeroGPU maximum requested allocation.
return min(
300,
max(120, estimated),
)
# ============================================================
# Main generation function
# ============================================================
@spaces.GPU(
size="xlarge",
duration=gpu_budget,
)
@torch.inference_mode()
def generate_foley(
video_path,
prompt,
negative_prompt,
duration,
cfg_strength,
steps,
seed,
):
if not video_path:
raise gr.Error(
"Please upload a video or select the sample video."
)
# --------------------------------------------------------
# Parameters
# --------------------------------------------------------
video_path = Path(video_path)
duration = float(duration)
cfg_strength = float(cfg_strength)
steps = int(steps)
seed = int(seed)
if duration < 1 or duration > 8:
raise gr.Error(
"Duration must be between 1 and 8 seconds."
)
if steps < 5 or steps > 30:
raise gr.Error(
"Inference steps must be between 5 and 30."
)
if not video_path.exists():
raise gr.Error(
"The uploaded video could not be found."
)
# --------------------------------------------------------
# Output directory
# --------------------------------------------------------
job_dir = Path(
tempfile.mkdtemp(
prefix="controlfoley_",
dir=str(OUTPUT_DIR),
)
)
audio_path = (
job_dir
/ "generated_foley.flac"
)
video_out_path = (
job_dir
/ "video_with_generated_audio.mp4"
)
# --------------------------------------------------------
# Load / preprocess video
# --------------------------------------------------------
print(f"Loading video: {video_path}")
video_info = load_video(
video_path,
duration,
)
actual_duration = min(
duration,
float(video_info.total_duration),
)
# --------------------------------------------------------
# Video conditioning
# --------------------------------------------------------
clip_frames = (
video_info
.clip_embeddings
.unsqueeze(0)
)
visual_frames = (
video_info
.visual_features
.unsqueeze(0)
)
sync_frames = (
video_info
.sync_embeddings
.unsqueeze(0)
)
# --------------------------------------------------------
# Configure temporal dimensions
# --------------------------------------------------------
SEQ_CFG.total_time_seconds = actual_duration
NET.update_seq_lengths(
SEQ_CFG.latent_sequence_length,
SEQ_CFG.clip_sequence_length,
SEQ_CFG.visual_sequence_length,
SEQ_CFG.sync_sequence_length,
)
# --------------------------------------------------------
# Random generator
# --------------------------------------------------------
rng = torch.Generator(
device="cuda"
)
rng.manual_seed(seed)
# --------------------------------------------------------
# Flow matching sampler
# --------------------------------------------------------
fm = FlowMatching(
min_sigma=0,
inference_mode="euler",
num_steps=steps,
)
# --------------------------------------------------------
# Generate
# --------------------------------------------------------
print("=" * 60)
print("Generating Foley audio...")
print(f"Prompt: {prompt}")
print(f"Duration: {actual_duration}")
print(f"Steps: {steps}")
print(f"CFG: {cfg_strength}")
print(f"Seed: {seed}")
print("=" * 60)
start_time = time.time()
audios = generate(
# Video conditioning
clip_frames,
visual_frames,
sync_frames,
# No reference audio
None,
# No timbre reference
None,
# Reference audio duration
0.0,
# Text prompt
[prompt or ""],
negative_text=[
negative_prompt or ""
],
feature_utils=FEATURES,
net=NET,
fm=fm,
rng=rng,
cfg_strength=cfg_strength,
)
# --------------------------------------------------------
# Convert generated tensor
# --------------------------------------------------------
audio = (
audios
.float()
.cpu()[0]
)
# --------------------------------------------------------
# Save generated FLAC
# --------------------------------------------------------
torchaudio.save(
str(audio_path),
audio,
SEQ_CFG.audio_sample_rate,
)
# --------------------------------------------------------
# Mux generated audio with original video
# --------------------------------------------------------
make_video(
video_info,
video_out_path,
audio,
sampling_rate=SEQ_CFG.audio_sample_rate,
)
# --------------------------------------------------------
# Finished
# --------------------------------------------------------
elapsed = time.time() - start_time
status = f"""
### ✅ Generation complete
**Duration:** {actual_duration:.2f}s
**Generation time:** {elapsed:.1f}s
**Steps:** {steps}
**CFG:** {cfg_strength}
**Seed:** {seed}
"""
# --------------------------------------------------------
# Release temporary tensors
# --------------------------------------------------------
del audios
del audio
try:
del clip_frames
del visual_frames
del sync_frames
except Exception:
pass
if torch.cuda.is_available():
torch.cuda.empty_cache()
return (
str(audio_path),
str(video_out_path),
status,
)
# ============================================================
# Gradio UI
# ============================================================
TITLE = """
# 🎬 ControlFoley — Video → Foley Audio
Generate synchronized Foley sound effects from video using
**Xiaomi Research ControlFoley**.
Upload a video or select the official sample below.
You can use:
- **V2A** — leave the prompt empty
- **TV2A** — describe the sound you want
"""
HELP_TEXT = """
### Usage
**Pure Video → Audio**
Leave the prompt blank.
**Text-guided Video → Audio**
Describe the expected sound.
Example:
`the skateboard wheels scraping and grinding on the ground.`
For the first test, use:
- Duration: **8 seconds**
- Steps: **25**
- CFG: **4.5**
- Seed: **42**
If you want a quicker test, reduce inference steps to **10–15**.
"""
with gr.Blocks(
title="ControlFoley Video to Audio"
) as demo:
gr.Markdown(TITLE)
with gr.Row():
# ====================================================
# INPUT
# ====================================================
with gr.Column():
video = gr.Video(
label="Input Video",
sources=["upload"],
format="mp4",
)
prompt = gr.Textbox(
label="Sound Prompt",
placeholder=(
"Describe the sound, or leave blank "
"for pure Video-to-Audio"
),
value=(
"the skateboard wheels scraping "
"and grinding on the ground."
),
lines=2,
)
negative_prompt = gr.Textbox(
label="Negative Prompt",
placeholder=(
"Example: music, speech, crowd noise"
),
value="",
lines=1,
)
with gr.Accordion(
"Generation Settings",
open=False,
):
duration = gr.Slider(
minimum=1,
maximum=8,
value=8,
step=0.5,
label="Duration (seconds)",
)
cfg_strength = gr.Slider(
minimum=1.0,
maximum=8.0,
value=4.5,
step=0.5,
label="CFG Strength",
)
steps = gr.Slider(
minimum=5,
maximum=30,
value=25,
step=1,
label="Inference Steps",
)
seed = gr.Number(
value=42,
precision=0,
label="Seed",
)
generate_btn = gr.Button(
"Generate Foley Sound",
variant="primary",
)
# ====================================================
# OUTPUT
# ====================================================
with gr.Column():
audio_out = gr.Audio(
label="Generated Foley Audio",
type="filepath",
)
video_out = gr.Video(
label="Video + Generated Foley",
)
status = gr.Markdown()
# ========================================================
# Sample
# ========================================================
if SAMPLE_VIDEO is not None:
gr.Examples(
examples=[
[
str(SAMPLE_VIDEO),
(
"the skateboard wheels scraping "
"and grinding on the ground."
),
"",
8,
4.5,
25,
42,
],
],
inputs=[
video,
prompt,
negative_prompt,
duration,
cfg_strength,
steps,
seed,
],
label="Official ControlFoley Sample",
)
gr.Markdown(HELP_TEXT)
# ========================================================
# Generation event
# ========================================================
generate_btn.click(
fn=generate_foley,
inputs=[
video,
prompt,
negative_prompt,
duration,
cfg_strength,
steps,
seed,
],
outputs=[
audio_out,
video_out,
status,
],
api_name="generate_foley",
concurrency_limit=1,
)
# ============================================================
# Launch
# ============================================================
if __name__ == "__main__":
demo.queue(
max_size=20,
default_concurrency_limit=1,
).launch()