Files
video-flow/modules/editor.py
Tony Zhang 33a165a615 feat: video-flow initial commit
- app.py: Streamlit UI for video generation workflow
- main_flow.py: CLI tool with argparse support
- modules/: Business logic modules (script_gen, image_gen, video_gen, composer, etc.)
- config.py: Configuration with API keys and paths
- requirements.txt: Python dependencies
- docs/: System prompt documentation
2025-12-12 19:18:27 +08:00

270 lines
6.8 KiB
Python

"""
MatchMe Studio - Editor Module (Assembly + BGM)
"""
import logging
import requests
from pathlib import Path
from typing import Dict, Any, List, Optional
from moviepy.editor import (
VideoFileClip, AudioFileClip, TextClip,
CompositeVideoClip, CompositeAudioClip,
concatenate_videoclips
)
import config
from modules import storage
logger = logging.getLogger(__name__)
# ============================================================
# Video Assembly
# ============================================================
def download_video(url: str) -> str:
"""Download video from URL to temp."""
filename = f"dl_{Path(url).name}"
local_path = config.TEMP_DIR / filename
with open(local_path, "wb") as f:
f.write(requests.get(url).content)
return str(local_path)
def concatenate_scenes(video_urls: List[str]) -> str:
"""Concatenate multiple video clips into one."""
logger.info(f"Concatenating {len(video_urls)} clips...")
clips = []
for url in video_urls:
local_path = download_video(url)
clip = VideoFileClip(local_path)
# Resize to 9:16 if needed
if clip.w != 1080 or clip.h != 1920:
clip = clip.resize(newsize=(1080, 1920))
clips.append(clip)
final = concatenate_videoclips(clips, method="compose")
output_path = config.TEMP_DIR / f"merged_{int(__import__('time').time())}.mp4"
final.write_videofile(
str(output_path),
fps=30,
codec="libx264",
audio_codec="aac",
threads=4,
logger=None
)
# Cleanup
for clip in clips:
clip.close()
final.close()
return str(output_path)
# ============================================================
# Subtitle Burning
# ============================================================
def burn_subtitles(
video_path: str,
scenes: List[Dict[str, Any]]
) -> str:
"""Burn subtitles onto video."""
logger.info("Burning subtitles...")
clip = VideoFileClip(video_path)
subtitle_clips = []
current_time = 0
for scene in scenes:
voiceover = scene.get("voiceover", "")
duration = scene.get("duration", 5)
if voiceover:
try:
txt = TextClip(
voiceover,
fontsize=48,
color='white',
stroke_color='black',
stroke_width=2,
font='DejaVu-Sans',
method='caption',
size=(900, None)
).set_position(('center', 1600)).set_start(current_time).set_duration(duration)
subtitle_clips.append(txt)
except Exception as e:
logger.warning(f"Subtitle error: {e}")
current_time += duration
if subtitle_clips:
final = CompositeVideoClip([clip] + subtitle_clips)
else:
final = clip
output_path = config.TEMP_DIR / f"subtitled_{int(__import__('time').time())}.mp4"
final.write_videofile(
str(output_path),
fps=30,
codec="libx264",
audio_codec="aac",
threads=4,
logger=None
)
clip.close()
final.close()
return str(output_path)
# ============================================================
# Voiceover Mixing
# ============================================================
def mix_voiceover(video_path: str, voiceover_url: str) -> str:
"""Mix voiceover audio with video."""
if not voiceover_url:
return video_path
logger.info("Mixing voiceover...")
# Download voiceover
vo_local = download_video(voiceover_url)
video = VideoFileClip(video_path)
voiceover = AudioFileClip(vo_local)
# Trim voiceover if longer than video
if voiceover.duration > video.duration:
voiceover = voiceover.subclip(0, video.duration)
# Mix with original audio (if any)
if video.audio:
mixed = CompositeAudioClip([
video.audio.volumex(0.3), # Lower original
voiceover.volumex(1.0)
])
else:
mixed = voiceover
final = video.set_audio(mixed)
output_path = config.TEMP_DIR / f"voiced_{int(__import__('time').time())}.mp4"
final.write_videofile(
str(output_path),
fps=30,
codec="libx264",
audio_codec="aac",
threads=4,
logger=None
)
video.close()
voiceover.close()
final.close()
return str(output_path)
# ============================================================
# BGM Mixing
# ============================================================
def mix_bgm(
video_path: str,
bgm_path: str,
bgm_volume: float = 0.2
) -> str:
"""Mix background music with video."""
logger.info("Mixing BGM...")
video = VideoFileClip(video_path)
bgm = AudioFileClip(bgm_path)
# Loop BGM if shorter than video
if bgm.duration < video.duration:
loops_needed = int(video.duration / bgm.duration) + 1
bgm = bgm.loop(loops_needed)
# Trim to video length
bgm = bgm.subclip(0, video.duration).volumex(bgm_volume)
# Mix with existing audio
if video.audio:
mixed = CompositeAudioClip([video.audio, bgm])
else:
mixed = bgm
final = video.set_audio(mixed)
output_path = config.TEMP_DIR / f"bgm_{int(__import__('time').time())}.mp4"
final.write_videofile(
str(output_path),
fps=30,
codec="libx264",
audio_codec="aac",
threads=4,
logger=None
)
video.close()
bgm.close()
final.close()
return str(output_path)
# ============================================================
# Full Pipeline
# ============================================================
def assemble_final_video(
video_urls: List[str],
scenes: List[Dict[str, Any]],
voiceover_url: str = "",
bgm_url: str = ""
) -> str:
"""
Full assembly pipeline:
1. Concatenate scene videos
2. Burn subtitles
3. Mix voiceover
4. Mix BGM
5. Upload to R2
"""
logger.info("Starting full assembly...")
# Step 1: Concatenate
merged = concatenate_scenes(video_urls)
# Step 2: Subtitles
subtitled = burn_subtitles(merged, scenes)
# Step 3: Voiceover
if voiceover_url:
voiced = mix_voiceover(subtitled, voiceover_url)
else:
voiced = subtitled
# Step 4: BGM
if bgm_url:
bgm_local = download_video(bgm_url)
final_path = mix_bgm(voiced, bgm_local)
else:
final_path = voiced
# Step 5: Upload
final_url = storage.upload_file(final_path)
logger.info(f"Final video uploaded: {final_url}")
return final_url