Spaces:
Running
Running
Update app.py
Browse files
app.py
CHANGED
|
@@ -8,7 +8,7 @@ from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
|
| 8 |
from pydub import AudioSegment
|
| 9 |
import subprocess
|
| 10 |
from pathlib import Path
|
| 11 |
-
from typing import Optional, List
|
| 12 |
import random
|
| 13 |
import math
|
| 14 |
import traceback
|
|
@@ -17,7 +17,7 @@ import sys
|
|
| 17 |
app = FastAPI(title="Video Generator with Effects API")
|
| 18 |
|
| 19 |
# ============================================================
|
| 20 |
-
# 粒子系统(全
|
| 21 |
# ============================================================
|
| 22 |
|
| 23 |
class Particle:
|
|
@@ -29,12 +29,13 @@ class Particle:
|
|
| 29 |
self.reset()
|
| 30 |
|
| 31 |
def reset(self):
|
|
|
|
| 32 |
self.x = random.uniform(0, self.canvas_w)
|
| 33 |
-
self.y = random.uniform(-50,
|
| 34 |
-
self.size = random.uniform(
|
| 35 |
self.speed = random.uniform(0.5, 2.5)
|
| 36 |
self.wind = random.uniform(-0.5, 0.5)
|
| 37 |
-
self.opacity = random.uniform(
|
| 38 |
self.rotation = random.uniform(0, 360)
|
| 39 |
self.rot_speed = random.uniform(-3, 3)
|
| 40 |
self.wobble = random.uniform(0, math.pi * 2)
|
|
@@ -173,13 +174,9 @@ def apply_flame(frame, frame_idx, fps):
|
|
| 173 |
|
| 174 |
|
| 175 |
# ============================================================
|
| 176 |
-
#
|
| 177 |
# ============================================================
|
| 178 |
def fit_image_to_canvas(img, target_w, target_h):
|
| 179 |
-
"""
|
| 180 |
-
保持原比例缩放图片,放到 target_w x target_h 的画布上。
|
| 181 |
-
如果图片比例和画布不一致,会有黑边。
|
| 182 |
-
"""
|
| 183 |
h, w = img.shape[:2]
|
| 184 |
scale = min(target_w / w, target_h / h)
|
| 185 |
new_w = int(w * scale)
|
|
@@ -232,23 +229,17 @@ def get_chinese_font():
|
|
| 232 |
|
| 233 |
|
| 234 |
# ============================================================
|
| 235 |
-
#
|
| 236 |
# ============================================================
|
| 237 |
|
| 238 |
def draw_subtitle(frame, text, font_path, video_w, video_h):
|
| 239 |
-
"""
|
| 240 |
-
精致字幕:透明阴影字 + 底部细线装饰
|
| 241 |
-
✅ 无背景板
|
| 242 |
-
✅ 字体更小
|
| 243 |
-
✅ 淡阴影增强可读性
|
| 244 |
-
"""
|
| 245 |
if not text:
|
| 246 |
return frame
|
| 247 |
|
| 248 |
img_rgba = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)).convert("RGBA")
|
| 249 |
draw = ImageDraw.Draw(img_rgba)
|
| 250 |
|
| 251 |
-
# ✅ 更小的字体:视频宽度的 1/22
|
| 252 |
font_size = max(18, min(video_w // 22, 40))
|
| 253 |
try:
|
| 254 |
font_obj = ImageFont.truetype(font_path, font_size) if font_path else ImageFont.load_default()
|
|
@@ -270,8 +261,6 @@ def draw_subtitle(frame, text, font_path, video_w, video_h):
|
|
| 270 |
lines.append(current_line)
|
| 271 |
|
| 272 |
line_height = font_size + 10
|
| 273 |
-
|
| 274 |
-
# ✅ 字幕放在底部上方 8% 处
|
| 275 |
bottom_margin = int(video_h * 0.08)
|
| 276 |
total_text_height = len(lines) * line_height
|
| 277 |
y_start = video_h - bottom_margin - total_text_height
|
|
@@ -282,25 +271,13 @@ def draw_subtitle(frame, text, font_path, video_w, video_h):
|
|
| 282 |
text_x = (video_w - text_w) // 2
|
| 283 |
text_y = y_start + i * line_height
|
| 284 |
|
| 285 |
-
#
|
| 286 |
for ox, oy in [(-1, -1), (1, -1), (-1, 1), (1, 1)]:
|
| 287 |
draw.text((text_x + ox, text_y + oy), line, fill=(0, 0, 0, 140), font=font_obj)
|
| 288 |
|
| 289 |
-
# 主文字
|
| 290 |
draw.text((text_x, text_y), line, fill=(255, 252, 245, 255), font=font_obj)
|
| 291 |
|
| 292 |
-
# ✅ 底部一条微弱的渐变线
|
| 293 |
-
line_y = y_start + total_text_height + 8
|
| 294 |
-
grad_w = int(video_w * 0.3)
|
| 295 |
-
grad_x = (video_w - grad_w) // 2
|
| 296 |
-
for j in range(2):
|
| 297 |
-
alpha = 100 - j * 40
|
| 298 |
-
draw.line(
|
| 299 |
-
[(grad_x, line_y + j), (grad_x + grad_w, line_y + j)],
|
| 300 |
-
fill=(255, 255, 255, alpha),
|
| 301 |
-
width=1,
|
| 302 |
-
)
|
| 303 |
-
|
| 304 |
return cv2.cvtColor(np.array(img_rgba.convert("RGB")), cv2.COLOR_RGB2BGR)
|
| 305 |
|
| 306 |
|
|
@@ -333,8 +310,6 @@ def generate_video(
|
|
| 333 |
# ✅ 用第一张图的比例决定视频尺寸
|
| 334 |
first_h, first_w = images_raw[0].shape[:2]
|
| 335 |
aspect_ratio = first_w / first_h
|
| 336 |
-
|
| 337 |
-
# 限制最大尺寸,避免免费 Space 内存爆炸
|
| 338 |
max_dim = 1280
|
| 339 |
if first_w >= first_h:
|
| 340 |
out_w = min(first_w, max_dim)
|
|
@@ -342,24 +317,22 @@ def generate_video(
|
|
| 342 |
else:
|
| 343 |
out_h = min(first_h, max_dim)
|
| 344 |
out_w = int(out_h * aspect_ratio)
|
| 345 |
-
|
| 346 |
-
# 确保偶数尺寸(ffmpeg 要求)
|
| 347 |
out_w = out_w if out_w % 2 == 0 else out_w + 1
|
| 348 |
out_h = out_h if out_h % 2 == 0 else out_h + 1
|
| 349 |
-
|
| 350 |
-
print(f"📐 视频尺寸: {out_w}x{out_h} (基于第一张图比例)", file=sys.stderr)
|
| 351 |
print(f"🖼️ 加载了 {len(images_raw)} 张图片", file=sys.stderr)
|
| 352 |
|
| 353 |
-
# 所有图片适配到相同尺寸
|
| 354 |
images_fitted = [fit_image_to_canvas(img, out_w, out_h) for img in images_raw]
|
| 355 |
-
|
| 356 |
frames_per_image = total_frames // len(images_fitted)
|
| 357 |
|
| 358 |
-
# ✅ 粒子
|
| 359 |
particles = []
|
| 360 |
if effect in ("snow", "leaves", "rain", "sparkle", "dust"):
|
| 361 |
-
|
|
|
|
|
|
|
| 362 |
particles.append(Particle(out_w, out_h, effect))
|
|
|
|
| 363 |
|
| 364 |
font_path = get_chinese_font()
|
| 365 |
print(f"🔤 字体: {font_path or '默认'}", file=sys.stderr)
|
|
@@ -375,7 +348,7 @@ def generate_video(
|
|
| 375 |
img_idx = min(frame_idx // frames_per_image, len(images_fitted) - 1)
|
| 376 |
frame = images_fitted[img_idx].copy()
|
| 377 |
|
| 378 |
-
# ✅ 全图特效
|
| 379 |
if effect in ("snow", "leaves", "rain", "sparkle", "dust"):
|
| 380 |
frame = apply_effect(frame, particles, effect)
|
| 381 |
elif effect == "flame":
|
|
|
|
| 8 |
from pydub import AudioSegment
|
| 9 |
import subprocess
|
| 10 |
from pathlib import Path
|
| 11 |
+
from typing import Optional, List
|
| 12 |
import random
|
| 13 |
import math
|
| 14 |
import traceback
|
|
|
|
| 17 |
app = FastAPI(title="Video Generator with Effects API")
|
| 18 |
|
| 19 |
# ============================================================
|
| 20 |
+
# 粒子系统(全图覆盖)
|
| 21 |
# ============================================================
|
| 22 |
|
| 23 |
class Particle:
|
|
|
|
| 29 |
self.reset()
|
| 30 |
|
| 31 |
def reset(self):
|
| 32 |
+
# ✅ 初始位置分布在全图各处,而非只在顶部
|
| 33 |
self.x = random.uniform(0, self.canvas_w)
|
| 34 |
+
self.y = random.uniform(-50, self.canvas_h + 50)
|
| 35 |
+
self.size = random.uniform(2, 6) # 更大更明显
|
| 36 |
self.speed = random.uniform(0.5, 2.5)
|
| 37 |
self.wind = random.uniform(-0.5, 0.5)
|
| 38 |
+
self.opacity = random.uniform(100, 255) # 更不透明
|
| 39 |
self.rotation = random.uniform(0, 360)
|
| 40 |
self.rot_speed = random.uniform(-3, 3)
|
| 41 |
self.wobble = random.uniform(0, math.pi * 2)
|
|
|
|
| 174 |
|
| 175 |
|
| 176 |
# ============================================================
|
| 177 |
+
# 图片缩放:保持原比例 + 黑边填充
|
| 178 |
# ============================================================
|
| 179 |
def fit_image_to_canvas(img, target_w, target_h):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
h, w = img.shape[:2]
|
| 181 |
scale = min(target_w / w, target_h / h)
|
| 182 |
new_w = int(w * scale)
|
|
|
|
| 229 |
|
| 230 |
|
| 231 |
# ============================================================
|
| 232 |
+
# 字幕渲染:无背景板 + 柔和阴影
|
| 233 |
# ============================================================
|
| 234 |
|
| 235 |
def draw_subtitle(frame, text, font_path, video_w, video_h):
|
| 236 |
+
"""精致字幕:柔阴影 + 暖白字,无背景板"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
if not text:
|
| 238 |
return frame
|
| 239 |
|
| 240 |
img_rgba = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)).convert("RGBA")
|
| 241 |
draw = ImageDraw.Draw(img_rgba)
|
| 242 |
|
|
|
|
| 243 |
font_size = max(18, min(video_w // 22, 40))
|
| 244 |
try:
|
| 245 |
font_obj = ImageFont.truetype(font_path, font_size) if font_path else ImageFont.load_default()
|
|
|
|
| 261 |
lines.append(current_line)
|
| 262 |
|
| 263 |
line_height = font_size + 10
|
|
|
|
|
|
|
| 264 |
bottom_margin = int(video_h * 0.08)
|
| 265 |
total_text_height = len(lines) * line_height
|
| 266 |
y_start = video_h - bottom_margin - total_text_height
|
|
|
|
| 271 |
text_x = (video_w - text_w) // 2
|
| 272 |
text_y = y_start + i * line_height
|
| 273 |
|
| 274 |
+
# 柔和阴影
|
| 275 |
for ox, oy in [(-1, -1), (1, -1), (-1, 1), (1, 1)]:
|
| 276 |
draw.text((text_x + ox, text_y + oy), line, fill=(0, 0, 0, 140), font=font_obj)
|
| 277 |
|
| 278 |
+
# 主文字
|
| 279 |
draw.text((text_x, text_y), line, fill=(255, 252, 245, 255), font=font_obj)
|
| 280 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
return cv2.cvtColor(np.array(img_rgba.convert("RGB")), cv2.COLOR_RGB2BGR)
|
| 282 |
|
| 283 |
|
|
|
|
| 310 |
# ✅ 用第一张图的比例决定视频尺寸
|
| 311 |
first_h, first_w = images_raw[0].shape[:2]
|
| 312 |
aspect_ratio = first_w / first_h
|
|
|
|
|
|
|
| 313 |
max_dim = 1280
|
| 314 |
if first_w >= first_h:
|
| 315 |
out_w = min(first_w, max_dim)
|
|
|
|
| 317 |
else:
|
| 318 |
out_h = min(first_h, max_dim)
|
| 319 |
out_w = int(out_h * aspect_ratio)
|
|
|
|
|
|
|
| 320 |
out_w = out_w if out_w % 2 == 0 else out_w + 1
|
| 321 |
out_h = out_h if out_h % 2 == 0 else out_h + 1
|
| 322 |
+
print(f"📐 视频尺寸: {out_w}x{out_h}", file=sys.stderr)
|
|
|
|
| 323 |
print(f"🖼️ 加载了 {len(images_raw)} 张图片", file=sys.stderr)
|
| 324 |
|
|
|
|
| 325 |
images_fitted = [fit_image_to_canvas(img, out_w, out_h) for img in images_raw]
|
|
|
|
| 326 |
frames_per_image = total_frames // len(images_fitted)
|
| 327 |
|
| 328 |
+
# ✅ 粒子按画面面积动态计算数量
|
| 329 |
particles = []
|
| 330 |
if effect in ("snow", "leaves", "rain", "sparkle", "dust"):
|
| 331 |
+
area = out_w * out_h
|
| 332 |
+
particle_count = max(100, int(area / 8000))
|
| 333 |
+
for _ in range(particle_count):
|
| 334 |
particles.append(Particle(out_w, out_h, effect))
|
| 335 |
+
print(f"✨ 粒子数量: {particle_count} (画面 {out_w}x{out_h})", file=sys.stderr)
|
| 336 |
|
| 337 |
font_path = get_chinese_font()
|
| 338 |
print(f"🔤 字体: {font_path or '默认'}", file=sys.stderr)
|
|
|
|
| 348 |
img_idx = min(frame_idx // frames_per_image, len(images_fitted) - 1)
|
| 349 |
frame = images_fitted[img_idx].copy()
|
| 350 |
|
| 351 |
+
# ✅ 全图特效(粒子持续运行,不随图片切换重置)
|
| 352 |
if effect in ("snow", "leaves", "rain", "sparkle", "dust"):
|
| 353 |
frame = apply_effect(frame, particles, effect)
|
| 354 |
elif effect == "flame":
|