Initial commit: YouTube Shorts maker application

Features:
- Video download from TikTok/Douyin using yt-dlp
- Audio transcription with OpenAI Whisper
- GPT-4 translation (direct/summarize/rewrite modes)
- Subtitle generation with ASS format
- Video trimming with frame-accurate preview
- BGM integration with volume control
- Intro text overlay support
- Thumbnail generation with text overlay

Tech stack:
- Backend: FastAPI, Python 3.11+
- Frontend: React, Vite, TailwindCSS
- Video processing: FFmpeg
- AI: OpenAI Whisper, GPT-4

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kihong.kim
2026-01-03 21:38:34 +09:00
commit c3795138da
64 changed files with 13059 additions and 0 deletions

578
backend/app/routers/bgm.py Normal file
View File

@@ -0,0 +1,578 @@
import os
import aiofiles
import httpx
from typing import List
from fastapi import APIRouter, UploadFile, File, HTTPException
from pydantic import BaseModel
from app.models.schemas import BGMInfo, BGMUploadResponse, TranscriptSegment
from app.services.video_processor import get_audio_duration
from app.services.bgm_provider import (
get_free_bgm_sources,
search_freesound,
download_freesound,
search_and_download_bgm,
BGMSearchResult,
)
from app.services.bgm_recommender import (
recommend_bgm_for_script,
get_preset_recommendation,
BGMRecommendation,
BGM_PRESETS,
)
from app.services.default_bgm import (
initialize_default_bgm,
get_default_bgm_list,
check_default_bgm_status,
DEFAULT_BGM_TRACKS,
)
from app.config import settings
router = APIRouter()
class BGMDownloadRequest(BaseModel):
"""Request to download BGM from URL."""
url: str
name: str
class BGMRecommendRequest(BaseModel):
"""Request for BGM recommendation based on script."""
segments: List[dict] # TranscriptSegment as dict
use_translated: bool = True
class FreesoundSearchRequest(BaseModel):
"""Request to search Freesound."""
query: str
min_duration: int = 10
max_duration: int = 180
page: int = 1
page_size: int = 15
commercial_only: bool = True # 상업적 사용 가능한 라이선스만 (CC0, CC-BY)
class FreesoundDownloadRequest(BaseModel):
"""Request to download from Freesound."""
sound_id: str
name: str # Custom name for the downloaded file
class AutoBGMRequest(BaseModel):
"""Request for automatic BGM search and download."""
keywords: List[str] # Search keywords (from BGM recommendation)
max_duration: int = 120
commercial_only: bool = True # 상업적 사용 가능한 라이선스만
@router.get("/", response_model=list[BGMInfo])
async def list_bgm():
"""List all available BGM files."""
bgm_list = []
if not os.path.exists(settings.BGM_DIR):
return bgm_list
for filename in os.listdir(settings.BGM_DIR):
if filename.endswith((".mp3", ".wav", ".m4a", ".ogg")):
filepath = os.path.join(settings.BGM_DIR, filename)
bgm_id = os.path.splitext(filename)[0]
duration = await get_audio_duration(filepath)
bgm_list.append(BGMInfo(
id=bgm_id,
name=bgm_id.replace("_", " ").replace("-", " ").title(),
duration=duration or 0,
path=f"/static/bgm/{filename}"
))
return bgm_list
@router.post("/upload", response_model=BGMUploadResponse)
async def upload_bgm(
file: UploadFile = File(...),
name: str | None = None
):
"""Upload a new BGM file."""
if not file.filename:
raise HTTPException(status_code=400, detail="No filename provided")
# Validate file type
allowed_extensions = (".mp3", ".wav", ".m4a", ".ogg")
if not file.filename.lower().endswith(allowed_extensions):
raise HTTPException(
status_code=400,
detail=f"Invalid file type. Allowed: {allowed_extensions}"
)
# Generate ID
bgm_id = name or os.path.splitext(file.filename)[0]
bgm_id = bgm_id.lower().replace(" ", "_")
# Get extension
ext = os.path.splitext(file.filename)[1].lower()
filepath = os.path.join(settings.BGM_DIR, f"{bgm_id}{ext}")
# Save file
os.makedirs(settings.BGM_DIR, exist_ok=True)
async with aiofiles.open(filepath, 'wb') as out_file:
content = await file.read()
await out_file.write(content)
return BGMUploadResponse(
id=bgm_id,
name=name or file.filename,
message="BGM uploaded successfully"
)
@router.delete("/{bgm_id}")
async def delete_bgm(bgm_id: str):
"""Delete a BGM file."""
for ext in (".mp3", ".wav", ".m4a", ".ogg"):
filepath = os.path.join(settings.BGM_DIR, f"{bgm_id}{ext}")
if os.path.exists(filepath):
os.remove(filepath)
return {"message": f"BGM '{bgm_id}' deleted"}
raise HTTPException(status_code=404, detail="BGM not found")
@router.get("/sources/free", response_model=dict)
async def get_free_sources():
"""Get list of recommended free BGM sources for commercial use."""
sources = get_free_bgm_sources()
return {
"sources": sources,
"notice": "이 소스들은 상업적 사용이 가능한 무료 음악을 제공합니다. 각 사이트의 라이선스를 확인하세요.",
"recommended": [
{
"name": "Pixabay Music",
"url": "https://pixabay.com/music/search/",
"why": "CC0 라이선스, 저작권 표기 불필요, 쇼츠용 짧은 트랙 많음",
"search_tips": ["upbeat", "energetic", "chill", "cinematic", "funny"],
},
{
"name": "Mixkit",
"url": "https://mixkit.co/free-stock-music/",
"why": "고품질, 카테고리별 정리, 상업적 무료 사용",
"search_tips": ["short", "intro", "background"],
},
],
}
@router.post("/download-url", response_model=BGMUploadResponse)
async def download_bgm_from_url(request: BGMDownloadRequest):
"""
Download BGM from external URL (Pixabay, Mixkit, etc.)
Use this to download free BGM files directly from their source URLs.
"""
url = request.url
name = request.name.lower().replace(" ", "_")
# Validate URL - allow trusted free music sources
allowed_domains = [
"pixabay.com",
"cdn.pixabay.com",
"mixkit.co",
"assets.mixkit.co",
"uppbeat.io",
"freemusicarchive.org",
]
from urllib.parse import urlparse
parsed = urlparse(url)
domain = parsed.netloc.lower()
if not any(allowed in domain for allowed in allowed_domains):
raise HTTPException(
status_code=400,
detail=f"URL must be from allowed sources: {', '.join(allowed_domains)}"
)
try:
async with httpx.AsyncClient(follow_redirects=True) as client:
response = await client.get(url, timeout=60)
if response.status_code != 200:
raise HTTPException(
status_code=400,
detail=f"Failed to download: HTTP {response.status_code}"
)
# Determine file extension
content_type = response.headers.get("content-type", "")
if "mpeg" in content_type or url.endswith(".mp3"):
ext = ".mp3"
elif "wav" in content_type or url.endswith(".wav"):
ext = ".wav"
elif "ogg" in content_type or url.endswith(".ogg"):
ext = ".ogg"
else:
ext = ".mp3"
# Save file
os.makedirs(settings.BGM_DIR, exist_ok=True)
filepath = os.path.join(settings.BGM_DIR, f"{name}{ext}")
async with aiofiles.open(filepath, 'wb') as f:
await f.write(response.content)
return BGMUploadResponse(
id=name,
name=request.name,
message=f"BGM downloaded from {domain}"
)
except httpx.TimeoutException:
raise HTTPException(status_code=408, detail="Download timed out")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Download failed: {str(e)}")
@router.post("/recommend")
async def recommend_bgm(request: BGMRecommendRequest):
"""
AI-powered BGM recommendation based on script content.
Analyzes the script mood and suggests appropriate background music.
Returns matched BGM from library if available, otherwise provides search keywords.
"""
# Convert dict to TranscriptSegment
segments = [TranscriptSegment(**seg) for seg in request.segments]
# Get available BGM list
available_bgm = []
if os.path.exists(settings.BGM_DIR):
for filename in os.listdir(settings.BGM_DIR):
if filename.endswith((".mp3", ".wav", ".m4a", ".ogg")):
bgm_id = os.path.splitext(filename)[0]
available_bgm.append({
"id": bgm_id,
"name": bgm_id.replace("_", " ").replace("-", " "),
})
# Get recommendation
success, message, recommendation = await recommend_bgm_for_script(
segments,
available_bgm,
use_translated=request.use_translated,
)
if not success:
raise HTTPException(status_code=500, detail=message)
# Build Pixabay search URL
search_keywords = "+".join(recommendation.search_keywords[:3])
pixabay_url = f"https://pixabay.com/music/search/{search_keywords}/"
return {
"recommendation": {
"mood": recommendation.mood,
"energy": recommendation.energy,
"reasoning": recommendation.reasoning,
"suggested_genres": recommendation.suggested_genres,
"search_keywords": recommendation.search_keywords,
},
"matched_bgm": recommendation.matched_bgm_id,
"search_urls": {
"pixabay": pixabay_url,
"mixkit": f"https://mixkit.co/free-stock-music/?q={search_keywords}",
},
"message": message,
}
@router.get("/recommend/presets")
async def get_bgm_presets():
"""
Get predefined BGM presets for common content types.
Use these presets for quick BGM selection without AI analysis.
"""
presets = {}
for content_type, preset_info in BGM_PRESETS.items():
presets[content_type] = {
"mood": preset_info["mood"],
"keywords": preset_info["keywords"],
"description": f"Best for {content_type} content",
}
return {
"presets": presets,
"usage": "Use content_type parameter with /recommend/preset/{content_type}",
}
@router.get("/recommend/preset/{content_type}")
async def get_preset_bgm(content_type: str):
"""
Get BGM recommendation for a specific content type.
Available types: cooking, fitness, tutorial, comedy, travel, asmr, news, gaming
"""
recommendation = get_preset_recommendation(content_type)
if not recommendation:
available_types = list(BGM_PRESETS.keys())
raise HTTPException(
status_code=404,
detail=f"Unknown content type. Available: {', '.join(available_types)}"
)
# Check for matching BGM in library
if os.path.exists(settings.BGM_DIR):
for filename in os.listdir(settings.BGM_DIR):
if filename.endswith((".mp3", ".wav", ".m4a", ".ogg")):
bgm_id = os.path.splitext(filename)[0]
bgm_name = bgm_id.lower()
# Check if any keyword matches
for keyword in recommendation.search_keywords:
if keyword in bgm_name:
recommendation.matched_bgm_id = bgm_id
break
if recommendation.matched_bgm_id:
break
search_keywords = "+".join(recommendation.search_keywords[:3])
return {
"content_type": content_type,
"recommendation": {
"mood": recommendation.mood,
"energy": recommendation.energy,
"suggested_genres": recommendation.suggested_genres,
"search_keywords": recommendation.search_keywords,
},
"matched_bgm": recommendation.matched_bgm_id,
"search_urls": {
"pixabay": f"https://pixabay.com/music/search/{search_keywords}/",
"mixkit": f"https://mixkit.co/free-stock-music/?q={search_keywords}",
},
}
@router.post("/freesound/search")
async def search_freesound_api(request: FreesoundSearchRequest):
"""
Search for music on Freesound.
Freesound API provides 500,000+ CC licensed sounds.
Get your API key at: https://freesound.org/apiv2/apply
Set commercial_only=true (default) to only return CC0 licensed sounds
that can be used commercially without attribution.
"""
success, message, results = await search_freesound(
query=request.query,
min_duration=request.min_duration,
max_duration=request.max_duration,
page=request.page,
page_size=request.page_size,
commercial_only=request.commercial_only,
)
if not success:
raise HTTPException(status_code=400, detail=message)
def is_commercial_ok(license_str: str) -> bool:
return "CC0" in license_str or license_str == "CC BY (Attribution)"
return {
"message": message,
"commercial_only": request.commercial_only,
"results": [
{
"id": r.id,
"title": r.title,
"duration": r.duration,
"tags": r.tags,
"license": r.license,
"commercial_use_ok": is_commercial_ok(r.license),
"preview_url": r.preview_url,
"source": r.source,
}
for r in results
],
"search_url": f"https://freesound.org/search/?q={request.query}",
}
@router.post("/freesound/download", response_model=BGMUploadResponse)
async def download_freesound_api(request: FreesoundDownloadRequest):
"""
Download a sound from Freesound by ID.
Downloads the high-quality preview (128kbps MP3).
"""
name = request.name.lower().replace(" ", "_")
name = "".join(c for c in name if c.isalnum() or c == "_")
success, message, file_path = await download_freesound(
sound_id=request.sound_id,
output_dir=settings.BGM_DIR,
filename=name,
)
if not success:
raise HTTPException(status_code=400, detail=message)
return BGMUploadResponse(
id=name,
name=request.name,
message=message,
)
@router.post("/auto-download")
async def auto_download_bgm(request: AutoBGMRequest):
"""
Automatically search and download BGM based on keywords.
Use this with keywords from /recommend endpoint to auto-download matching BGM.
Requires FREESOUND_API_KEY to be configured.
Set commercial_only=true (default) to only download CC0 licensed sounds
that can be used commercially without attribution.
"""
success, message, file_path, matched_result = await search_and_download_bgm(
keywords=request.keywords,
output_dir=settings.BGM_DIR,
max_duration=request.max_duration,
commercial_only=request.commercial_only,
)
if not success:
return {
"success": False,
"message": message,
"downloaded": None,
"suggestion": "Configure FREESOUND_API_KEY or manually download from Pixabay/Mixkit",
}
# Get duration of downloaded file
duration = 0
if file_path:
duration = await get_audio_duration(file_path) or 0
# Check if license is commercially usable
license_name = matched_result.license if matched_result else ""
commercial_ok = "CC0" in license_name or license_name == "CC BY (Attribution)"
return {
"success": True,
"message": message,
"downloaded": {
"id": os.path.splitext(os.path.basename(file_path))[0] if file_path else None,
"name": matched_result.title if matched_result else None,
"duration": duration,
"license": license_name,
"commercial_use_ok": commercial_ok,
"source": "freesound",
"path": f"/static/bgm/{os.path.basename(file_path)}" if file_path else None,
},
"original": {
"freesound_id": matched_result.id if matched_result else None,
"tags": matched_result.tags if matched_result else [],
},
}
@router.get("/defaults/status")
async def get_default_bgm_status():
"""
Check status of default BGM tracks.
Returns which default tracks are installed and which are missing.
"""
status = check_default_bgm_status(settings.BGM_DIR)
# Add track details
tracks = []
for track in DEFAULT_BGM_TRACKS:
installed = track.id in status["installed_ids"]
tracks.append({
"id": track.id,
"name": track.name,
"category": track.category,
"description": track.description,
"installed": installed,
})
return {
"total": status["total"],
"installed": status["installed"],
"missing": status["missing"],
"tracks": tracks,
}
@router.post("/defaults/initialize")
async def initialize_default_bgms(force: bool = False):
"""
Download default BGM tracks.
Downloads pre-selected royalty-free BGM tracks (Pixabay License).
Use force=true to re-download all tracks.
These tracks are free for commercial use without attribution.
"""
downloaded, skipped, errors = await initialize_default_bgm(
settings.BGM_DIR,
force=force,
)
return {
"success": len(errors) == 0,
"downloaded": downloaded,
"skipped": skipped,
"errors": errors,
"message": f"Downloaded {downloaded} tracks, skipped {skipped} existing" if downloaded > 0
else "All default tracks already installed" if skipped > 0
else "Failed to download tracks",
}
@router.get("/defaults/list")
async def list_default_bgms():
"""
Get list of available default BGM tracks with metadata.
Returns information about all pre-configured default tracks.
"""
tracks = await get_default_bgm_list()
status = check_default_bgm_status(settings.BGM_DIR)
for track in tracks:
track["installed"] = track["id"] in status["installed_ids"]
return {
"tracks": tracks,
"total": len(tracks),
"installed": status["installed"],
"license": "Pixabay License (Free for commercial use, no attribution required)",
}
@router.get("/{bgm_id}")
async def get_bgm(bgm_id: str):
"""Get BGM info by ID."""
for ext in (".mp3", ".wav", ".m4a", ".ogg"):
filepath = os.path.join(settings.BGM_DIR, f"{bgm_id}{ext}")
if os.path.exists(filepath):
duration = await get_audio_duration(filepath)
return BGMInfo(
id=bgm_id,
name=bgm_id.replace("_", " ").replace("-", " ").title(),
duration=duration or 0,
path=f"/static/bgm/{bgm_id}{ext}"
)
raise HTTPException(status_code=404, detail="BGM not found")