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>
176 lines
5.3 KiB
Python
176 lines
5.3 KiB
Python
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}
|