초기 프로젝트 구성: 반려동물 음악 롱폼 자동 생성 파이프라인
- ACE-Step 1.5 음악 생성 (과학적 근거 기반) - FLUX 이미지 생성 (신카이 마코토 스타일) - ffmpeg 영상 렌더링 (워터마크 포함) - YouTube Data API 롱폼 업로드 - 프롬프트 및 문서 포함
This commit is contained in:
185
render_video.py
Executable file
185
render_video.py
Executable file
@@ -0,0 +1,185 @@
|
||||
"""Animily Music - 영상 렌더링
|
||||
|
||||
이미지 + 음악 + 워터마크 → MP4 영상.
|
||||
Ken Burns 효과 + ANIMILY 워터마크 (우측 상단, 영상 전체).
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from config import (
|
||||
OUTPUT_DIR, WATERMARK_TEXT, WATERMARK_FONT, WATERMARK_SIZE,
|
||||
)
|
||||
|
||||
|
||||
def render_video(image_path, audio_path, output_path, title=None):
|
||||
"""이미지 + 오디오 → 워터마크 포함 영상 렌더링
|
||||
|
||||
Args:
|
||||
image_path: 배경 이미지 (1920x1080)
|
||||
audio_path: 오디오 파일
|
||||
output_path: 출력 MP4 경로
|
||||
title: (미사용, 향후 텍스트 오버레이용)
|
||||
|
||||
Returns:
|
||||
bool: 성공 여부
|
||||
"""
|
||||
# 오디오 길이 측정
|
||||
probe = subprocess.run(
|
||||
["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1", audio_path],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
try:
|
||||
duration = float(probe.stdout.strip())
|
||||
except ValueError:
|
||||
print(f" [RENDER] 오디오 길이 측정 실패", flush=True)
|
||||
return False
|
||||
|
||||
print(f" [RENDER] 영상 렌더링 시작 (길이: {duration/60:.1f}분)", flush=True)
|
||||
|
||||
# Ken Burns: 매우 느린 줌인 (119분 동안 1.0 → 1.08 정도)
|
||||
# zoompan fps=30, duration=전체 프레임 수
|
||||
total_frames = int(duration * 30)
|
||||
|
||||
# 워터마크 필터
|
||||
watermark_filter = (
|
||||
f"drawtext=text='{WATERMARK_TEXT}':"
|
||||
f"fontfile={WATERMARK_FONT}:"
|
||||
f"fontsize={WATERMARK_SIZE}:"
|
||||
f"fontcolor=white@0.4:"
|
||||
f"x=w-tw-80:y=40:"
|
||||
f"shadowcolor=black@0.3:shadowx=1:shadowy=1"
|
||||
)
|
||||
|
||||
# Ken Burns + 워터마크 결합
|
||||
# zoompan으로 미세 줌인 효과
|
||||
video_filter = (
|
||||
f"zoompan=z='min(zoom+0.00001,1.08)':"
|
||||
f"d={total_frames}:s=1920x1080:fps=30,"
|
||||
f"{watermark_filter}"
|
||||
)
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-loop", "1", "-i", image_path,
|
||||
"-i", audio_path,
|
||||
"-vf", video_filter,
|
||||
"-c:v", "libx264", "-preset", "medium", "-crf", "20",
|
||||
"-tune", "stillimage",
|
||||
"-c:a", "aac", "-b:a", "192k",
|
||||
"-shortest",
|
||||
"-pix_fmt", "yuv420p",
|
||||
output_path,
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=7200)
|
||||
if result.returncode != 0:
|
||||
print(f" [RENDER] ffmpeg 에러: {result.stderr[:500]}", flush=True)
|
||||
# 폴백: Ken Burns 없이 단순 정지 이미지
|
||||
cmd_simple = [
|
||||
"ffmpeg", "-y",
|
||||
"-loop", "1", "-i", image_path,
|
||||
"-i", audio_path,
|
||||
"-vf", watermark_filter,
|
||||
"-c:v", "libx264", "-preset", "medium", "-crf", "20",
|
||||
"-tune", "stillimage",
|
||||
"-c:a", "aac", "-b:a", "192k",
|
||||
"-shortest",
|
||||
"-pix_fmt", "yuv420p",
|
||||
output_path,
|
||||
]
|
||||
result = subprocess.run(cmd_simple, capture_output=True, text=True, timeout=7200)
|
||||
if result.returncode != 0:
|
||||
print(f" [RENDER] 폴백도 실패: {result.stderr[:300]}", flush=True)
|
||||
return False
|
||||
|
||||
size_mb = os.path.getsize(output_path) / (1024 * 1024)
|
||||
print(f" [RENDER] 완료: {output_path} ({size_mb:.0f}MB)", flush=True)
|
||||
return True
|
||||
|
||||
|
||||
def extend_video(short_video, target_hours, output_path):
|
||||
"""<EFBFBD><EFBFBD>은 영상을 반복하여 긴 영상 생성
|
||||
|
||||
Args:
|
||||
short_video: 원본 영상 (1h or 2h)
|
||||
target_hours: 목표 시간
|
||||
output_path: 출력 경로
|
||||
|
||||
Returns:
|
||||
bool: 성공 여부
|
||||
"""
|
||||
# 원본 길이 측정
|
||||
probe = subprocess.run(
|
||||
["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1", short_video],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
src_duration = float(probe.stdout.strip())
|
||||
target_seconds = target_hours * 3600
|
||||
|
||||
# 필요 반복 횟<><ED9A9F>
|
||||
repeats = int(target_seconds / src_duration) + 1
|
||||
|
||||
print(f" [RENDER] 영상 반복: {repeats}회 → {target_hours}시간", flush=True)
|
||||
|
||||
# concat 파일 생성
|
||||
concat_file = os.path.join(OUTPUT_DIR, "_concat_list.txt")
|
||||
with open(concat_file, "w") as f:
|
||||
for _ in range(repeats):
|
||||
f.write(f"file '{short_video}'\n")
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-f", "concat", "-safe", "0", "-i", concat_file,
|
||||
"-t", str(int(target_seconds)),
|
||||
"-c", "copy",
|
||||
output_path,
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=3600)
|
||||
os.remove(concat_file)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f" [RENDER] 반복 영상 실패: {result.stderr[:300]}", flush=True)
|
||||
return False
|
||||
|
||||
size_mb = os.path.getsize(output_path) / (1024 * 1024)
|
||||
print(f" [RENDER] 반복 영상 완료: {size_mb:.0f}MB ({target_hours}h)", flush=True)
|
||||
return True
|
||||
|
||||
|
||||
def concat_videos(video_paths, output_path):
|
||||
"""여러 영상을 순서대로 이어붙이기 (12시간용: 1h+2h 반복)
|
||||
|
||||
Args:
|
||||
video_paths: 이어붙일 영상 리스트
|
||||
output_path: 출력 경로
|
||||
|
||||
Returns:
|
||||
bool: 성공 여부
|
||||
"""
|
||||
concat_file = os.path.join(OUTPUT_DIR, "_concat_mix.txt")
|
||||
with open(concat_file, "w") as f:
|
||||
for vp in video_paths:
|
||||
f.write(f"file '{vp}'\n")
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-f", "concat", "-safe", "0", "-i", concat_file,
|
||||
"-c", "copy",
|
||||
output_path,
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=3600)
|
||||
os.remove(concat_file)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f" [RENDER] concat 실패: {result.stderr[:300]}", flush=True)
|
||||
return False
|
||||
|
||||
size_mb = os.path.getsize(output_path) / (1024 * 1024)
|
||||
print(f" [RENDER] concat 완료: {size_mb:.0f}MB", flush=True)
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("render_video.py - 직접 실행은 scheduler.py를 사용하세요")
|
||||
Reference in New Issue
Block a user