Spaces:
Build error
Build error
Update app.py
Browse files
app.py
CHANGED
|
@@ -55,11 +55,35 @@ OUTPUT_DIR = "gradio_output"
|
|
| 55 |
WEIGHTS_DIR = "pretrain_weights"
|
| 56 |
REPO_ID = "lixinyizju/moda"
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
# --- Download Pre-trained Weights from Hugging Face Hub ---
|
| 59 |
def download_weights():
|
| 60 |
"""
|
| 61 |
Downloads pre-trained weights from Hugging Face Hub if they don't exist locally.
|
| 62 |
"""
|
|
|
|
| 63 |
motion_model_file = os.path.join(WEIGHTS_DIR, "moda", "net-200.pth")
|
| 64 |
|
| 65 |
if not os.path.exists(motion_model_file):
|
|
@@ -96,16 +120,20 @@ def ensure_wav_format(audio_path, max_duration_ms=300000): # Increased to 5 minu
|
|
| 96 |
|
| 97 |
try:
|
| 98 |
print(f"Loading and processing audio from: {audio_path}")
|
|
|
|
| 99 |
audio = AudioSegment.from_file(audio_path)
|
| 100 |
|
|
|
|
| 101 |
if len(audio) > max_duration_ms:
|
| 102 |
print(f"Audio is {len(audio)/1000:.2f}s, trimming to {max_duration_ms/1000:.2f}s")
|
| 103 |
audio = audio[:max_duration_ms]
|
| 104 |
else:
|
| 105 |
print(f"Audio is {len(audio)/1000:.2f}s (under the {max_duration_ms/1000:.2f}s limit)")
|
| 106 |
|
|
|
|
| 107 |
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp_file:
|
| 108 |
wav_path = tmp_file.name
|
|
|
|
| 109 |
audio.export(
|
| 110 |
wav_path,
|
| 111 |
format='wav',
|
|
@@ -147,57 +175,62 @@ def extract_last_frame(video_path, output_image_path):
|
|
| 147 |
|
| 148 |
def concatenate_videos_ffmpeg(video_paths, full_audio_path, output_path):
|
| 149 |
"""
|
| 150 |
-
Stitches multiple video files together
|
| 151 |
-
|
| 152 |
-
Finally overlays the clean original audio track.
|
| 153 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
temp_video_only = os.path.join(os.path.dirname(output_path), "temp_video_only.mp4")
|
| 155 |
-
num_inputs = len(video_paths)
|
| 156 |
|
| 157 |
try:
|
| 158 |
-
#
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
for i in range(num_inputs):
|
| 167 |
-
filter_parts.append(f"[{i}:v]scale=512:512,setsar=1[v{i}]")
|
| 168 |
-
concat_inputs.append(f"[v{i}]")
|
| 169 |
-
|
| 170 |
-
concat_inputs_str = "".join(concat_inputs)
|
| 171 |
-
filter_parts.append(f"{concat_inputs_str}concat=n={num_inputs}:v=1:a=0[v]")
|
| 172 |
-
filter_complex_str = "; ".join(filter_parts)
|
| 173 |
-
|
| 174 |
-
# Output setup for high compatibility (H.264, YUV420p, strict 25 FPS)
|
| 175 |
-
cmd_concat.extend([
|
| 176 |
-
'-filter_complex', filter_complex_str,
|
| 177 |
-
'-map', '[v]',
|
| 178 |
-
'-c:v', 'libx264',
|
| 179 |
-
'-preset', 'medium',
|
| 180 |
-
'-crf', '18', # High quality output
|
| 181 |
-
'-pix_fmt', 'yuv420p', # High compatibility pixel format
|
| 182 |
-
'-r', '25', # Strictly 25 fps matching default output
|
| 183 |
temp_video_only
|
| 184 |
-
]
|
|
|
|
|
|
|
| 185 |
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
|
| 191 |
-
# Step 2: Merge the
|
| 192 |
cmd_merge = [
|
| 193 |
'ffmpeg', '-y',
|
| 194 |
'-i', temp_video_only,
|
| 195 |
'-i', str(full_audio_path),
|
| 196 |
-
'-map', '0:v:0', #
|
| 197 |
-
'-map', '1:a:0', #
|
| 198 |
-
'-c:v', 'copy', #
|
| 199 |
'-c:a', 'aac', # Encode original audio to standard clean AAC
|
| 200 |
-
'-shortest', # Sync to shortest stream
|
| 201 |
str(output_path)
|
| 202 |
]
|
| 203 |
print(f"Applying final original audio track: {' '.join(cmd_merge)}")
|
|
@@ -206,7 +239,9 @@ def concatenate_videos_ffmpeg(video_paths, full_audio_path, output_path):
|
|
| 206 |
raise Exception(f"FFMPEG audio replacement failed: {result_merge.stderr.decode('utf-8')}")
|
| 207 |
|
| 208 |
finally:
|
| 209 |
-
# Clean up temporary
|
|
|
|
|
|
|
| 210 |
if os.path.exists(temp_video_only):
|
| 211 |
try:
|
| 212 |
os.remove(temp_video_only)
|
|
@@ -217,6 +252,9 @@ def concatenate_videos_ffmpeg(video_paths, full_audio_path, output_path):
|
|
| 217 |
# Create output directory if it doesn't exist
|
| 218 |
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
| 219 |
|
|
|
|
|
|
|
|
|
|
| 220 |
# Download weights before initializing the pipeline
|
| 221 |
download_weights()
|
| 222 |
|
|
@@ -350,12 +388,12 @@ def generate_motion(source_image_path, driving_audio_path, emotion_name, cfg_sca
|
|
| 350 |
print("Falling back to the original source image for the next segment.")
|
| 351 |
current_image_path = source_image_path
|
| 352 |
|
| 353 |
-
# 4. Merge all generated chunks and
|
| 354 |
progress(0.95, desc="Merging segments and applying original audio...")
|
| 355 |
final_video_name = f"final_{timestamp}.mp4"
|
| 356 |
final_path = Path(os.path.join(run_output_dir, final_video_name))
|
| 357 |
|
| 358 |
-
#
|
| 359 |
concatenate_videos_ffmpeg(video_chunks_paths, wav_audio_path, final_path)
|
| 360 |
|
| 361 |
except Exception as e:
|
|
|
|
| 55 |
WEIGHTS_DIR = "pretrain_weights"
|
| 56 |
REPO_ID = "lixinyizju/moda"
|
| 57 |
|
| 58 |
+
# --- Automatic Config Patching for Aspect Ratio Preservation ---
|
| 59 |
+
def patch_liveportrait_config():
|
| 60 |
+
"""
|
| 61 |
+
Patches the liveportrait_config.yaml to enable 'flag_pasteback'.
|
| 62 |
+
This stitches the animated face back onto the original high-resolution full-sized
|
| 63 |
+
source image, maintaining the exact original image size and aspect ratio.
|
| 64 |
+
"""
|
| 65 |
+
config_path = "configs/audio2motion/model/liveportrait_config.yaml"
|
| 66 |
+
if os.path.exists(config_path):
|
| 67 |
+
try:
|
| 68 |
+
import yaml
|
| 69 |
+
with open(config_path, 'r', encoding='utf-8') as f:
|
| 70 |
+
config_data = yaml.safe_load(f) or {}
|
| 71 |
+
|
| 72 |
+
# Enable pasteback to restore original image dimensions in the output video
|
| 73 |
+
if config_data.get('flag_pasteback') is not True:
|
| 74 |
+
config_data['flag_pasteback'] = True
|
| 75 |
+
with open(config_path, 'w', encoding='utf-8') as f:
|
| 76 |
+
yaml.dump(config_data, f)
|
| 77 |
+
print("Successfully patched liveportrait_config.yaml: flag_pasteback set to True")
|
| 78 |
+
except Exception as e:
|
| 79 |
+
print(f"Warning: Failed to automatically patch configuration file: {e}")
|
| 80 |
+
|
| 81 |
# --- Download Pre-trained Weights from Hugging Face Hub ---
|
| 82 |
def download_weights():
|
| 83 |
"""
|
| 84 |
Downloads pre-trained weights from Hugging Face Hub if they don't exist locally.
|
| 85 |
"""
|
| 86 |
+
# A simple check for a key file to see if the download is likely complete
|
| 87 |
motion_model_file = os.path.join(WEIGHTS_DIR, "moda", "net-200.pth")
|
| 88 |
|
| 89 |
if not os.path.exists(motion_model_file):
|
|
|
|
| 120 |
|
| 121 |
try:
|
| 122 |
print(f"Loading and processing audio from: {audio_path}")
|
| 123 |
+
# Load the audio file (supports wav, mp3, m4a, etc.)
|
| 124 |
audio = AudioSegment.from_file(audio_path)
|
| 125 |
|
| 126 |
+
# Trim if the audio exceeds the limit
|
| 127 |
if len(audio) > max_duration_ms:
|
| 128 |
print(f"Audio is {len(audio)/1000:.2f}s, trimming to {max_duration_ms/1000:.2f}s")
|
| 129 |
audio = audio[:max_duration_ms]
|
| 130 |
else:
|
| 131 |
print(f"Audio is {len(audio)/1000:.2f}s (under the {max_duration_ms/1000:.2f}s limit)")
|
| 132 |
|
| 133 |
+
# Create a temporary WAV file
|
| 134 |
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp_file:
|
| 135 |
wav_path = tmp_file.name
|
| 136 |
+
# Export as WAV with standard settings
|
| 137 |
audio.export(
|
| 138 |
wav_path,
|
| 139 |
format='wav',
|
|
|
|
| 175 |
|
| 176 |
def concatenate_videos_ffmpeg(video_paths, full_audio_path, output_path):
|
| 177 |
"""
|
| 178 |
+
Stitches multiple video files together (video track only) and overlays the original full audio track.
|
| 179 |
+
This bypasses any pops or sync problems at the chunk boundaries.
|
|
|
|
| 180 |
"""
|
| 181 |
+
# Create the text file listing all videos
|
| 182 |
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False, encoding='utf-8') as f:
|
| 183 |
+
for p in video_paths:
|
| 184 |
+
# Format absolute path with forward slashes and escape single quotes
|
| 185 |
+
abs_path = os.path.abspath(p).replace('\\', '/')
|
| 186 |
+
safe_path = abs_path.replace("'", "'\\''")
|
| 187 |
+
f.write(f"file '{safe_path}'\n")
|
| 188 |
+
list_file_path = f.name
|
| 189 |
+
|
| 190 |
temp_video_only = os.path.join(os.path.dirname(output_path), "temp_video_only.mp4")
|
|
|
|
| 191 |
|
| 192 |
try:
|
| 193 |
+
# Step 1: Concatenate video tracks only (discard original segment audios to avoid jumps)
|
| 194 |
+
cmd_copy = [
|
| 195 |
+
'ffmpeg', '-y',
|
| 196 |
+
'-f', 'concat',
|
| 197 |
+
'-safe', '0',
|
| 198 |
+
'-i', list_file_path,
|
| 199 |
+
'-an', # Discard segment audios completely
|
| 200 |
+
'-c:v', 'copy', # Direct stream copy (instant & lossless)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
temp_video_only
|
| 202 |
+
]
|
| 203 |
+
print(f"Executing lossless FFMPEG video concat (no-audio): {' '.join(cmd_copy)}")
|
| 204 |
+
result = subprocess.run(cmd_copy, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
| 205 |
|
| 206 |
+
if result.returncode != 0:
|
| 207 |
+
print("Lossless video-only concatenation failed. Retrying with full video re-encoding...")
|
| 208 |
+
# Fallback path: video re-encoding (robust)
|
| 209 |
+
cmd_reencode = [
|
| 210 |
+
'ffmpeg', '-y',
|
| 211 |
+
'-f', 'concat',
|
| 212 |
+
'-safe', '0',
|
| 213 |
+
'-i', list_file_path,
|
| 214 |
+
'-an',
|
| 215 |
+
'-c:v', 'libx264',
|
| 216 |
+
'-pix_fmt', 'yuv420p',
|
| 217 |
+
temp_video_only
|
| 218 |
+
]
|
| 219 |
+
print(f"Executing FFMPEG video-only re-encode: {' '.join(cmd_reencode)}")
|
| 220 |
+
result_re = subprocess.run(cmd_reencode, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
| 221 |
+
if result_re.returncode != 0:
|
| 222 |
+
raise Exception(f"FFMPEG video concatenation failed: {result_re.stderr.decode('utf-8')}")
|
| 223 |
|
| 224 |
+
# Step 2: Merge the concatenated video track with the complete original audio file
|
| 225 |
cmd_merge = [
|
| 226 |
'ffmpeg', '-y',
|
| 227 |
'-i', temp_video_only,
|
| 228 |
'-i', str(full_audio_path),
|
| 229 |
+
'-map', '0:v:0', # Use video from concatenated file
|
| 230 |
+
'-map', '1:a:0', # Use audio from original full wav file
|
| 231 |
+
'-c:v', 'copy', # Keep video intact
|
| 232 |
'-c:a', 'aac', # Encode original audio to standard clean AAC
|
| 233 |
+
'-shortest', # Sync to the shortest stream duration
|
| 234 |
str(output_path)
|
| 235 |
]
|
| 236 |
print(f"Applying final original audio track: {' '.join(cmd_merge)}")
|
|
|
|
| 239 |
raise Exception(f"FFMPEG audio replacement failed: {result_merge.stderr.decode('utf-8')}")
|
| 240 |
|
| 241 |
finally:
|
| 242 |
+
# Clean up temporary files
|
| 243 |
+
if os.path.exists(list_file_path):
|
| 244 |
+
os.remove(list_file_path)
|
| 245 |
if os.path.exists(temp_video_only):
|
| 246 |
try:
|
| 247 |
os.remove(temp_video_only)
|
|
|
|
| 252 |
# Create output directory if it doesn't exist
|
| 253 |
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
| 254 |
|
| 255 |
+
# Patch the LivePortrait config to enable full-frame pasteback
|
| 256 |
+
patch_liveportrait_config()
|
| 257 |
+
|
| 258 |
# Download weights before initializing the pipeline
|
| 259 |
download_weights()
|
| 260 |
|
|
|
|
| 388 |
print("Falling back to the original source image for the next segment.")
|
| 389 |
current_image_path = source_image_path
|
| 390 |
|
| 391 |
+
# 4. Merge all generated chunks and lay the original full audio track on top
|
| 392 |
progress(0.95, desc="Merging segments and applying original audio...")
|
| 393 |
final_video_name = f"final_{timestamp}.mp4"
|
| 394 |
final_path = Path(os.path.join(run_output_dir, final_video_name))
|
| 395 |
|
| 396 |
+
# We always run this function (even for 1 chunk) to ensure the original, clean, high-quality WAV audio is perfectly mapped on the video
|
| 397 |
concatenate_videos_ffmpeg(video_chunks_paths, wav_audio_path, final_path)
|
| 398 |
|
| 399 |
except Exception as e:
|