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 @@
from app.routers import download, process, bgm, jobs

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")

View File

@@ -0,0 +1,62 @@
from fastapi import APIRouter, BackgroundTasks, HTTPException
from app.models.schemas import DownloadRequest, DownloadResponse, JobStatus
from app.models.job_store import job_store
from app.services.downloader import download_video, detect_platform
router = APIRouter()
async def download_task(job_id: str, url: str):
"""Background task for downloading video."""
job_store.update_job(job_id, status=JobStatus.DOWNLOADING, progress=10)
success, message, video_path = await download_video(url, job_id)
if success:
job_store.update_job(
job_id,
status=JobStatus.READY_FOR_TRIM, # Ready for trimming step
video_path=video_path,
progress=30,
)
else:
job_store.update_job(
job_id,
status=JobStatus.FAILED,
error=message,
)
@router.post("/", response_model=DownloadResponse)
async def start_download(
request: DownloadRequest,
background_tasks: BackgroundTasks
):
"""Start video download from URL."""
platform = request.platform or detect_platform(request.url)
# Create job
job = job_store.create_job(original_url=request.url)
# Start background download
background_tasks.add_task(download_task, job.job_id, request.url)
return DownloadResponse(
job_id=job.job_id,
status=JobStatus.PENDING,
message=f"Download started for {platform} video"
)
@router.get("/platforms")
async def get_supported_platforms():
"""Get list of supported platforms."""
return {
"platforms": [
{"id": "douyin", "name": "抖音 (Douyin)", "domains": ["douyin.com", "iesdouyin.com"]},
{"id": "kuaishou", "name": "快手 (Kuaishou)", "domains": ["kuaishou.com", "gifshow.com"]},
{"id": "bilibili", "name": "哔哩哔哩 (Bilibili)", "domains": ["bilibili.com"]},
{"id": "tiktok", "name": "TikTok", "domains": ["tiktok.com"]},
{"id": "youtube", "name": "YouTube", "domains": ["youtube.com", "youtu.be"]},
]
}

View File

@@ -0,0 +1,163 @@
"""
Fonts Router - Korean font management for subtitles.
Provides font listing and recommendations for YouTube Shorts subtitles.
"""
from fastapi import APIRouter, HTTPException
from app.models.schemas import FontInfo, KOREAN_FONTS, FONT_RECOMMENDATIONS
router = APIRouter()
@router.get("/")
async def list_fonts():
"""
List all available Korean fonts for subtitles.
Returns font information including:
- id: System font name to use in subtitle_style.font_name
- name: Display name in Korean
- style: Font style description
- recommended_for: Content types this font works well with
- download_url: Where to download the font
- license: Font license information
"""
fonts = []
for font_id, font_info in KOREAN_FONTS.items():
fonts.append({
"id": font_info.id,
"name": font_info.name,
"style": font_info.style,
"recommended_for": font_info.recommended_for,
"download_url": font_info.download_url,
"license": font_info.license,
})
return {
"fonts": fonts,
"total": len(fonts),
"default": "NanumGothic",
"usage": "Set subtitle_style.font_name to the font id",
}
@router.get("/recommend/{content_type}")
async def recommend_fonts(content_type: str):
"""
Get font recommendations for a specific content type.
Available content types:
- tutorial: 튜토리얼, 강의
- gaming: 게임, 리액션
- cooking: 요리, 먹방
- comedy: 코미디, 유머
- travel: 여행, 브이로그
- news: 뉴스, 정보
- asmr: ASMR, 릴렉스
- fitness: 운동, 피트니스
- tech: 기술, IT
- lifestyle: 라이프스타일, 일상
"""
content_type_lower = content_type.lower()
if content_type_lower not in FONT_RECOMMENDATIONS:
available_types = list(FONT_RECOMMENDATIONS.keys())
raise HTTPException(
status_code=404,
detail=f"Unknown content type. Available: {', '.join(available_types)}"
)
recommended_ids = FONT_RECOMMENDATIONS[content_type_lower]
recommendations = []
for font_id in recommended_ids:
if font_id in KOREAN_FONTS:
font = KOREAN_FONTS[font_id]
recommendations.append({
"id": font.id,
"name": font.name,
"style": font.style,
"download_url": font.download_url,
})
return {
"content_type": content_type_lower,
"recommendations": recommendations,
"primary": recommended_ids[0] if recommended_ids else "NanumGothic",
}
@router.get("/categories")
async def list_font_categories():
"""
List fonts grouped by style category.
"""
categories = {
"clean": {
"name": "깔끔/모던",
"description": "정보성 콘텐츠, 튜토리얼에 적합",
"fonts": ["Pretendard", "SpoqaHanSansNeo", "NanumGothic"],
},
"friendly": {
"name": "친근/둥글",
"description": "일상, 라이프스타일 콘텐츠에 적합",
"fonts": ["GmarketSans", "NanumSquareRound", "Cafe24SsurroundAir"],
},
"handwriting": {
"name": "손글씨/캐주얼",
"description": "먹방, 요리, 유머 콘텐츠에 적합",
"fonts": ["BMDoHyeon", "BMJua", "DoHyeon"],
},
"impact": {
"name": "강조/임팩트",
"description": "게임, 하이라이트, 리액션에 적합",
"fonts": ["Cafe24Ssurround", "BlackHanSans"],
},
}
# Add font details to each category
for category_id, category_info in categories.items():
font_details = []
for font_id in category_info["fonts"]:
if font_id in KOREAN_FONTS:
font = KOREAN_FONTS[font_id]
font_details.append({
"id": font.id,
"name": font.name,
})
category_info["font_details"] = font_details
return {
"categories": categories,
}
@router.get("/{font_id}")
async def get_font(font_id: str):
"""
Get detailed information about a specific font.
"""
if font_id not in KOREAN_FONTS:
available_fonts = list(KOREAN_FONTS.keys())
raise HTTPException(
status_code=404,
detail=f"Font not found. Available fonts: {', '.join(available_fonts)}"
)
font = KOREAN_FONTS[font_id]
return {
"id": font.id,
"name": font.name,
"style": font.style,
"recommended_for": font.recommended_for,
"download_url": font.download_url,
"license": font.license,
"usage_example": {
"subtitle_style": {
"font_name": font.id,
"font_size": 36,
"position": "center",
}
},
}

175
backend/app/routers/jobs.py Normal file
View File

@@ -0,0 +1,175 @@
import os
import shutil
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from app.models.schemas import JobInfo
from app.models.job_store import job_store
from app.config import settings
router = APIRouter()
@router.get("/", response_model=list[JobInfo])
async def list_jobs(limit: int = 50):
"""List all jobs."""
return job_store.list_jobs(limit=limit)
@router.get("/{job_id}", response_model=JobInfo)
async def get_job(job_id: str):
"""Get job details."""
job = job_store.get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
print(f"[API GET] Job {job_id}: status={job.status}, progress={job.progress}")
return job
@router.delete("/{job_id}")
async def delete_job(job_id: str):
"""Delete a job and its files."""
job = job_store.get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
# Delete associated files
download_dir = os.path.join(settings.DOWNLOAD_DIR, job_id)
processed_dir = os.path.join(settings.PROCESSED_DIR, job_id)
if os.path.exists(download_dir):
shutil.rmtree(download_dir)
if os.path.exists(processed_dir):
shutil.rmtree(processed_dir)
job_store.delete_job(job_id)
return {"message": f"Job {job_id} deleted"}
@router.get("/{job_id}/download")
async def download_output(job_id: str):
"""Download the processed video."""
job = job_store.get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
if not job.output_path or not os.path.exists(job.output_path):
raise HTTPException(status_code=404, detail="Output file not found")
return FileResponse(
path=job.output_path,
media_type="video/mp4",
filename=f"shorts_{job_id}.mp4"
)
@router.get("/{job_id}/original")
async def download_original(job_id: str):
"""Download the original video."""
job = job_store.get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
if not job.video_path or not os.path.exists(job.video_path):
raise HTTPException(status_code=404, detail="Original video not found")
filename = os.path.basename(job.video_path)
# Disable caching to ensure trimmed video is always fetched fresh
return FileResponse(
path=job.video_path,
media_type="video/mp4",
filename=filename,
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0"
}
)
@router.get("/{job_id}/subtitle")
async def download_subtitle(job_id: str, format: str = "ass"):
"""Download the subtitle file."""
job = job_store.get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
if not job.video_path:
raise HTTPException(status_code=404, detail="Video not found")
job_dir = os.path.dirname(job.video_path)
subtitle_path = os.path.join(job_dir, f"subtitle.{format}")
if not os.path.exists(subtitle_path):
# Try to generate from transcript
if job.transcript:
from app.services.transcriber import segments_to_ass, segments_to_srt
if format == "srt":
content = segments_to_srt(job.transcript, use_translated=True)
else:
content = segments_to_ass(job.transcript, use_translated=True)
with open(subtitle_path, "w", encoding="utf-8") as f:
f.write(content)
else:
raise HTTPException(status_code=404, detail="Subtitle not found")
return FileResponse(
path=subtitle_path,
media_type="text/plain",
filename=f"subtitle_{job_id}.{format}"
)
@router.get("/{job_id}/thumbnail")
async def download_thumbnail(job_id: str):
"""Download the generated thumbnail image."""
job = job_store.get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
# Check for thumbnail in processed directory
thumbnail_path = os.path.join(settings.PROCESSED_DIR, f"{job_id}_thumbnail.jpg")
if not os.path.exists(thumbnail_path):
raise HTTPException(status_code=404, detail="Thumbnail not found. Generate it first using /process/{job_id}/thumbnail")
return FileResponse(
path=thumbnail_path,
media_type="image/jpeg",
filename=f"thumbnail_{job_id}.jpg"
)
@router.post("/{job_id}/re-edit")
async def re_edit_job(job_id: str):
"""Reset job status to awaiting_review for re-editing."""
from app.models.schemas import JobStatus
job = job_store.get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
if job.status != JobStatus.COMPLETED:
raise HTTPException(
status_code=400,
detail="Only completed jobs can be re-edited"
)
# Check if transcript exists for re-editing
if not job.transcript:
raise HTTPException(
status_code=400,
detail="No transcript found. Cannot re-edit."
)
# Reset status to awaiting_review
job_store.update_job(
job_id,
status=JobStatus.AWAITING_REVIEW,
progress=70,
error=None
)
return {"message": "Job ready for re-editing", "job_id": job_id}

File diff suppressed because it is too large Load Diff