Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -18,27 +18,27 @@ def get_media_duration(file_path):
|
|
| 18 |
return 5.0
|
| 19 |
|
| 20 |
def create_static_background(image_path, output_path):
|
| 21 |
-
"""Creates a darkened, blurred background (720p
|
| 22 |
target_size = (720, 1280)
|
| 23 |
with Image.open(image_path) as img:
|
| 24 |
img = img.convert("RGB")
|
| 25 |
bg = ImageOps.fit(img, target_size, method=Image.Resampling.LANCZOS)
|
| 26 |
-
bg = bg.filter(ImageFilter.GaussianBlur(radius=
|
| 27 |
enhancer = ImageEnhance.Brightness(bg)
|
| 28 |
-
bg = enhancer.enhance(0.
|
| 29 |
bg.save(output_path, "JPEG", quality=95)
|
| 30 |
return output_path
|
| 31 |
|
| 32 |
def prepare_foreground(image_path, output_path):
|
| 33 |
"""
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
This
|
| 37 |
"""
|
| 38 |
-
target_width =
|
| 39 |
with Image.open(image_path) as img:
|
| 40 |
img = img.convert("RGB")
|
| 41 |
-
|
| 42 |
w_percent = (target_width / float(img.size[0]))
|
| 43 |
h_size = int((float(img.size[1]) * float(w_percent)))
|
| 44 |
|
|
@@ -48,38 +48,33 @@ def prepare_foreground(image_path, output_path):
|
|
| 48 |
|
| 49 |
def get_smooth_movement_filter(total_frames, fg_w, fg_h):
|
| 50 |
"""
|
| 51 |
-
Generates
|
| 52 |
-
We add a buffer to 'd' (duration) to prevent end-of-clip freezing.
|
| 53 |
"""
|
| 54 |
move_type = random.choice(["zoom_in", "zoom_out", "pan_up", "pan_down"])
|
| 55 |
|
| 56 |
-
#
|
| 57 |
-
#
|
| 58 |
-
common = f"d={total_frames +
|
| 59 |
|
| 60 |
-
# We use the EXACT duration for the math, so the movement finishes exactly when audio ends
|
| 61 |
math_duration = total_frames
|
| 62 |
|
|
|
|
| 63 |
if move_type == "zoom_in":
|
| 64 |
-
# Linear Zoom In (Slow & Smooth)
|
| 65 |
z = f"1+(0.15*on/{math_duration})"
|
| 66 |
x = "iw/2-(iw/zoom/2)"
|
| 67 |
y = "ih/2-(ih/zoom/2)"
|
| 68 |
|
| 69 |
elif move_type == "zoom_out":
|
| 70 |
-
# Linear Zoom Out
|
| 71 |
z = f"1.15-(0.15*on/{math_duration})"
|
| 72 |
x = "iw/2-(iw/zoom/2)"
|
| 73 |
y = "ih/2-(ih/zoom/2)"
|
| 74 |
|
| 75 |
elif move_type == "pan_up":
|
| 76 |
-
# Linear Pan Up
|
| 77 |
z = "1.15"
|
| 78 |
x = "iw/2-(iw/zoom/2)"
|
| 79 |
y = f"(ih-ih/zoom)*(1-on/{math_duration})"
|
| 80 |
|
| 81 |
else: # pan_down
|
| 82 |
-
# Linear Pan Down
|
| 83 |
z = "1.15"
|
| 84 |
x = "iw/2-(iw/zoom/2)"
|
| 85 |
y = f"(ih-ih/zoom)*(on/{math_duration})"
|
|
@@ -90,11 +85,9 @@ def process_batch(image_files, audio_files, progress=gr.Progress()):
|
|
| 90 |
if not image_files or not audio_files:
|
| 91 |
raise gr.Error("Please upload both images and audio.")
|
| 92 |
|
| 93 |
-
# Sort files
|
| 94 |
image_files.sort(key=lambda x: x.name)
|
| 95 |
audio_files.sort(key=lambda x: x.name)
|
| 96 |
|
| 97 |
-
# Cleanup
|
| 98 |
output_dir = "temp_clips"
|
| 99 |
processed_img_dir = "temp_images"
|
| 100 |
for d in [output_dir, processed_img_dir]:
|
|
@@ -105,29 +98,24 @@ def process_batch(image_files, audio_files, progress=gr.Progress()):
|
|
| 105 |
clip_paths = []
|
| 106 |
|
| 107 |
for i, (img_path, aud_path) in enumerate(zip(image_files, audio_files)):
|
| 108 |
-
progress((i / len(image_files)), desc=f"Rendering Scene {i+1} (
|
| 109 |
|
| 110 |
try:
|
| 111 |
-
#
|
| 112 |
bg_path = os.path.join(processed_img_dir, f"bg_{i}.jpg")
|
| 113 |
create_static_background(img_path, bg_path)
|
| 114 |
|
|
|
|
| 115 |
fg_path = os.path.join(processed_img_dir, f"fg_{i}.jpg")
|
| 116 |
-
# fg_w/h will be High Res (1080p width)
|
| 117 |
fg_w, fg_h = prepare_foreground(img_path, fg_path)
|
| 118 |
|
| 119 |
duration = get_media_duration(aud_path)
|
| 120 |
|
| 121 |
-
# CHANGE: Calculate for
|
| 122 |
-
dur_frames = int(duration *
|
| 123 |
|
| 124 |
-
# Get the Fixed Filter
|
| 125 |
movement_filter = get_smooth_movement_filter(dur_frames, fg_w, fg_h)
|
| 126 |
|
| 127 |
-
# Filter Complex:
|
| 128 |
-
# 1. Background is input 0
|
| 129 |
-
# 2. Foreground is input 1 -> goes into zoompan -> becomes [fg_move]
|
| 130 |
-
# 3. [fg_move] is overlayed on [0:v]
|
| 131 |
filter_complex = (
|
| 132 |
f"[1:v]{movement_filter}[fg_move];"
|
| 133 |
f"[0:v][fg_move]overlay=(W-w)/2:(H-h)/2,"
|
|
@@ -139,20 +127,24 @@ def process_batch(image_files, audio_files, progress=gr.Progress()):
|
|
| 139 |
cmd = [
|
| 140 |
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
|
| 141 |
"-loop", "1", "-i", bg_path,
|
|
|
|
|
|
|
|
|
|
| 142 |
"-loop", "1", "-i", fg_path,
|
|
|
|
| 143 |
"-i", aud_path,
|
| 144 |
"-filter_complex", filter_complex,
|
| 145 |
"-map", "[v]", "-map", "2:a",
|
| 146 |
-
|
|
|
|
| 147 |
"-c:v", "libx264",
|
| 148 |
-
"-crf", "
|
| 149 |
-
"-preset", "
|
| 150 |
-
"-c:a", "aac", "-b:a", "
|
| 151 |
"-shortest",
|
| 152 |
clip_name
|
| 153 |
]
|
| 154 |
|
| 155 |
-
# Run
|
| 156 |
result = subprocess.run(cmd, capture_output=True, text=True)
|
| 157 |
if result.returncode != 0:
|
| 158 |
print(f"FFmpeg Error: {result.stderr}")
|
|
@@ -169,7 +161,7 @@ def process_batch(image_files, audio_files, progress=gr.Progress()):
|
|
| 169 |
for clip in clip_paths:
|
| 170 |
f.write(f"file '{clip}'\n")
|
| 171 |
|
| 172 |
-
final_output = "
|
| 173 |
subprocess.run([
|
| 174 |
"ffmpeg", "-y", "-f", "concat", "-safe", "0",
|
| 175 |
"-i", list_file, "-c", "copy", final_output
|
|
@@ -182,15 +174,15 @@ css = """
|
|
| 182 |
.gradio-container {background-color: #222; color: #eee}
|
| 183 |
"""
|
| 184 |
|
| 185 |
-
with gr.Blocks(title="
|
| 186 |
-
gr.Markdown("## 🎬
|
| 187 |
-
gr.Markdown("
|
| 188 |
|
| 189 |
with gr.Row():
|
| 190 |
img_in = gr.File(label="Images", file_count="multiple", file_types=["image"])
|
| 191 |
aud_in = gr.File(label="Audio", file_count="multiple", file_types=["audio"])
|
| 192 |
|
| 193 |
-
btn = gr.Button("Generate
|
| 194 |
out = gr.Video()
|
| 195 |
|
| 196 |
btn.click(process_batch, [img_in, aud_in], out)
|
|
|
|
| 18 |
return 5.0
|
| 19 |
|
| 20 |
def create_static_background(image_path, output_path):
|
| 21 |
+
"""Creates a darkened, blurred background (720p)."""
|
| 22 |
target_size = (720, 1280)
|
| 23 |
with Image.open(image_path) as img:
|
| 24 |
img = img.convert("RGB")
|
| 25 |
bg = ImageOps.fit(img, target_size, method=Image.Resampling.LANCZOS)
|
| 26 |
+
bg = bg.filter(ImageFilter.GaussianBlur(radius=30))
|
| 27 |
enhancer = ImageEnhance.Brightness(bg)
|
| 28 |
+
bg = enhancer.enhance(0.5)
|
| 29 |
bg.save(output_path, "JPEG", quality=95)
|
| 30 |
return output_path
|
| 31 |
|
| 32 |
def prepare_foreground(image_path, output_path):
|
| 33 |
"""
|
| 34 |
+
CRITICAL FOR SMOOTHNESS:
|
| 35 |
+
Upscales image to 2560px (2K) even for 30 FPS output.
|
| 36 |
+
This creates a high-res grid so movement doesn't snap to pixels.
|
| 37 |
"""
|
| 38 |
+
target_width = 2560
|
| 39 |
with Image.open(image_path) as img:
|
| 40 |
img = img.convert("RGB")
|
| 41 |
+
|
| 42 |
w_percent = (target_width / float(img.size[0]))
|
| 43 |
h_size = int((float(img.size[1]) * float(w_percent)))
|
| 44 |
|
|
|
|
| 48 |
|
| 49 |
def get_smooth_movement_filter(total_frames, fg_w, fg_h):
|
| 50 |
"""
|
| 51 |
+
Generates 30 FPS zoompan filter.
|
|
|
|
| 52 |
"""
|
| 53 |
move_type = random.choice(["zoom_in", "zoom_out", "pan_up", "pan_down"])
|
| 54 |
|
| 55 |
+
# 30 FPS buffer (add 30 frames extra to prevent end freeze)
|
| 56 |
+
# s=1280x720: Downscales the high-res movement to 720p smoothly
|
| 57 |
+
common = f"d={total_frames + 30}:s=1280x720:fps=30"
|
| 58 |
|
|
|
|
| 59 |
math_duration = total_frames
|
| 60 |
|
| 61 |
+
# 0.15 zoom factor is the sweet spot for 30fps smoothness
|
| 62 |
if move_type == "zoom_in":
|
|
|
|
| 63 |
z = f"1+(0.15*on/{math_duration})"
|
| 64 |
x = "iw/2-(iw/zoom/2)"
|
| 65 |
y = "ih/2-(ih/zoom/2)"
|
| 66 |
|
| 67 |
elif move_type == "zoom_out":
|
|
|
|
| 68 |
z = f"1.15-(0.15*on/{math_duration})"
|
| 69 |
x = "iw/2-(iw/zoom/2)"
|
| 70 |
y = "ih/2-(ih/zoom/2)"
|
| 71 |
|
| 72 |
elif move_type == "pan_up":
|
|
|
|
| 73 |
z = "1.15"
|
| 74 |
x = "iw/2-(iw/zoom/2)"
|
| 75 |
y = f"(ih-ih/zoom)*(1-on/{math_duration})"
|
| 76 |
|
| 77 |
else: # pan_down
|
|
|
|
| 78 |
z = "1.15"
|
| 79 |
x = "iw/2-(iw/zoom/2)"
|
| 80 |
y = f"(ih-ih/zoom)*(on/{math_duration})"
|
|
|
|
| 85 |
if not image_files or not audio_files:
|
| 86 |
raise gr.Error("Please upload both images and audio.")
|
| 87 |
|
|
|
|
| 88 |
image_files.sort(key=lambda x: x.name)
|
| 89 |
audio_files.sort(key=lambda x: x.name)
|
| 90 |
|
|
|
|
| 91 |
output_dir = "temp_clips"
|
| 92 |
processed_img_dir = "temp_images"
|
| 93 |
for d in [output_dir, processed_img_dir]:
|
|
|
|
| 98 |
clip_paths = []
|
| 99 |
|
| 100 |
for i, (img_path, aud_path) in enumerate(zip(image_files, audio_files)):
|
| 101 |
+
progress((i / len(image_files)), desc=f"Rendering Scene {i+1} (30 FPS High-Res)...")
|
| 102 |
|
| 103 |
try:
|
| 104 |
+
# 1. Background
|
| 105 |
bg_path = os.path.join(processed_img_dir, f"bg_{i}.jpg")
|
| 106 |
create_static_background(img_path, bg_path)
|
| 107 |
|
| 108 |
+
# 2. Foreground (High Res 2K)
|
| 109 |
fg_path = os.path.join(processed_img_dir, f"fg_{i}.jpg")
|
|
|
|
| 110 |
fg_w, fg_h = prepare_foreground(img_path, fg_path)
|
| 111 |
|
| 112 |
duration = get_media_duration(aud_path)
|
| 113 |
|
| 114 |
+
# CHANGE: Calculate frames for 30 FPS
|
| 115 |
+
dur_frames = int(duration * 30)
|
| 116 |
|
|
|
|
| 117 |
movement_filter = get_smooth_movement_filter(dur_frames, fg_w, fg_h)
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
filter_complex = (
|
| 120 |
f"[1:v]{movement_filter}[fg_move];"
|
| 121 |
f"[0:v][fg_move]overlay=(W-w)/2:(H-h)/2,"
|
|
|
|
| 127 |
cmd = [
|
| 128 |
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
|
| 129 |
"-loop", "1", "-i", bg_path,
|
| 130 |
+
|
| 131 |
+
# Input framerate 30 to match output
|
| 132 |
+
"-framerate", "30",
|
| 133 |
"-loop", "1", "-i", fg_path,
|
| 134 |
+
|
| 135 |
"-i", aud_path,
|
| 136 |
"-filter_complex", filter_complex,
|
| 137 |
"-map", "[v]", "-map", "2:a",
|
| 138 |
+
|
| 139 |
+
"-r", "30", # 30 FPS Output
|
| 140 |
"-c:v", "libx264",
|
| 141 |
+
"-crf", "18", # HIGH BITRATE (Visual Lossless)
|
| 142 |
+
"-preset", "slow", # BETTER COMPRESSION/QUALITY
|
| 143 |
+
"-c:a", "aac", "-b:a", "320k", # High Quality Audio
|
| 144 |
"-shortest",
|
| 145 |
clip_name
|
| 146 |
]
|
| 147 |
|
|
|
|
| 148 |
result = subprocess.run(cmd, capture_output=True, text=True)
|
| 149 |
if result.returncode != 0:
|
| 150 |
print(f"FFmpeg Error: {result.stderr}")
|
|
|
|
| 161 |
for clip in clip_paths:
|
| 162 |
f.write(f"file '{clip}'\n")
|
| 163 |
|
| 164 |
+
final_output = "final_high_bitrate_30fps.mp4"
|
| 165 |
subprocess.run([
|
| 166 |
"ffmpeg", "-y", "-f", "concat", "-safe", "0",
|
| 167 |
"-i", list_file, "-c", "copy", final_output
|
|
|
|
| 174 |
.gradio-container {background-color: #222; color: #eee}
|
| 175 |
"""
|
| 176 |
|
| 177 |
+
with gr.Blocks(title="High Bitrate 30FPS Maker", css=css) as app:
|
| 178 |
+
gr.Markdown("## 🎬 High Quality Video Generator (30 FPS)")
|
| 179 |
+
gr.Markdown("Optimized for **High Bitrate** and Smooth Motion at 30fps.")
|
| 180 |
|
| 181 |
with gr.Row():
|
| 182 |
img_in = gr.File(label="Images", file_count="multiple", file_types=["image"])
|
| 183 |
aud_in = gr.File(label="Audio", file_count="multiple", file_types=["audio"])
|
| 184 |
|
| 185 |
+
btn = gr.Button("Generate HQ Video", variant="primary")
|
| 186 |
out = gr.Video()
|
| 187 |
|
| 188 |
btn.click(process_batch, [img_in, aud_in], out)
|