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

View File

@@ -0,0 +1,495 @@
"""
BGM Provider Service - Freesound & Pixabay Integration
Freesound API: https://freesound.org/docs/api/
- 500,000+ Creative Commons licensed sounds
- Free API with generous rate limits
- Various licenses (CC0, CC-BY, CC-BY-NC, etc.)
Pixabay: Manual download recommended (no public Music API)
"""
import os
import httpx
import aiofiles
from typing import Optional, List, Tuple
from pydantic import BaseModel
from app.config import settings
class FreesoundTrack(BaseModel):
"""Freesound track model."""
id: int
name: str
duration: float # seconds
tags: List[str]
license: str
username: str
preview_url: str # HQ preview (128kbps mp3)
download_url: str # Original file (requires auth)
description: str = ""
class BGMSearchResult(BaseModel):
"""BGM search result."""
id: str
title: str
duration: int
tags: List[str]
preview_url: str
download_url: str = ""
license: str = ""
source: str = "freesound"
# Freesound license filters for commercial use
# CC0 and CC-BY are commercially usable, CC-BY-NC is NOT
COMMERCIAL_LICENSES = [
"Creative Commons 0", # CC0 - Public Domain
"Attribution", # CC-BY - Attribution required
"Attribution Noncommercial", # Exclude this (NOT commercial)
]
# License filter string for commercial-only search
COMMERCIAL_LICENSE_FILTER = 'license:"Creative Commons 0" OR license:"Attribution"'
async def search_freesound(
query: str,
min_duration: int = 10,
max_duration: int = 180, # Shorts typically < 60s, allow some buffer
page: int = 1,
page_size: int = 15,
filter_music: bool = True,
commercial_only: bool = True, # Default: only commercially usable
) -> Tuple[bool, str, List[BGMSearchResult]]:
"""
Search for sounds on Freesound API.
Args:
query: Search keywords (e.g., "upbeat music", "chill background")
min_duration: Minimum duration in seconds
max_duration: Maximum duration in seconds
page: Page number (1-indexed)
page_size: Results per page (max 150)
filter_music: Add "music" to query for better BGM results
commercial_only: Only return commercially usable licenses (CC0, CC-BY)
Returns:
Tuple of (success, message, results)
"""
api_key = settings.FREESOUND_API_KEY
if not api_key:
return False, "Freesound API key not configured. Get one at https://freesound.org/apiv2/apply", []
# Add "music" filter for better BGM results
search_query = f"{query} music" if filter_music and "music" not in query.lower() else query
# Build filter string for duration and license
filter_parts = [f"duration:[{min_duration} TO {max_duration}]"]
if commercial_only:
# Filter for commercially usable licenses only
# CC0 (Creative Commons 0) and CC-BY (Attribution) are commercial-OK
# Exclude CC-BY-NC (Noncommercial)
filter_parts.append('license:"Creative Commons 0"')
filter_str = " ".join(filter_parts)
params = {
"token": api_key,
"query": search_query,
"filter": filter_str,
"page": page,
"page_size": min(page_size, 150),
"fields": "id,name,duration,tags,license,username,previews,description",
"sort": "score", # relevance
}
try:
async with httpx.AsyncClient() as client:
response = await client.get(
"https://freesound.org/apiv2/search/text/",
params=params,
timeout=30,
)
if response.status_code == 401:
return False, "Invalid Freesound API key", []
if response.status_code != 200:
return False, f"Freesound API error: HTTP {response.status_code}", []
data = response.json()
results = []
for sound in data.get("results", []):
# Get preview URLs (prefer high quality)
previews = sound.get("previews", {})
preview_url = (
previews.get("preview-hq-mp3") or
previews.get("preview-lq-mp3") or
""
)
# Parse license for display
license_url = sound.get("license", "")
license_name = _parse_freesound_license(license_url)
results.append(BGMSearchResult(
id=str(sound["id"]),
title=sound.get("name", "Unknown"),
duration=int(sound.get("duration", 0)),
tags=sound.get("tags", [])[:10], # Limit tags
preview_url=preview_url,
download_url=f"https://freesound.org/apiv2/sounds/{sound['id']}/download/",
license=license_name,
source="freesound",
))
total = data.get("count", 0)
license_info = " (commercial use OK)" if commercial_only else ""
message = f"Found {total} sounds on Freesound{license_info}"
return True, message, results
except httpx.TimeoutException:
return False, "Freesound API timeout", []
except Exception as e:
return False, f"Freesound search error: {str(e)}", []
def _parse_freesound_license(license_url: str) -> str:
"""Parse Freesound license URL to human-readable name."""
if "zero" in license_url or "cc0" in license_url.lower():
return "CC0 (Public Domain)"
elif "by-nc" in license_url:
return "CC BY-NC (Non-Commercial)"
elif "by-sa" in license_url:
return "CC BY-SA (Share Alike)"
elif "by/" in license_url:
return "CC BY (Attribution)"
elif "sampling+" in license_url:
return "Sampling+"
else:
return "See License"
async def download_freesound(
sound_id: str,
output_dir: str,
filename: str,
) -> Tuple[bool, str, Optional[str]]:
"""
Download a sound from Freesound.
Note: Freesound requires OAuth for original file downloads.
This function downloads the HQ preview (128kbps MP3) which is sufficient for BGM.
Args:
sound_id: Freesound sound ID
output_dir: Directory to save file
filename: Output filename (without extension)
Returns:
Tuple of (success, message, file_path)
"""
api_key = settings.FREESOUND_API_KEY
if not api_key:
return False, "Freesound API key not configured", None
try:
async with httpx.AsyncClient() as client:
# First, get sound info to get preview URL
info_response = await client.get(
f"https://freesound.org/apiv2/sounds/{sound_id}/",
params={
"token": api_key,
"fields": "id,name,previews,license,username",
},
timeout=30,
)
if info_response.status_code != 200:
return False, f"Failed to get sound info: HTTP {info_response.status_code}", None
sound_data = info_response.json()
previews = sound_data.get("previews", {})
# Get high quality preview URL
preview_url = previews.get("preview-hq-mp3")
if not preview_url:
preview_url = previews.get("preview-lq-mp3")
if not preview_url:
return False, "No preview URL available", None
# Download the preview
audio_response = await client.get(preview_url, timeout=60, follow_redirects=True)
if audio_response.status_code != 200:
return False, f"Download failed: HTTP {audio_response.status_code}", None
# Save file
os.makedirs(output_dir, exist_ok=True)
file_path = os.path.join(output_dir, f"{filename}.mp3")
async with aiofiles.open(file_path, 'wb') as f:
await f.write(audio_response.content)
# Get attribution info
username = sound_data.get("username", "Unknown")
license_name = _parse_freesound_license(sound_data.get("license", ""))
return True, f"Downloaded from Freesound (by {username}, {license_name})", file_path
except httpx.TimeoutException:
return False, "Download timeout", None
except Exception as e:
return False, f"Download error: {str(e)}", None
async def search_and_download_bgm(
keywords: List[str],
output_dir: str,
max_duration: int = 120,
commercial_only: bool = True,
) -> Tuple[bool, str, Optional[str], Optional[BGMSearchResult]]:
"""
Search for BGM and download the best match.
Args:
keywords: Search keywords from BGM recommendation
output_dir: Directory to save downloaded file
max_duration: Maximum duration in seconds
commercial_only: Only search commercially usable licenses (CC0)
Returns:
Tuple of (success, message, file_path, matched_result)
"""
if not settings.FREESOUND_API_KEY:
return False, "Freesound API key not configured", None, None
# Try searching with combined keywords
query = " ".join(keywords[:3])
success, message, results = await search_freesound(
query=query,
min_duration=15,
max_duration=max_duration,
page_size=10,
commercial_only=commercial_only,
)
if not success or not results:
# Try with individual keywords
for keyword in keywords[:3]:
success, message, results = await search_freesound(
query=keyword,
min_duration=15,
max_duration=max_duration,
page_size=5,
commercial_only=commercial_only,
)
if success and results:
break
if not results:
return False, "No matching BGM found on Freesound", None, None
# Select the best result (first one, sorted by relevance)
best_match = results[0]
# Download it
safe_filename = best_match.title.lower().replace(" ", "_")[:50]
safe_filename = "".join(c for c in safe_filename if c.isalnum() or c == "_")
success, download_msg, file_path = await download_freesound(
sound_id=best_match.id,
output_dir=output_dir,
filename=safe_filename,
)
if not success:
return False, download_msg, None, best_match
return True, download_msg, file_path, best_match
async def search_pixabay_music(
query: str = "",
category: str = "",
min_duration: int = 0,
max_duration: int = 120,
page: int = 1,
per_page: int = 20,
) -> Tuple[bool, str, List[BGMSearchResult]]:
"""
Search for royalty-free music on Pixabay.
Note: Pixabay doesn't have a public Music API, returns curated list instead.
"""
# Pixabay's music API is not publicly available
# Return curated recommendations instead
return await _get_curated_bgm_list(query)
async def _get_curated_bgm_list(query: str = "") -> Tuple[bool, str, List[BGMSearchResult]]:
"""
Return curated list of recommended free BGM sources.
Since Pixabay Music API requires special access, we provide curated recommendations.
"""
# Curated BGM recommendations (these are categories/suggestions, not actual files)
curated_bgm = [
{
"id": "upbeat_energetic",
"title": "Upbeat & Energetic",
"duration": 60,
"tags": ["upbeat", "energetic", "happy", "positive"],
"description": "활기찬 쇼츠에 적합",
},
{
"id": "chill_lofi",
"title": "Chill Lo-Fi",
"duration": 60,
"tags": ["chill", "lofi", "relaxing", "calm"],
"description": "편안한 분위기의 콘텐츠",
},
{
"id": "epic_cinematic",
"title": "Epic & Cinematic",
"duration": 60,
"tags": ["epic", "cinematic", "dramatic", "intense"],
"description": "드라마틱한 순간",
},
{
"id": "funny_quirky",
"title": "Funny & Quirky",
"duration": 30,
"tags": ["funny", "quirky", "comedy", "playful"],
"description": "유머러스한 콘텐츠",
},
{
"id": "corporate_tech",
"title": "Corporate & Tech",
"duration": 60,
"tags": ["corporate", "tech", "modern", "professional"],
"description": "정보성 콘텐츠",
},
]
# Filter by query if provided
if query:
query_lower = query.lower()
filtered = [
bgm for bgm in curated_bgm
if query_lower in bgm["title"].lower()
or any(query_lower in tag for tag in bgm["tags"])
]
curated_bgm = filtered if filtered else curated_bgm
results = [
BGMSearchResult(
id=bgm["id"],
title=bgm["title"],
duration=bgm["duration"],
tags=bgm["tags"],
preview_url="", # Would be filled with actual URL
source="curated",
)
for bgm in curated_bgm
]
return True, "Curated BGM list", results
async def download_from_url(
url: str,
output_path: str,
filename: str,
) -> Tuple[bool, str, Optional[str]]:
"""
Download audio file from URL.
Args:
url: Audio file URL
output_path: Directory to save file
filename: Output filename (without extension)
Returns:
Tuple of (success, message, file_path)
"""
try:
os.makedirs(output_path, exist_ok=True)
async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=60, follow_redirects=True)
if response.status_code != 200:
return False, f"Download failed: HTTP {response.status_code}", None
# Determine file extension from content-type
content_type = response.headers.get("content-type", "")
if "mpeg" in content_type:
ext = ".mp3"
elif "wav" in content_type:
ext = ".wav"
elif "ogg" in content_type:
ext = ".ogg"
else:
ext = ".mp3" # Default to mp3
file_path = os.path.join(output_path, f"{filename}{ext}")
with open(file_path, "wb") as f:
f.write(response.content)
return True, "Download complete", file_path
except Exception as e:
return False, f"Download error: {str(e)}", None
# Popular free BGM download links
FREE_BGM_SOURCES = {
"freesound": {
"name": "Freesound",
"url": "https://freesound.org/",
"license": "CC0/CC-BY/CC-BY-NC (Various)",
"description": "500,000+ CC licensed sounds, API available",
"api_available": True,
"api_url": "https://freesound.org/apiv2/apply",
},
"pixabay": {
"name": "Pixabay Music",
"url": "https://pixabay.com/music/",
"license": "Pixabay License (Free for commercial use)",
"description": "Large collection of royalty-free music",
"api_available": False,
},
"mixkit": {
"name": "Mixkit",
"url": "https://mixkit.co/free-stock-music/",
"license": "Mixkit License (Free for commercial use)",
"description": "High-quality free music tracks",
"api_available": False,
},
"uppbeat": {
"name": "Uppbeat",
"url": "https://uppbeat.io/",
"license": "Free tier: 10 tracks/month",
"description": "YouTube-friendly music",
"api_available": False,
},
"youtube_audio_library": {
"name": "YouTube Audio Library",
"url": "https://studio.youtube.com/channel/UC/music",
"license": "Free for YouTube videos",
"description": "Google's free music library",
"api_available": False,
},
}
def get_free_bgm_sources() -> dict:
"""Get list of recommended free BGM sources."""
return FREE_BGM_SOURCES