2257 lines
88 KiB
Python
2257 lines
88 KiB
Python
import os
|
||
import uuid
|
||
import subprocess
|
||
import threading
|
||
import json
|
||
import shutil
|
||
import glob
|
||
import time
|
||
import asyncio
|
||
from dotenv import load_dotenv
|
||
from typing import Dict, Optional, List
|
||
from contextlib import asynccontextmanager
|
||
from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Request, Header, BackgroundTasks
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.responses import HTMLResponse
|
||
from pydantic import BaseModel
|
||
from s3_uploader import upload_job_artifacts, list_all_clips, upload_actor_to_s3, list_actor_gallery, upload_video_to_gallery, list_video_gallery
|
||
|
||
load_dotenv()
|
||
|
||
# Constants
|
||
UPLOAD_DIR = "uploads"
|
||
OUTPUT_DIR = "output"
|
||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||
|
||
# Configuration
|
||
# Default to 1 if not set, but user can set higher for powerful servers
|
||
MAX_CONCURRENT_JOBS = int(os.environ.get("MAX_CONCURRENT_JOBS", "5"))
|
||
MAX_FILE_SIZE_MB = 2048 # 2GB limit
|
||
JOB_RETENTION_SECONDS = 3600 # 1 hour retention
|
||
DISABLE_YOUTUBE_URL = os.environ.get("DISABLE_YOUTUBE_URL", "false").lower() in ("1", "true", "yes")
|
||
|
||
# Application State
|
||
job_queue = asyncio.Queue()
|
||
jobs: Dict[str, Dict] = {}
|
||
thumbnail_sessions: Dict[str, Dict] = {}
|
||
publish_jobs: Dict[str, Dict] = {} # {publish_id: {status, result, error}}
|
||
# Semester to limit concurrency to MAX_CONCURRENT_JOBS
|
||
concurrency_semaphore = asyncio.Semaphore(MAX_CONCURRENT_JOBS)
|
||
|
||
def _relocate_root_job_artifacts(job_id: str, job_output_dir: str) -> bool:
|
||
"""
|
||
Backward-compat rescue:
|
||
If main.py accidentally wrote metadata/clips into OUTPUT_DIR root (e.g. output/<jobid>_...),
|
||
move them into output/<job_id>/ so the API can find and serve them.
|
||
"""
|
||
try:
|
||
os.makedirs(job_output_dir, exist_ok=True)
|
||
root = OUTPUT_DIR
|
||
pattern = os.path.join(root, f"{job_id}_*_metadata.json")
|
||
meta_candidates = sorted(glob.glob(pattern), key=lambda p: os.path.getmtime(p), reverse=True)
|
||
if not meta_candidates:
|
||
return False
|
||
|
||
# Move the newest metadata and its associated clips.
|
||
metadata_path = meta_candidates[0]
|
||
base_name = os.path.basename(metadata_path).replace("_metadata.json", "")
|
||
|
||
# Move metadata
|
||
dest_metadata = os.path.join(job_output_dir, os.path.basename(metadata_path))
|
||
if os.path.abspath(metadata_path) != os.path.abspath(dest_metadata):
|
||
shutil.move(metadata_path, dest_metadata)
|
||
|
||
# Move any clips that match the same base_name into the job folder
|
||
clip_pattern = os.path.join(root, f"{base_name}_clip_*.mp4")
|
||
for clip_path in glob.glob(clip_pattern):
|
||
dest_clip = os.path.join(job_output_dir, os.path.basename(clip_path))
|
||
if os.path.abspath(clip_path) != os.path.abspath(dest_clip):
|
||
shutil.move(clip_path, dest_clip)
|
||
|
||
# Also move any temp_ clips that might remain
|
||
temp_clip_pattern = os.path.join(root, f"temp_{base_name}_clip_*.mp4")
|
||
for clip_path in glob.glob(temp_clip_pattern):
|
||
dest_clip = os.path.join(job_output_dir, os.path.basename(clip_path))
|
||
if os.path.abspath(clip_path) != os.path.abspath(dest_clip):
|
||
shutil.move(clip_path, dest_clip)
|
||
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
async def cleanup_jobs():
|
||
"""Background task to remove old jobs and files."""
|
||
import time
|
||
print("🧹 Cleanup task started.")
|
||
while True:
|
||
try:
|
||
await asyncio.sleep(300) # Check every 5 minutes
|
||
now = time.time()
|
||
|
||
# Simple directory cleanup based on modification time
|
||
# Check OUTPUT_DIR
|
||
for job_id in os.listdir(OUTPUT_DIR):
|
||
job_path = os.path.join(OUTPUT_DIR, job_id)
|
||
if os.path.isdir(job_path):
|
||
if now - os.path.getmtime(job_path) > JOB_RETENTION_SECONDS:
|
||
print(f"🧹 Purging old job: {job_id}")
|
||
shutil.rmtree(job_path, ignore_errors=True)
|
||
if job_id in jobs:
|
||
del jobs[job_id]
|
||
|
||
# Cleanup SaaSShorts jobs from memory
|
||
try:
|
||
saas_expired = [
|
||
jid for jid, jdata in list(saas_jobs.items())
|
||
if jdata.get("status") in ("completed", "failed")
|
||
and jdata.get("output_dir")
|
||
and os.path.isdir(jdata["output_dir"])
|
||
and now - os.path.getmtime(jdata["output_dir"]) > JOB_RETENTION_SECONDS
|
||
]
|
||
for jid in saas_expired:
|
||
del saas_jobs[jid]
|
||
except NameError:
|
||
pass
|
||
|
||
# Cleanup Uploads
|
||
for filename in os.listdir(UPLOAD_DIR):
|
||
file_path = os.path.join(UPLOAD_DIR, filename)
|
||
try:
|
||
if now - os.path.getmtime(file_path) > JOB_RETENTION_SECONDS:
|
||
os.remove(file_path)
|
||
except Exception: pass
|
||
|
||
except Exception as e:
|
||
print(f"⚠️ Cleanup error: {e}")
|
||
|
||
async def process_queue():
|
||
"""Background worker to process jobs from the queue with concurrency limit."""
|
||
print(f"🚀 Job Queue Worker started with {MAX_CONCURRENT_JOBS} concurrent slots.")
|
||
while True:
|
||
try:
|
||
# Wait for a job
|
||
job_id = await job_queue.get()
|
||
|
||
# Acquire semaphore slot (waits if max jobs are running)
|
||
await concurrency_semaphore.acquire()
|
||
print(f"🔄 Acquired slot for job: {job_id}")
|
||
|
||
# Process in background task to not block the loop (allowing other slots to fill)
|
||
asyncio.create_task(run_job_wrapper(job_id))
|
||
|
||
except Exception as e:
|
||
print(f"❌ Queue dispatch error: {e}")
|
||
await asyncio.sleep(1)
|
||
|
||
async def run_job_wrapper(job_id):
|
||
"""Wrapper to run job and release semaphore"""
|
||
try:
|
||
job = jobs.get(job_id)
|
||
if job:
|
||
await run_job(job_id, job)
|
||
except Exception as e:
|
||
print(f"❌ Job wrapper error {job_id}: {e}")
|
||
finally:
|
||
# Always release semaphore and mark queue task done
|
||
concurrency_semaphore.release()
|
||
job_queue.task_done()
|
||
print(f"✅ Released slot for job: {job_id}")
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
# Start worker and cleanup
|
||
worker_task = asyncio.create_task(process_queue())
|
||
cleanup_task = asyncio.create_task(cleanup_jobs())
|
||
yield
|
||
# Cleanup (optional: cancel worker)
|
||
|
||
app = FastAPI(lifespan=lifespan)
|
||
|
||
# Enable CORS for frontend
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=["*"],
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
# Mount static files for serving videos
|
||
app.mount("/videos", StaticFiles(directory=OUTPUT_DIR), name="videos")
|
||
|
||
# Mount static files for serving thumbnails
|
||
THUMBNAILS_DIR = os.path.join(OUTPUT_DIR, "thumbnails")
|
||
os.makedirs(THUMBNAILS_DIR, exist_ok=True)
|
||
app.mount("/thumbnails", StaticFiles(directory=THUMBNAILS_DIR), name="thumbnails")
|
||
|
||
class ProcessRequest(BaseModel):
|
||
url: str
|
||
|
||
def enqueue_output(out, job_id):
|
||
"""Reads output from a subprocess and appends it to jobs logs."""
|
||
try:
|
||
for line in iter(out.readline, b''):
|
||
decoded_line = line.decode('utf-8').strip()
|
||
if decoded_line:
|
||
print(f"📝 [Job Output] {decoded_line}")
|
||
if job_id in jobs:
|
||
jobs[job_id]['logs'].append(decoded_line)
|
||
except Exception as e:
|
||
print(f"Error reading output for job {job_id}: {e}")
|
||
finally:
|
||
out.close()
|
||
|
||
async def run_job(job_id, job_data):
|
||
"""Executes the subprocess for a specific job."""
|
||
|
||
cmd = job_data['cmd']
|
||
env = job_data['env']
|
||
output_dir = job_data['output_dir']
|
||
|
||
jobs[job_id]['status'] = 'processing'
|
||
jobs[job_id]['logs'].append("Job started by worker.")
|
||
print(f"🎬 [run_job] Executing command for {job_id}: {' '.join(cmd)}")
|
||
|
||
try:
|
||
process = subprocess.Popen(
|
||
cmd,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.STDOUT, # Merge stderr to stdout
|
||
env=env,
|
||
cwd=os.getcwd()
|
||
)
|
||
|
||
# We need to capture logs in a thread because Popen isn't async
|
||
t_log = threading.Thread(target=enqueue_output, args=(process.stdout, job_id))
|
||
t_log.daemon = True
|
||
t_log.start()
|
||
|
||
# Async wait for process with incremental updates
|
||
start_wait = time.time()
|
||
while process.poll() is None:
|
||
await asyncio.sleep(2)
|
||
|
||
# Check for partial results every 2 seconds
|
||
# Look for metadata file
|
||
try:
|
||
json_files = glob.glob(os.path.join(output_dir, "*_metadata.json"))
|
||
if json_files:
|
||
target_json = json_files[0]
|
||
# Read metadata (it might be being written to, so simple try/except or just read)
|
||
# Use a lock or just robust read? json.load might fail if file is partial.
|
||
# Usually main.py writes it once at start (based on my review).
|
||
if os.path.getsize(target_json) > 0:
|
||
with open(target_json, 'r') as f:
|
||
data = json.load(f)
|
||
|
||
base_name = os.path.basename(target_json).replace('_metadata.json', '')
|
||
clips = data.get('shorts', [])
|
||
cost_analysis = data.get('cost_analysis')
|
||
|
||
# Check which clips actually exist on disk
|
||
ready_clips = []
|
||
for i, clip in enumerate(clips):
|
||
clip_filename = f"{base_name}_clip_{i+1}.mp4"
|
||
clip_path = os.path.join(output_dir, clip_filename)
|
||
if os.path.exists(clip_path) and os.path.getsize(clip_path) > 0:
|
||
# Checking if file is growing? For now assume if it exists and main.py moves it there, it's done.
|
||
# main.py writes to temp_... then moves to final name. So presence means ready!
|
||
clip['video_url'] = f"/videos/{job_id}/{clip_filename}"
|
||
ready_clips.append(clip)
|
||
|
||
if ready_clips:
|
||
jobs[job_id]['result'] = {'clips': ready_clips, 'cost_analysis': cost_analysis}
|
||
except Exception as e:
|
||
# Ignore read errors during processing
|
||
pass
|
||
|
||
returncode = process.returncode
|
||
|
||
if returncode == 0:
|
||
jobs[job_id]['status'] = 'completed'
|
||
jobs[job_id]['logs'].append("Process finished successfully.")
|
||
|
||
# Start S3 upload in background (silent, non-blocking)
|
||
loop = asyncio.get_event_loop()
|
||
loop.run_in_executor(None, upload_job_artifacts, output_dir, job_id)
|
||
|
||
# Find result JSON
|
||
json_files = glob.glob(os.path.join(output_dir, "*_metadata.json"))
|
||
if not json_files:
|
||
# Backward-compat rescue if outputs were written to OUTPUT_DIR root
|
||
if _relocate_root_job_artifacts(job_id, output_dir):
|
||
json_files = glob.glob(os.path.join(output_dir, "*_metadata.json"))
|
||
if json_files:
|
||
target_json = json_files[0]
|
||
with open(target_json, 'r') as f:
|
||
data = json.load(f)
|
||
|
||
# Enhance result with video URLs
|
||
base_name = os.path.basename(target_json).replace('_metadata.json', '')
|
||
clips = data.get('shorts', [])
|
||
cost_analysis = data.get('cost_analysis')
|
||
|
||
for i, clip in enumerate(clips):
|
||
clip_filename = f"{base_name}_clip_{i+1}.mp4"
|
||
clip['video_url'] = f"/videos/{job_id}/{clip_filename}"
|
||
|
||
jobs[job_id]['result'] = {'clips': clips, 'cost_analysis': cost_analysis}
|
||
else:
|
||
jobs[job_id]['status'] = 'failed'
|
||
jobs[job_id]['logs'].append("No metadata file generated.")
|
||
else:
|
||
jobs[job_id]['status'] = 'failed'
|
||
jobs[job_id]['logs'].append(f"Process failed with exit code {returncode}")
|
||
|
||
except Exception as e:
|
||
jobs[job_id]['status'] = 'failed'
|
||
jobs[job_id]['logs'].append(f"Execution error: {str(e)}")
|
||
|
||
@app.get("/api/config")
|
||
async def get_config():
|
||
return {"youtubeUrlEnabled": not DISABLE_YOUTUBE_URL}
|
||
|
||
@app.post("/api/process")
|
||
async def process_endpoint(
|
||
request: Request,
|
||
file: Optional[UploadFile] = File(None),
|
||
url: Optional[str] = Form(None),
|
||
acknowledged: Optional[str] = Form(None)
|
||
):
|
||
api_key = request.headers.get("X-Gemini-Key")
|
||
if not api_key:
|
||
raise HTTPException(status_code=400, detail="Missing X-Gemini-Key header")
|
||
|
||
ack_flag = str(acknowledged).lower() in ("1", "true", "yes")
|
||
|
||
# Handle JSON body manually for URL payload
|
||
content_type = request.headers.get("content-type", "")
|
||
if "application/json" in content_type:
|
||
body = await request.json()
|
||
url = body.get("url")
|
||
ack_flag = bool(body.get("acknowledged"))
|
||
|
||
if not url and not file:
|
||
raise HTTPException(status_code=400, detail="Must provide URL or File")
|
||
|
||
if not ack_flag:
|
||
raise HTTPException(status_code=400, detail="You must confirm you own the content or have rights to process it.")
|
||
|
||
if url and DISABLE_YOUTUBE_URL:
|
||
raise HTTPException(status_code=403, detail="YouTube URL ingest is disabled on this deployment. Please upload a file you own.")
|
||
|
||
# Capture attestation context for legal record (IP + timestamp + UA)
|
||
client_ip = request.client.host if request.client else "unknown"
|
||
fwd = request.headers.get("x-forwarded-for")
|
||
if fwd:
|
||
client_ip = fwd.split(",")[0].strip()
|
||
user_agent = request.headers.get("user-agent", "")
|
||
attestation = {
|
||
"acknowledged": True,
|
||
"ip": client_ip,
|
||
"user_agent": user_agent,
|
||
"timestamp": time.time(),
|
||
"source": "url" if url else "file",
|
||
}
|
||
|
||
job_id = str(uuid.uuid4())
|
||
job_output_dir = os.path.join(OUTPUT_DIR, job_id)
|
||
os.makedirs(job_output_dir, exist_ok=True)
|
||
|
||
# Prepare Command
|
||
cmd = ["python", "-u", "main.py"] # -u for unbuffered
|
||
env = os.environ.copy()
|
||
env["GEMINI_API_KEY"] = api_key # Override with key from request
|
||
|
||
if url:
|
||
cmd.extend(["-u", url])
|
||
else:
|
||
# Save uploaded file with size limit check
|
||
input_path = os.path.join(UPLOAD_DIR, f"{job_id}_{file.filename}")
|
||
|
||
# Read file in chunks to check size
|
||
size = 0
|
||
limit_bytes = MAX_FILE_SIZE_MB * 1024 * 1024
|
||
|
||
with open(input_path, "wb") as buffer:
|
||
while content := await file.read(1024 * 1024): # Read 1MB chunks
|
||
size += len(content)
|
||
if size > limit_bytes:
|
||
os.remove(input_path)
|
||
shutil.rmtree(job_output_dir)
|
||
raise HTTPException(status_code=413, detail=f"File too large. Max size {MAX_FILE_SIZE_MB}MB")
|
||
buffer.write(content)
|
||
|
||
cmd.extend(["-i", input_path])
|
||
|
||
cmd.extend(["-o", job_output_dir])
|
||
|
||
print(f"[attestation] job={job_id} ip={attestation['ip']} source={attestation['source']} ack=true")
|
||
|
||
# Enqueue Job
|
||
jobs[job_id] = {
|
||
'status': 'queued',
|
||
'logs': [f"Job {job_id} queued."],
|
||
'cmd': cmd,
|
||
'env': env,
|
||
'output_dir': job_output_dir,
|
||
'attestation': attestation
|
||
}
|
||
|
||
await job_queue.put(job_id)
|
||
|
||
return {"job_id": job_id, "status": "queued"}
|
||
|
||
@app.get("/api/status/{job_id}")
|
||
async def get_status(job_id: str):
|
||
if job_id not in jobs:
|
||
raise HTTPException(status_code=404, detail="Job not found")
|
||
|
||
job = jobs[job_id]
|
||
return {
|
||
"status": job['status'],
|
||
"logs": job['logs'],
|
||
"result": job.get('result')
|
||
}
|
||
|
||
from editor import VideoEditor
|
||
from subtitles import generate_srt, burn_subtitles, generate_srt_from_video
|
||
from hooks import add_hook_to_video
|
||
from translate import translate_video, get_supported_languages
|
||
from thumbnail import analyze_video_for_titles, refine_titles, generate_thumbnail, generate_youtube_description
|
||
|
||
class EditRequest(BaseModel):
|
||
job_id: str
|
||
clip_index: int
|
||
api_key: Optional[str] = None
|
||
input_filename: Optional[str] = None
|
||
|
||
@app.post("/api/edit")
|
||
async def edit_clip(
|
||
req: EditRequest,
|
||
x_gemini_key: Optional[str] = Header(None, alias="X-Gemini-Key")
|
||
):
|
||
# Determine API Key
|
||
final_api_key = req.api_key or x_gemini_key or os.environ.get("GEMINI_API_KEY")
|
||
|
||
if not final_api_key:
|
||
raise HTTPException(status_code=400, detail="Missing Gemini API Key (Header or Body)")
|
||
|
||
if req.job_id not in jobs:
|
||
raise HTTPException(status_code=404, detail="Job not found")
|
||
|
||
job = jobs[req.job_id]
|
||
if 'result' not in job or 'clips' not in job['result']:
|
||
raise HTTPException(status_code=400, detail="Job result not available")
|
||
|
||
try:
|
||
# Resolve Input Path: Prefer explict input_filename from frontend (chaining edits)
|
||
if req.input_filename:
|
||
# Security: Ensure just a filename, no paths
|
||
safe_name = os.path.basename(req.input_filename)
|
||
input_path = os.path.join(OUTPUT_DIR, req.job_id, safe_name)
|
||
filename = safe_name
|
||
else:
|
||
# Fallback to original clip
|
||
clip = job['result']['clips'][req.clip_index]
|
||
filename = clip['video_url'].split('/')[-1]
|
||
input_path = os.path.join(OUTPUT_DIR, req.job_id, filename)
|
||
|
||
if not os.path.exists(input_path):
|
||
raise HTTPException(status_code=404, detail=f"Video file not found: {input_path}")
|
||
|
||
# Define output path for edited video
|
||
edited_filename = f"edited_{filename}"
|
||
output_path = os.path.join(OUTPUT_DIR, req.job_id, edited_filename)
|
||
|
||
# Run editing in a thread to avoid blocking main loop
|
||
# Since VideoEditor uses blocking calls (subprocess, API wait)
|
||
def run_edit():
|
||
editor = VideoEditor(api_key=final_api_key)
|
||
|
||
# SAFE FILE RENAMING STRATEGY (Avoid UnicodeEncodeError in Docker)
|
||
# Create a safe ASCII filename in the same directory
|
||
safe_filename = f"temp_input_{req.job_id}.mp4"
|
||
safe_input_path = os.path.join(OUTPUT_DIR, req.job_id, safe_filename)
|
||
|
||
# Copy original file to safe path
|
||
# (Copy is safer than rename if something crashes, we keep original)
|
||
shutil.copy(input_path, safe_input_path)
|
||
|
||
try:
|
||
# 1. Upload (using safe path)
|
||
vid_file = editor.upload_video(safe_input_path)
|
||
|
||
# 2. Get duration
|
||
import cv2
|
||
cap = cv2.VideoCapture(safe_input_path)
|
||
fps = cap.get(cv2.CAP_PROP_FPS)
|
||
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||
duration = frame_count / fps if fps else 0
|
||
cap.release()
|
||
|
||
# Load transcript from metadata
|
||
transcript = None
|
||
try:
|
||
meta_files = glob.glob(os.path.join(OUTPUT_DIR, req.job_id, "*_metadata.json"))
|
||
if meta_files:
|
||
with open(meta_files[0], 'r') as f:
|
||
data = json.load(f)
|
||
transcript = data.get('transcript')
|
||
except Exception as e:
|
||
print(f"⚠️ Could not load transcript for editing context: {e}")
|
||
|
||
# 3. Get Plan (Filter String)
|
||
filter_data = editor.get_ffmpeg_filter(vid_file, duration, fps=fps, width=width, height=height, transcript=transcript)
|
||
|
||
# 4. Apply
|
||
# Use safe output name first
|
||
safe_output_path = os.path.join(OUTPUT_DIR, req.job_id, f"temp_output_{req.job_id}.mp4")
|
||
editor.apply_edits(safe_input_path, safe_output_path, filter_data)
|
||
|
||
# Move result to final destination (rename works even if dest name has unicode if filesystem supports it,
|
||
# but python might still struggle if locale is broken? No, os.rename usually handles it better than subprocess args)
|
||
# Actually, output_path is defined above: f"edited_{filename}"
|
||
# If filename has unicode, output_path has unicode.
|
||
# Let's hope shutil.move / os.rename works.
|
||
if os.path.exists(safe_output_path):
|
||
shutil.move(safe_output_path, output_path)
|
||
|
||
return filter_data
|
||
finally:
|
||
# Cleanup temp safe input
|
||
if os.path.exists(safe_input_path):
|
||
os.remove(safe_input_path)
|
||
|
||
# Run in thread pool
|
||
loop = asyncio.get_event_loop()
|
||
plan = await loop.run_in_executor(None, run_edit)
|
||
|
||
# Update clip URL in the job result?
|
||
# Or return new URL and let frontend handle it?
|
||
# Updating job result allows persistence if page refreshes.
|
||
|
||
new_video_url = f"/videos/{req.job_id}/{edited_filename}"
|
||
|
||
# Start a new "edited" clip entry or just update the current one?
|
||
# Let's update the current one's video_url but keep backup?
|
||
# Or return the new URL to the frontend to display.
|
||
|
||
return {
|
||
"success": True,
|
||
"new_video_url": new_video_url,
|
||
"edit_plan": plan
|
||
}
|
||
|
||
except Exception as e:
|
||
print(f"❌ Edit Error: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
class SubtitleRequest(BaseModel):
|
||
job_id: str
|
||
clip_index: int
|
||
position: str = "bottom" # top, middle, bottom
|
||
font_size: int = 16
|
||
font_name: str = "Verdana"
|
||
font_color: str = "#FFFFFF"
|
||
border_color: str = "#000000"
|
||
border_width: int = 2
|
||
bg_color: str = "#000000"
|
||
bg_opacity: float = 0.0
|
||
input_filename: Optional[str] = None
|
||
|
||
|
||
@app.get("/api/clip/{job_id}/{clip_index}/transcript")
|
||
async def get_clip_transcript(job_id: str, clip_index: int):
|
||
"""Return word-level captions for a specific clip, formatted for Remotion."""
|
||
if job_id not in jobs:
|
||
raise HTTPException(status_code=404, detail="Job not found")
|
||
|
||
output_dir = os.path.join(OUTPUT_DIR, job_id)
|
||
json_files = glob.glob(os.path.join(output_dir, "*_metadata.json"))
|
||
|
||
if not json_files:
|
||
raise HTTPException(status_code=404, detail="Metadata not found")
|
||
|
||
with open(json_files[0], 'r') as f:
|
||
data = json.load(f)
|
||
|
||
transcript = data.get('transcript')
|
||
if not transcript:
|
||
raise HTTPException(status_code=400, detail="Transcript not found in metadata")
|
||
|
||
clips = data.get('shorts', [])
|
||
if clip_index >= len(clips):
|
||
raise HTTPException(status_code=404, detail="Clip not found")
|
||
|
||
clip_data = clips[clip_index]
|
||
clip_start = clip_data.get('start', 0)
|
||
clip_end = clip_data.get('end', 0)
|
||
|
||
# Extract words within clip range and convert to CaptionWord format
|
||
captions = []
|
||
for segment in transcript.get('segments', []):
|
||
for word_info in segment.get('words', []):
|
||
if word_info['end'] > clip_start and word_info['start'] < clip_end:
|
||
captions.append({
|
||
"text": word_info.get('word', '').strip(),
|
||
"startMs": int((max(0, word_info['start'] - clip_start)) * 1000),
|
||
"endMs": int((max(0, word_info['end'] - clip_start)) * 1000),
|
||
})
|
||
|
||
duration_sec = clip_end - clip_start
|
||
|
||
return {
|
||
"captions": captions,
|
||
"durationSec": duration_sec,
|
||
"language": transcript.get('language', 'en'),
|
||
}
|
||
|
||
|
||
# --- Remotion Render Proxy ---
|
||
RENDER_SERVICE_URL = os.getenv("RENDER_SERVICE_URL", "http://renderer:3100")
|
||
|
||
@app.post("/api/render")
|
||
async def proxy_render(request: Request):
|
||
"""Proxy render requests to the Node.js Remotion render service."""
|
||
import httpx
|
||
body = await request.json()
|
||
try:
|
||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||
resp = await client.post(f"{RENDER_SERVICE_URL}/render", json=body)
|
||
return resp.json()
|
||
except Exception as e:
|
||
raise HTTPException(status_code=502, detail=f"Render service unavailable: {e}")
|
||
|
||
@app.get("/api/render/{render_id}")
|
||
async def proxy_render_status(render_id: str):
|
||
"""Proxy render status polling to the Node.js Remotion render service."""
|
||
import httpx
|
||
try:
|
||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||
resp = await client.get(f"{RENDER_SERVICE_URL}/render/{render_id}")
|
||
return resp.json()
|
||
except Exception as e:
|
||
raise HTTPException(status_code=502, detail=f"Render service unavailable: {e}")
|
||
|
||
|
||
class EffectsGenerateRequest(BaseModel):
|
||
job_id: str
|
||
clip_index: int
|
||
input_filename: Optional[str] = None
|
||
|
||
@app.post("/api/effects/generate")
|
||
async def generate_effects_config(
|
||
req: EffectsGenerateRequest,
|
||
x_gemini_key: Optional[str] = Header(None, alias="X-Gemini-Key")
|
||
):
|
||
"""Generate structured EffectsConfig JSON for Remotion rendering via Gemini AI."""
|
||
final_api_key = x_gemini_key or os.environ.get("GEMINI_API_KEY")
|
||
|
||
if not final_api_key:
|
||
raise HTTPException(status_code=400, detail="Missing Gemini API Key (Header)")
|
||
|
||
if req.job_id not in jobs:
|
||
raise HTTPException(status_code=404, detail="Job not found")
|
||
|
||
job = jobs[req.job_id]
|
||
if 'result' not in job or 'clips' not in job['result']:
|
||
raise HTTPException(status_code=400, detail="Job result not available")
|
||
|
||
try:
|
||
# Resolve input path
|
||
if req.input_filename:
|
||
safe_name = os.path.basename(req.input_filename)
|
||
input_path = os.path.join(OUTPUT_DIR, req.job_id, safe_name)
|
||
else:
|
||
clip = job['result']['clips'][req.clip_index]
|
||
filename = clip['video_url'].split('/')[-1]
|
||
input_path = os.path.join(OUTPUT_DIR, req.job_id, filename)
|
||
|
||
if not os.path.exists(input_path):
|
||
raise HTTPException(status_code=404, detail=f"Video file not found: {input_path}")
|
||
|
||
def run_effects_generation():
|
||
editor = VideoEditor(api_key=final_api_key)
|
||
|
||
# Create safe ASCII filename to avoid encoding issues
|
||
safe_filename = f"temp_effects_{req.job_id}.mp4"
|
||
safe_input_path = os.path.join(OUTPUT_DIR, req.job_id, safe_filename)
|
||
shutil.copy(input_path, safe_input_path)
|
||
|
||
try:
|
||
# Upload video to Gemini
|
||
vid_file = editor.upload_video(safe_input_path)
|
||
|
||
# Get video metadata via ffprobe
|
||
probe_cmd = [
|
||
'ffprobe', '-v', 'error',
|
||
'-select_streams', 'v:0',
|
||
'-show_entries', 'stream=width,height,r_frame_rate,duration',
|
||
'-show_entries', 'format=duration',
|
||
'-of', 'json',
|
||
safe_input_path
|
||
]
|
||
probe_result = subprocess.check_output(probe_cmd).decode().strip()
|
||
probe_data = json.loads(probe_result)
|
||
|
||
stream = probe_data.get('streams', [{}])[0]
|
||
width = int(stream.get('width', 1080))
|
||
height = int(stream.get('height', 1920))
|
||
|
||
# Parse fps from r_frame_rate (e.g. "30/1")
|
||
r_frame_rate = stream.get('r_frame_rate', '30/1')
|
||
num, den = r_frame_rate.split('/')
|
||
fps = round(int(num) / int(den), 2)
|
||
|
||
# Get duration from stream or format
|
||
duration = float(stream.get('duration', 0))
|
||
if duration == 0:
|
||
duration = float(probe_data.get('format', {}).get('duration', 0))
|
||
|
||
# Load transcript from metadata
|
||
transcript = None
|
||
try:
|
||
meta_files = glob.glob(os.path.join(OUTPUT_DIR, req.job_id, "*_metadata.json"))
|
||
if meta_files:
|
||
with open(meta_files[0], 'r') as f:
|
||
data = json.load(f)
|
||
transcript = data.get('transcript')
|
||
except Exception as e:
|
||
print(f"⚠️ Could not load transcript for effects config: {e}")
|
||
|
||
# Generate effects config
|
||
effects_config = editor.get_effects_config(
|
||
vid_file, duration, fps=fps, width=width, height=height, transcript=transcript
|
||
)
|
||
|
||
return effects_config
|
||
finally:
|
||
if os.path.exists(safe_input_path):
|
||
os.remove(safe_input_path)
|
||
|
||
loop = asyncio.get_event_loop()
|
||
effects_config = await loop.run_in_executor(None, run_effects_generation)
|
||
|
||
if effects_config is None:
|
||
raise HTTPException(status_code=500, detail="Failed to generate effects config from Gemini")
|
||
|
||
return {"effects": effects_config}
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
print(f"❌ Effects Generation Error: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@app.post("/api/subtitle")
|
||
async def add_subtitles(req: SubtitleRequest):
|
||
if req.job_id not in jobs:
|
||
raise HTTPException(status_code=404, detail="Job not found")
|
||
|
||
# Reload job data from disk just in case metadata was updated
|
||
job = jobs[req.job_id]
|
||
|
||
# We need to access metadata.json to get the transcript
|
||
output_dir = os.path.join(OUTPUT_DIR, req.job_id)
|
||
json_files = glob.glob(os.path.join(output_dir, "*_metadata.json"))
|
||
|
||
if not json_files:
|
||
raise HTTPException(status_code=404, detail="Metadata not found")
|
||
|
||
with open(json_files[0], 'r') as f:
|
||
data = json.load(f)
|
||
|
||
transcript = data.get('transcript')
|
||
if not transcript:
|
||
raise HTTPException(status_code=400, detail="Transcript not found in metadata. Please process a new video.")
|
||
|
||
clips = data.get('shorts', [])
|
||
if req.clip_index >= len(clips):
|
||
raise HTTPException(status_code=404, detail="Clip not found")
|
||
|
||
clip_data = clips[req.clip_index]
|
||
|
||
# Video Path
|
||
if req.input_filename:
|
||
# Use chained file
|
||
filename = os.path.basename(req.input_filename)
|
||
else:
|
||
# Fallback to standard naming
|
||
filename = clip_data.get('video_url', '').split('/')[-1]
|
||
if not filename:
|
||
base_name = os.path.basename(json_files[0]).replace('_metadata.json', '')
|
||
filename = f"{base_name}_clip_{req.clip_index+1}.mp4"
|
||
|
||
input_path = os.path.join(output_dir, filename)
|
||
if not os.path.exists(input_path):
|
||
# Try looking for edited version if url implied it?
|
||
# Just fail if not found.
|
||
raise HTTPException(status_code=404, detail=f"Video file not found: {input_path}")
|
||
|
||
# Define outputs
|
||
srt_filename = f"subs_{req.clip_index}_{int(time.time())}.srt"
|
||
srt_path = os.path.join(output_dir, srt_filename)
|
||
|
||
# Output video
|
||
# We create a new file "subtitled_..."
|
||
output_filename = f"subtitled_{filename}"
|
||
output_path = os.path.join(output_dir, output_filename)
|
||
|
||
try:
|
||
# 1. Generate SRT
|
||
# Check if this is a dubbed video - if so, transcribe it fresh
|
||
is_dubbed = filename.startswith("translated_")
|
||
|
||
if is_dubbed:
|
||
print(f"🎙️ Dubbed video detected, transcribing audio for subtitles...")
|
||
def run_transcribe_srt():
|
||
return generate_srt_from_video(input_path, srt_path)
|
||
|
||
loop = asyncio.get_event_loop()
|
||
success = await loop.run_in_executor(None, run_transcribe_srt)
|
||
else:
|
||
success = generate_srt(transcript, clip_data['start'], clip_data['end'], srt_path)
|
||
|
||
if not success:
|
||
raise HTTPException(status_code=400, detail="No words found for this clip range.")
|
||
|
||
# 2. Burn Subtitles
|
||
# Run in thread pool
|
||
def run_burn():
|
||
burn_subtitles(input_path, srt_path, output_path,
|
||
alignment=req.position, fontsize=req.font_size,
|
||
font_name=req.font_name, font_color=req.font_color,
|
||
border_color=req.border_color, border_width=req.border_width,
|
||
bg_color=req.bg_color, bg_opacity=req.bg_opacity)
|
||
|
||
loop = asyncio.get_event_loop()
|
||
await loop.run_in_executor(None, run_burn)
|
||
|
||
except Exception as e:
|
||
print(f"❌ Subtitle Error: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
# 3. Update Result and Metadata
|
||
# Update InMemory Jobs
|
||
if req.clip_index < len(job['result']['clips']):
|
||
job['result']['clips'][req.clip_index]['video_url'] = f"/videos/{req.job_id}/{output_filename}"
|
||
|
||
# Update Metadata on Disk (Persistence)
|
||
try:
|
||
if req.clip_index < len(clips):
|
||
clips[req.clip_index]['video_url'] = f"/videos/{req.job_id}/{output_filename}"
|
||
# Update the main data structure
|
||
data['shorts'] = clips
|
||
|
||
# Write back
|
||
with open(json_files[0], 'w') as f:
|
||
json.dump(data, f, indent=4)
|
||
print(f"✅ Metadata updated with subtitled video for clip {req.clip_index}")
|
||
except Exception as e:
|
||
print(f"⚠️ Failed to update metadata.json: {e}")
|
||
# Non-critical, but good for persistence
|
||
|
||
return {
|
||
"success": True,
|
||
"new_video_url": f"/videos/{req.job_id}/{output_filename}"
|
||
}
|
||
|
||
class HookRequest(BaseModel):
|
||
job_id: str
|
||
clip_index: int
|
||
text: str
|
||
input_filename: Optional[str] = None
|
||
position: Optional[str] = "top" # top, center, bottom
|
||
size: Optional[str] = "M" # S, M, L
|
||
|
||
@app.post("/api/hook")
|
||
async def add_hook(req: HookRequest):
|
||
if req.job_id not in jobs:
|
||
raise HTTPException(status_code=404, detail="Job not found")
|
||
|
||
job = jobs[req.job_id]
|
||
output_dir = os.path.join(OUTPUT_DIR, req.job_id)
|
||
json_files = glob.glob(os.path.join(output_dir, "*_metadata.json"))
|
||
|
||
if not json_files:
|
||
raise HTTPException(status_code=404, detail="Metadata not found")
|
||
|
||
with open(json_files[0], 'r') as f:
|
||
data = json.load(f)
|
||
|
||
clips = data.get('shorts', [])
|
||
if req.clip_index >= len(clips):
|
||
raise HTTPException(status_code=404, detail="Clip not found")
|
||
|
||
clip_data = clips[req.clip_index]
|
||
|
||
# Video Path
|
||
if req.input_filename:
|
||
filename = os.path.basename(req.input_filename)
|
||
else:
|
||
filename = clip_data.get('video_url', '').split('/')[-1]
|
||
if not filename:
|
||
base_name = os.path.basename(json_files[0]).replace('_metadata.json', '')
|
||
filename = f"{base_name}_clip_{req.clip_index+1}.mp4"
|
||
|
||
input_path = os.path.join(output_dir, filename)
|
||
if not os.path.exists(input_path):
|
||
raise HTTPException(status_code=404, detail=f"Video file not found: {input_path}")
|
||
|
||
# Output video
|
||
output_filename = f"hook_{filename}"
|
||
output_path = os.path.join(output_dir, output_filename)
|
||
|
||
# Map Size to Scale
|
||
size_map = {"S": 0.8, "M": 1.0, "L": 1.3}
|
||
font_scale = size_map.get(req.size, 1.0)
|
||
|
||
try:
|
||
# Run in thread pool
|
||
def run_hook():
|
||
add_hook_to_video(input_path, req.text, output_path, position=req.position, font_scale=font_scale)
|
||
|
||
loop = asyncio.get_event_loop()
|
||
await loop.run_in_executor(None, run_hook)
|
||
|
||
except Exception as e:
|
||
print(f"❌ Hook Error: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
# Update Persistence (Same logic as subtitles)
|
||
# Update InMemory Jobs
|
||
if req.clip_index < len(job['result']['clips']):
|
||
job['result']['clips'][req.clip_index]['video_url'] = f"/videos/{req.job_id}/{output_filename}"
|
||
|
||
# Update Metadata on Disk
|
||
try:
|
||
if req.clip_index < len(clips):
|
||
clips[req.clip_index]['video_url'] = f"/videos/{req.job_id}/{output_filename}"
|
||
data['shorts'] = clips
|
||
with open(json_files[0], 'w') as f:
|
||
json.dump(data, f, indent=4)
|
||
print(f"✅ Metadata updated with hook video for clip {req.clip_index}")
|
||
except Exception as e:
|
||
print(f"⚠️ Failed to update metadata.json: {e}")
|
||
|
||
return {
|
||
"success": True,
|
||
"new_video_url": f"/videos/{req.job_id}/{output_filename}"
|
||
}
|
||
|
||
class TranslateRequest(BaseModel):
|
||
job_id: str
|
||
clip_index: int
|
||
target_language: str
|
||
source_language: Optional[str] = None
|
||
input_filename: Optional[str] = None
|
||
|
||
@app.get("/api/translate/languages")
|
||
async def get_languages():
|
||
"""Return supported languages for translation."""
|
||
return {"languages": get_supported_languages()}
|
||
|
||
@app.post("/api/translate")
|
||
async def translate_clip(
|
||
req: TranslateRequest,
|
||
x_elevenlabs_key: Optional[str] = Header(None, alias="X-ElevenLabs-Key")
|
||
):
|
||
"""Translate a video clip to a different language using ElevenLabs dubbing."""
|
||
if not x_elevenlabs_key:
|
||
raise HTTPException(status_code=400, detail="Missing X-ElevenLabs-Key header")
|
||
|
||
if req.job_id not in jobs:
|
||
raise HTTPException(status_code=404, detail="Job not found")
|
||
|
||
job = jobs[req.job_id]
|
||
output_dir = os.path.join(OUTPUT_DIR, req.job_id)
|
||
json_files = glob.glob(os.path.join(output_dir, "*_metadata.json"))
|
||
|
||
if not json_files:
|
||
raise HTTPException(status_code=404, detail="Metadata not found")
|
||
|
||
with open(json_files[0], 'r') as f:
|
||
data = json.load(f)
|
||
|
||
clips = data.get('shorts', [])
|
||
if req.clip_index >= len(clips):
|
||
raise HTTPException(status_code=404, detail="Clip not found")
|
||
|
||
clip_data = clips[req.clip_index]
|
||
|
||
# Video Path
|
||
if req.input_filename:
|
||
filename = os.path.basename(req.input_filename)
|
||
else:
|
||
filename = clip_data.get('video_url', '').split('/')[-1]
|
||
if not filename:
|
||
base_name = os.path.basename(json_files[0]).replace('_metadata.json', '')
|
||
filename = f"{base_name}_clip_{req.clip_index+1}.mp4"
|
||
|
||
input_path = os.path.join(output_dir, filename)
|
||
if not os.path.exists(input_path):
|
||
raise HTTPException(status_code=404, detail=f"Video file not found: {input_path}")
|
||
|
||
# Output video with language suffix
|
||
base, ext = os.path.splitext(filename)
|
||
output_filename = f"translated_{req.target_language}_{base}{ext}"
|
||
output_path = os.path.join(output_dir, output_filename)
|
||
|
||
try:
|
||
# Run translation in thread pool (blocking API calls)
|
||
def run_translate():
|
||
return translate_video(
|
||
video_path=input_path,
|
||
output_path=output_path,
|
||
target_language=req.target_language,
|
||
api_key=x_elevenlabs_key,
|
||
source_language=req.source_language,
|
||
)
|
||
|
||
loop = asyncio.get_event_loop()
|
||
await loop.run_in_executor(None, run_translate)
|
||
|
||
except Exception as e:
|
||
print(f"❌ Translation Error: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
# Update InMemory Jobs
|
||
if req.clip_index < len(job['result']['clips']):
|
||
job['result']['clips'][req.clip_index]['video_url'] = f"/videos/{req.job_id}/{output_filename}"
|
||
|
||
# Update Metadata on Disk
|
||
try:
|
||
if req.clip_index < len(clips):
|
||
clips[req.clip_index]['video_url'] = f"/videos/{req.job_id}/{output_filename}"
|
||
data['shorts'] = clips
|
||
with open(json_files[0], 'w') as f:
|
||
json.dump(data, f, indent=4)
|
||
print(f"✅ Metadata updated with translated video for clip {req.clip_index}")
|
||
except Exception as e:
|
||
print(f"⚠️ Failed to update metadata.json: {e}")
|
||
|
||
return {
|
||
"success": True,
|
||
"new_video_url": f"/videos/{req.job_id}/{output_filename}"
|
||
}
|
||
|
||
class SocialPostRequest(BaseModel):
|
||
job_id: str
|
||
clip_index: int
|
||
api_key: str
|
||
user_id: str
|
||
platforms: List[str] # ["tiktok", "instagram", "youtube"]
|
||
# Optional overrides if frontend wants to edit them
|
||
title: Optional[str] = None
|
||
description: Optional[str] = None
|
||
scheduled_date: Optional[str] = None # ISO-8601 string
|
||
timezone: Optional[str] = "UTC"
|
||
|
||
import httpx
|
||
|
||
@app.post("/api/social/post")
|
||
async def post_to_socials(req: SocialPostRequest):
|
||
if req.job_id not in jobs:
|
||
raise HTTPException(status_code=404, detail="Job not found")
|
||
|
||
job = jobs[req.job_id]
|
||
if 'result' not in job or 'clips' not in job['result']:
|
||
raise HTTPException(status_code=400, detail="Job result not available")
|
||
|
||
try:
|
||
clip = job['result']['clips'][req.clip_index]
|
||
# Video URL is relative /videos/..., we need absolute file path
|
||
# clip['video_url'] is like "/videos/{job_id}/{filename}"
|
||
# We constructed it as: f"/videos/{job_id}/{clip_filename}"
|
||
# And file is at f"{OUTPUT_DIR}/{job_id}/{clip_filename}"
|
||
|
||
filename = clip['video_url'].split('/')[-1]
|
||
file_path = os.path.join(OUTPUT_DIR, req.job_id, filename)
|
||
|
||
if not os.path.exists(file_path):
|
||
raise HTTPException(status_code=404, detail=f"Video file not found: {file_path}")
|
||
|
||
# Construct parameters for Upload-Post API
|
||
# Fallbacks
|
||
final_title = req.title or clip.get('title', 'Viral Short')
|
||
final_description = req.description or clip.get('video_description_for_instagram') or clip.get('video_description_for_tiktok') or "Check this out!"
|
||
|
||
# Prepare form data
|
||
url = "https://api.upload-post.com/api/upload"
|
||
headers = {
|
||
"Authorization": f"Apikey {req.api_key}"
|
||
}
|
||
|
||
# Prepare data as dict (httpx handles lists for multiple values)
|
||
data_payload = {
|
||
"user": req.user_id,
|
||
"title": final_title,
|
||
"platform[]": req.platforms, # Pass list directly
|
||
"async_upload": "true" # Enable async upload
|
||
}
|
||
|
||
# Add scheduling if present
|
||
if req.scheduled_date:
|
||
data_payload["scheduled_date"] = req.scheduled_date
|
||
if req.timezone:
|
||
data_payload["timezone"] = req.timezone
|
||
|
||
# Add Platform specifics
|
||
if "tiktok" in req.platforms:
|
||
data_payload["tiktok_title"] = final_description
|
||
|
||
if "instagram" in req.platforms:
|
||
data_payload["instagram_title"] = final_description
|
||
data_payload["media_type"] = "REELS"
|
||
|
||
if "youtube" in req.platforms:
|
||
yt_title = req.title or clip.get('video_title_for_youtube_short', final_title)
|
||
data_payload["youtube_title"] = yt_title
|
||
data_payload["youtube_description"] = final_description
|
||
data_payload["privacyStatus"] = "public"
|
||
|
||
# Send File
|
||
# httpx AsyncClient requires async file reading or bytes.
|
||
# Since we have MAX_FILE_SIZE_MB, reading into memory is safe-ish.
|
||
with open(file_path, "rb") as f:
|
||
file_content = f.read()
|
||
|
||
files = {
|
||
"video": (filename, file_content, "video/mp4")
|
||
}
|
||
|
||
# Switch to synchronous Client to avoid "sync request with AsyncClient" error with multipart/files
|
||
with httpx.Client(timeout=120.0) as client:
|
||
print(f"📡 Sending to Upload-Post for platforms: {req.platforms}")
|
||
response = client.post(url, headers=headers, data=data_payload, files=files)
|
||
|
||
if response.status_code not in [200, 201, 202]: # Added 201
|
||
print(f"❌ Upload-Post Error: {response.text}")
|
||
raise HTTPException(status_code=response.status_code, detail=f"Vendor API Error: {response.text}")
|
||
|
||
return response.json()
|
||
|
||
except Exception as e:
|
||
print(f"❌ Social Post Exception: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
@app.get("/api/social/user")
|
||
async def get_social_user(api_key: str = Header(..., alias="X-Upload-Post-Key")):
|
||
"""Proxy to fetch user ID from Upload-Post"""
|
||
if not api_key:
|
||
raise HTTPException(status_code=400, detail="Missing X-Upload-Post-Key header")
|
||
|
||
url = "https://api.upload-post.com/api/uploadposts/users"
|
||
print(f"🔍 Fetching User ID from: {url}")
|
||
headers = {"Authorization": f"Apikey {api_key}"}
|
||
|
||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||
try:
|
||
resp = await client.get(url, headers=headers)
|
||
if resp.status_code != 200:
|
||
print(f"❌ Upload-Post User Fetch Error: {resp.text}")
|
||
raise HTTPException(status_code=resp.status_code, detail=f"Failed to fetch user: {resp.text}")
|
||
|
||
data = resp.json()
|
||
print(f"🔍 Upload-Post User Response: {data}")
|
||
|
||
user_id = None
|
||
# The structure is {'success': True, 'profiles': [{'username': '...'}, ...]}
|
||
profiles_list = []
|
||
if isinstance(data, dict):
|
||
raw_profiles = data.get('profiles', [])
|
||
if isinstance(raw_profiles, list):
|
||
for p in raw_profiles:
|
||
username = p.get('username')
|
||
if username:
|
||
# Determine connected platforms
|
||
socials = p.get('social_accounts', {})
|
||
connected = []
|
||
# Check typical platforms
|
||
for platform in ['tiktok', 'instagram', 'youtube']:
|
||
account_info = socials.get(platform)
|
||
# If it's a dict and typically has data, or just not empty string
|
||
if isinstance(account_info, dict):
|
||
connected.append(platform)
|
||
|
||
profiles_list.append({
|
||
"username": username,
|
||
"connected": connected
|
||
})
|
||
|
||
if not profiles_list:
|
||
# Fallback if no profiles found
|
||
return {"profiles": [], "error": "No profiles found"}
|
||
|
||
return {"profiles": profiles_list}
|
||
|
||
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
# --- Thumbnail Studio Endpoints ---
|
||
|
||
@app.post("/api/thumbnail/upload")
|
||
async def thumbnail_upload(
|
||
file: Optional[UploadFile] = File(None),
|
||
url: Optional[str] = Form(None),
|
||
):
|
||
"""Upload video and start background Whisper transcription immediately."""
|
||
if not url and not file:
|
||
raise HTTPException(status_code=400, detail="Must provide URL or File")
|
||
|
||
session_id = str(uuid.uuid4())
|
||
transcript_event = asyncio.Event()
|
||
|
||
# Save file if uploaded directly
|
||
video_path = None
|
||
if file:
|
||
video_path = os.path.join(UPLOAD_DIR, f"thumb_{session_id}_{file.filename}")
|
||
with open(video_path, "wb") as buffer:
|
||
content = await file.read()
|
||
buffer.write(content)
|
||
|
||
# Initialize session
|
||
thumbnail_sessions[session_id] = {
|
||
"video_path": video_path,
|
||
"transcript_event": transcript_event,
|
||
"transcript_ready": False,
|
||
"transcript": None,
|
||
"transcript_segments": [],
|
||
"video_duration": 0,
|
||
"language": "en",
|
||
"context": "",
|
||
"titles": [],
|
||
"conversation": [],
|
||
"_url": url, # Store URL for deferred download
|
||
}
|
||
|
||
async def run_background_whisper():
|
||
try:
|
||
vpath = video_path
|
||
# Download YouTube video if URL was provided
|
||
if not vpath and url:
|
||
from main import download_youtube_video
|
||
loop = asyncio.get_event_loop()
|
||
vpath, _ = await loop.run_in_executor(None, download_youtube_video, url, UPLOAD_DIR)
|
||
thumbnail_sessions[session_id]["video_path"] = vpath
|
||
|
||
from main import transcribe_video
|
||
loop = asyncio.get_event_loop()
|
||
transcript = await loop.run_in_executor(None, transcribe_video, vpath)
|
||
segments = transcript.get("segments", [])
|
||
duration = segments[-1]["end"] if segments else 0
|
||
|
||
thumbnail_sessions[session_id].update({
|
||
"transcript_ready": True,
|
||
"transcript": transcript,
|
||
"transcript_segments": segments,
|
||
"video_duration": duration,
|
||
"language": transcript.get("language", "en"),
|
||
})
|
||
print(f"✅ [Thumbnail] Background Whisper complete for session {session_id}")
|
||
except Exception as e:
|
||
print(f"❌ [Thumbnail] Background Whisper failed: {e}")
|
||
thumbnail_sessions[session_id]["transcript_error"] = str(e)
|
||
finally:
|
||
transcript_event.set()
|
||
|
||
asyncio.create_task(run_background_whisper())
|
||
|
||
return {"session_id": session_id}
|
||
|
||
|
||
@app.post("/api/thumbnail/analyze")
|
||
async def thumbnail_analyze(
|
||
request: Request,
|
||
file: Optional[UploadFile] = File(None),
|
||
url: Optional[str] = Form(None),
|
||
session_id: Optional[str] = Form(None),
|
||
x_gemini_key: Optional[str] = Header(None, alias="X-Gemini-Key")
|
||
):
|
||
"""Analyze a video and suggest viral YouTube titles."""
|
||
api_key = x_gemini_key
|
||
if not api_key:
|
||
raise HTTPException(status_code=400, detail="Missing X-Gemini-Key header")
|
||
|
||
pre_transcript = None
|
||
|
||
# Check for pre-existing session with background Whisper
|
||
if session_id and session_id in thumbnail_sessions:
|
||
session = thumbnail_sessions[session_id]
|
||
|
||
# Wait for background Whisper to complete
|
||
transcript_event = session.get("transcript_event")
|
||
if transcript_event:
|
||
print(f"⏳ [Thumbnail] Waiting for background Whisper to finish...")
|
||
await transcript_event.wait()
|
||
|
||
if session.get("transcript_error"):
|
||
raise HTTPException(status_code=500, detail=f"Transcription failed: {session['transcript_error']}")
|
||
|
||
video_path = session["video_path"]
|
||
if not video_path or not os.path.exists(video_path):
|
||
raise HTTPException(status_code=404, detail="Video file not found in session")
|
||
|
||
if session.get("transcript_ready"):
|
||
pre_transcript = session["transcript"]
|
||
else:
|
||
# No pre-existing session — need file or URL
|
||
if not url and not file:
|
||
raise HTTPException(status_code=400, detail="Must provide URL, File, or session_id")
|
||
|
||
session_id = str(uuid.uuid4())
|
||
|
||
if url:
|
||
from main import download_youtube_video
|
||
video_path, _ = download_youtube_video(url, UPLOAD_DIR)
|
||
else:
|
||
video_path = os.path.join(UPLOAD_DIR, f"thumb_{session_id}_{file.filename}")
|
||
with open(video_path, "wb") as buffer:
|
||
content = await file.read()
|
||
buffer.write(content)
|
||
|
||
try:
|
||
# Run analysis in thread pool (skips Whisper if pre_transcript is available)
|
||
loop = asyncio.get_event_loop()
|
||
result = await loop.run_in_executor(None, analyze_video_for_titles, api_key, video_path, pre_transcript)
|
||
|
||
# Store/update session context
|
||
if session_id not in thumbnail_sessions:
|
||
thumbnail_sessions[session_id] = {}
|
||
|
||
thumbnail_sessions[session_id].update({
|
||
"context": result.get("transcript_summary", ""),
|
||
"titles": result.get("titles", []),
|
||
"language": result.get("language", "en"),
|
||
"conversation": thumbnail_sessions[session_id].get("conversation", []),
|
||
"video_path": video_path,
|
||
"transcript_segments": result.get("segments", []),
|
||
"video_duration": result.get("video_duration", 0)
|
||
})
|
||
|
||
return {
|
||
"session_id": session_id,
|
||
"titles": result.get("titles", []),
|
||
"context": result.get("transcript_summary", ""),
|
||
"language": result.get("language", "en"),
|
||
"recommended": result.get("recommended", [])
|
||
}
|
||
|
||
except Exception as e:
|
||
print(f"❌ Thumbnail Analyze Error: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
class ThumbnailTitlesRequest(BaseModel):
|
||
session_id: Optional[str] = None
|
||
message: Optional[str] = None
|
||
title: Optional[str] = None
|
||
|
||
@app.post("/api/thumbnail/titles")
|
||
async def thumbnail_titles(
|
||
req: ThumbnailTitlesRequest,
|
||
x_gemini_key: Optional[str] = Header(None, alias="X-Gemini-Key")
|
||
):
|
||
"""Refine title suggestions or accept a manual title."""
|
||
api_key = x_gemini_key
|
||
if not api_key:
|
||
raise HTTPException(status_code=400, detail="Missing X-Gemini-Key header")
|
||
|
||
# Manual title mode - just create a session with the user's title
|
||
if req.title:
|
||
session_id = req.session_id or str(uuid.uuid4())
|
||
if session_id not in thumbnail_sessions:
|
||
thumbnail_sessions[session_id] = {
|
||
"context": "",
|
||
"titles": [req.title],
|
||
"language": "en",
|
||
"conversation": []
|
||
}
|
||
return {"session_id": session_id, "titles": [req.title]}
|
||
|
||
# Refinement mode
|
||
if not req.session_id or req.session_id not in thumbnail_sessions:
|
||
raise HTTPException(status_code=404, detail="Session not found")
|
||
|
||
if not req.message:
|
||
raise HTTPException(status_code=400, detail="Must provide message or title")
|
||
|
||
session = thumbnail_sessions[req.session_id]
|
||
|
||
# Add user message to conversation history
|
||
session["conversation"].append({"role": "user", "content": req.message})
|
||
|
||
try:
|
||
loop = asyncio.get_event_loop()
|
||
result = await loop.run_in_executor(
|
||
None,
|
||
refine_titles,
|
||
api_key,
|
||
session["context"],
|
||
req.message,
|
||
session["conversation"]
|
||
)
|
||
|
||
new_titles = result.get("titles", [])
|
||
session["titles"] = new_titles
|
||
session["conversation"].append({"role": "assistant", "content": json.dumps(new_titles)})
|
||
|
||
return {"titles": new_titles}
|
||
|
||
except Exception as e:
|
||
print(f"❌ Thumbnail Titles Error: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@app.post("/api/thumbnail/generate")
|
||
async def thumbnail_generate(
|
||
request: Request,
|
||
session_id: str = Form(...),
|
||
title: str = Form(...),
|
||
extra_prompt: str = Form(""),
|
||
count: int = Form(3),
|
||
face: Optional[UploadFile] = File(None),
|
||
background: Optional[UploadFile] = File(None),
|
||
x_gemini_key: Optional[str] = Header(None, alias="X-Gemini-Key")
|
||
):
|
||
"""Generate YouTube thumbnails with Gemini image generation."""
|
||
api_key = x_gemini_key
|
||
if not api_key:
|
||
raise HTTPException(status_code=400, detail="Missing X-Gemini-Key header")
|
||
|
||
# Clamp count
|
||
count = min(max(1, count), 6)
|
||
|
||
# Save optional uploaded images
|
||
face_path = None
|
||
bg_path = None
|
||
thumb_upload_dir = os.path.join(UPLOAD_DIR, f"thumb_{session_id}")
|
||
os.makedirs(thumb_upload_dir, exist_ok=True)
|
||
|
||
try:
|
||
if face and face.filename:
|
||
face_path = os.path.join(thumb_upload_dir, f"face_{face.filename}")
|
||
with open(face_path, "wb") as f:
|
||
f.write(await face.read())
|
||
|
||
if background and background.filename:
|
||
bg_path = os.path.join(thumb_upload_dir, f"bg_{background.filename}")
|
||
with open(bg_path, "wb") as f:
|
||
f.write(await background.read())
|
||
|
||
# Get video context from session (transcript summary from analysis step)
|
||
video_context = ""
|
||
if session_id in thumbnail_sessions:
|
||
video_context = thumbnail_sessions[session_id].get("context", "")
|
||
|
||
# Run generation in thread pool
|
||
loop = asyncio.get_event_loop()
|
||
thumbnails = await loop.run_in_executor(
|
||
None,
|
||
generate_thumbnail,
|
||
api_key,
|
||
title,
|
||
session_id,
|
||
face_path,
|
||
bg_path,
|
||
extra_prompt,
|
||
count,
|
||
video_context
|
||
)
|
||
|
||
if not thumbnails:
|
||
raise HTTPException(status_code=500, detail="Thumbnail generation failed. Please check your Gemini API key has access to image generation (gemini-3.1-flash-image-preview model).")
|
||
|
||
return {"thumbnails": thumbnails}
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
print(f"❌ Thumbnail Generate Error: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
class ThumbnailDescribeRequest(BaseModel):
|
||
session_id: str
|
||
title: str
|
||
|
||
@app.post("/api/thumbnail/describe")
|
||
async def thumbnail_describe(
|
||
req: ThumbnailDescribeRequest,
|
||
x_gemini_key: Optional[str] = Header(None, alias="X-Gemini-Key")
|
||
):
|
||
"""Generate a YouTube description with chapters from the transcript."""
|
||
api_key = x_gemini_key
|
||
if not api_key:
|
||
raise HTTPException(status_code=400, detail="Missing X-Gemini-Key header")
|
||
|
||
if req.session_id not in thumbnail_sessions:
|
||
raise HTTPException(status_code=404, detail="Session not found")
|
||
|
||
session = thumbnail_sessions[req.session_id]
|
||
segments = session.get("transcript_segments", [])
|
||
if not segments:
|
||
raise HTTPException(status_code=400, detail="No transcript segments available. Please analyze a video first.")
|
||
|
||
try:
|
||
loop = asyncio.get_event_loop()
|
||
result = await loop.run_in_executor(
|
||
None,
|
||
generate_youtube_description,
|
||
api_key,
|
||
req.title,
|
||
segments,
|
||
session.get("language", "en"),
|
||
session.get("video_duration", 0)
|
||
)
|
||
return {"description": result.get("description", "")}
|
||
|
||
except Exception as e:
|
||
print(f"❌ Thumbnail Describe Error: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@app.post("/api/thumbnail/publish")
|
||
async def thumbnail_publish(
|
||
background_tasks: BackgroundTasks,
|
||
session_id: str = Form(...),
|
||
title: str = Form(...),
|
||
description: str = Form(...),
|
||
thumbnail_url: str = Form(...),
|
||
api_key: str = Form(...),
|
||
user_id: str = Form(...),
|
||
):
|
||
"""Kick off a background upload to YouTube via Upload-Post and return immediately."""
|
||
if session_id not in thumbnail_sessions:
|
||
raise HTTPException(status_code=404, detail="Session not found")
|
||
|
||
session = thumbnail_sessions[session_id]
|
||
video_path = session.get("video_path")
|
||
if not video_path or not os.path.exists(video_path):
|
||
raise HTTPException(status_code=404, detail="Original video file not found")
|
||
|
||
# Resolve thumbnail path from URL
|
||
thumb_relative = thumbnail_url.lstrip("/")
|
||
if thumb_relative.startswith("thumbnails/"):
|
||
thumb_path = os.path.join(OUTPUT_DIR, thumb_relative)
|
||
else:
|
||
thumb_path = os.path.join(THUMBNAILS_DIR, thumb_relative)
|
||
|
||
if not os.path.exists(thumb_path):
|
||
raise HTTPException(status_code=404, detail=f"Thumbnail file not found: {thumb_path}")
|
||
|
||
# Generate a unique ID for this publish job so the frontend can poll
|
||
publish_id = str(uuid.uuid4())
|
||
publish_jobs[publish_id] = {"status": "uploading", "result": None, "error": None}
|
||
|
||
def do_upload():
|
||
"""Runs in a thread via BackgroundTasks — does the actual multipart upload."""
|
||
try:
|
||
upload_url = "https://api.upload-post.com/api/upload"
|
||
headers = {"Authorization": f"Apikey {api_key}"}
|
||
data_payload = {
|
||
"user": user_id,
|
||
"platform[]": ["youtube"],
|
||
"title": title, # required base field (fallback)
|
||
"async_upload": "true",
|
||
"youtube_title": title,
|
||
"youtube_description": description,
|
||
"privacyStatus": "public",
|
||
}
|
||
video_filename = os.path.basename(video_path)
|
||
thumb_filename = os.path.basename(thumb_path)
|
||
|
||
print(f"📡 [Thumbnail] Publishing to YouTube via Upload-Post... (publish_id={publish_id})")
|
||
with open(video_path, "rb") as vf, open(thumb_path, "rb") as tf:
|
||
files = {
|
||
"video": (video_filename, vf.read(), "video/mp4"),
|
||
"thumbnail": (thumb_filename, tf.read(), "image/jpeg"),
|
||
}
|
||
|
||
# Use a long timeout — video uploads can take several minutes
|
||
with httpx.Client(timeout=600.0) as client:
|
||
response = client.post(upload_url, headers=headers, data=data_payload, files=files)
|
||
|
||
if response.status_code not in [200, 201, 202]:
|
||
err = f"Upload-Post API Error ({response.status_code}): {response.text}"
|
||
print(f"❌ {err}")
|
||
publish_jobs[publish_id]["status"] = "failed"
|
||
publish_jobs[publish_id]["error"] = err
|
||
else:
|
||
print(f"✅ [Thumbnail] Published successfully (publish_id={publish_id})")
|
||
publish_jobs[publish_id]["status"] = "done"
|
||
publish_jobs[publish_id]["result"] = response.json()
|
||
|
||
except Exception as e:
|
||
err = str(e)
|
||
print(f"❌ Thumbnail Publish Background Error: {err}")
|
||
publish_jobs[publish_id]["status"] = "failed"
|
||
publish_jobs[publish_id]["error"] = err
|
||
|
||
background_tasks.add_task(do_upload)
|
||
return {"publish_id": publish_id, "status": "uploading"}
|
||
|
||
|
||
@app.get("/api/thumbnail/publish/status/{publish_id}")
|
||
async def thumbnail_publish_status(publish_id: str):
|
||
"""Poll the status of a background publish job."""
|
||
if publish_id not in publish_jobs:
|
||
raise HTTPException(status_code=404, detail="Publish job not found")
|
||
return publish_jobs[publish_id]
|
||
|
||
|
||
# @app.get("/api/gallery/clips")
|
||
# async def get_gallery_clips(limit: int = 20, offset: int = 0, refresh: bool = False):
|
||
# """
|
||
# Fetch clips from S3 for the gallery with pagination.
|
||
#
|
||
# Args:
|
||
# limit: Number of clips to return (default 20, max 100)
|
||
# offset: Starting position for pagination
|
||
# refresh: Force refresh cache
|
||
# """
|
||
# try:
|
||
# # Clamp limit to reasonable values
|
||
# limit = min(max(1, limit), 100)
|
||
#
|
||
# # Get clips (uses cache internally)
|
||
# all_clips = list_all_clips(limit=limit + offset, force_refresh=refresh)
|
||
#
|
||
# # Apply offset for pagination
|
||
# clips = all_clips[offset:offset + limit]
|
||
#
|
||
# return {
|
||
# "clips": clips,
|
||
# "total": len(all_clips),
|
||
# "limit": limit,
|
||
# "offset": offset,
|
||
# "has_more": len(all_clips) > offset + limit
|
||
# }
|
||
# except Exception as e:
|
||
# print(f"❌ Gallery Error: {e}")
|
||
# raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# SaaSShorts: AI UGC Video Generator for SaaS Products
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
from saasshorts import (
|
||
scrape_website,
|
||
research_saas_online,
|
||
analyze_saas,
|
||
generate_scripts,
|
||
generate_full_video,
|
||
generate_actor_images,
|
||
get_elevenlabs_voices,
|
||
DEFAULT_VOICES,
|
||
)
|
||
|
||
# State for SaaSShorts jobs (separate from video processing jobs)
|
||
saas_jobs: Dict[str, Dict] = {}
|
||
|
||
|
||
class SaaSAnalyzeRequest(BaseModel):
|
||
url: Optional[str] = None
|
||
description: Optional[str] = None # Manual product/business description
|
||
num_scripts: int = 3
|
||
style: str = "ugc"
|
||
language: str = "en"
|
||
actor_gender: str = "female"
|
||
|
||
|
||
@app.post("/api/saasshorts/analyze")
|
||
async def saasshorts_analyze(
|
||
req: SaaSAnalyzeRequest,
|
||
x_gemini_key: Optional[str] = Header(None, alias="X-Gemini-Key"),
|
||
):
|
||
"""Analyze a URL or manual description and generate video scripts."""
|
||
gemini_key = x_gemini_key or os.environ.get("GEMINI_API_KEY")
|
||
if not gemini_key:
|
||
raise HTTPException(status_code=400, detail="Missing Gemini API Key")
|
||
|
||
if not req.url and not req.description:
|
||
raise HTTPException(status_code=400, detail="Provide a URL or a product description")
|
||
|
||
try:
|
||
loop = asyncio.get_event_loop()
|
||
|
||
def run_analysis():
|
||
web_research = None
|
||
|
||
if req.url and req.url.strip():
|
||
# URL provided: full scrape + research pipeline
|
||
scraped = scrape_website(req.url)
|
||
web_research = research_saas_online(req.url, gemini_key)
|
||
analysis = analyze_saas(scraped, gemini_key, web_research=web_research)
|
||
else:
|
||
# Manual description: build analysis from description
|
||
analysis = {
|
||
"product_name": req.description.split(",")[0].strip()[:60] if req.description else "Product",
|
||
"description": req.description,
|
||
"value_proposition": req.description,
|
||
"target_audience": "general audience",
|
||
"key_features": [req.description],
|
||
"pain_points": [],
|
||
"tone": "casual and authentic",
|
||
}
|
||
|
||
scripts = generate_scripts(analysis, gemini_key, req.num_scripts, req.style, req.language, req.actor_gender)
|
||
return {
|
||
"analysis": analysis,
|
||
"scripts": scripts,
|
||
"web_research": web_research,
|
||
}
|
||
|
||
result = await loop.run_in_executor(None, run_analysis)
|
||
return result
|
||
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
class SaaSActorRequest(BaseModel):
|
||
actor_description: str
|
||
num_options: int = 3
|
||
product_description: Optional[str] = None
|
||
|
||
|
||
@app.post("/api/saasshorts/actor-upload")
|
||
async def saasshorts_actor_upload(file: UploadFile = File(...)):
|
||
"""Upload a custom actor image (stored locally only, not S3)."""
|
||
if not file.content_type or not file.content_type.startswith("image/"):
|
||
raise HTTPException(status_code=400, detail="File must be an image")
|
||
|
||
try:
|
||
content = await file.read()
|
||
|
||
# Validate minimum size
|
||
if len(content) < 1000:
|
||
raise HTTPException(status_code=400, detail="File too small to be a valid image")
|
||
|
||
upload_id = uuid.uuid4().hex[:8]
|
||
upload_dir = os.path.join(OUTPUT_DIR, "actor_uploads")
|
||
os.makedirs(upload_dir, exist_ok=True)
|
||
filename = f"custom_{upload_id}.png"
|
||
file_path = os.path.join(upload_dir, filename)
|
||
|
||
with open(file_path, "wb") as f:
|
||
f.write(content)
|
||
|
||
return {"url": f"/videos/actor_uploads/{filename}"}
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@app.post("/api/saasshorts/actor-options")
|
||
async def saasshorts_actor_options(
|
||
req: SaaSActorRequest,
|
||
x_fal_key: Optional[str] = Header(None, alias="X-Fal-Key"),
|
||
):
|
||
"""Generate multiple actor image options for the user to choose from."""
|
||
fal_key = x_fal_key
|
||
if not fal_key:
|
||
raise HTTPException(status_code=400, detail="Missing fal.ai API Key")
|
||
|
||
try:
|
||
job_id = str(uuid.uuid4())
|
||
out_dir = os.path.join(OUTPUT_DIR, f"saas_actors_{job_id}")
|
||
os.makedirs(out_dir, exist_ok=True)
|
||
|
||
loop = asyncio.get_running_loop()
|
||
import functools
|
||
paths = await loop.run_in_executor(
|
||
None,
|
||
functools.partial(
|
||
generate_actor_images,
|
||
req.actor_description, fal_key, out_dir, "actor", req.num_options,
|
||
product_description=req.product_description,
|
||
),
|
||
)
|
||
|
||
# Upload each actor image to public S3 with description
|
||
desc = req.actor_description
|
||
if req.product_description:
|
||
desc += f" (holding {req.product_description})"
|
||
urls = []
|
||
for p in paths:
|
||
s3_url = upload_actor_to_s3(p, description=desc)
|
||
if s3_url:
|
||
urls.append(s3_url)
|
||
else:
|
||
# Fallback to local URL if S3 fails
|
||
urls.append(f"/videos/saas_actors_{job_id}/{os.path.basename(p)}")
|
||
|
||
return {"images": urls}
|
||
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@app.get("/api/saasshorts/gallery")
|
||
async def saasshorts_video_gallery(limit: int = 50):
|
||
"""List all UGC videos from the public gallery."""
|
||
try:
|
||
loop = asyncio.get_running_loop()
|
||
videos = await loop.run_in_executor(None, list_video_gallery, limit)
|
||
return {"videos": videos, "total": len(videos)}
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
class SaaSPostRequest(BaseModel):
|
||
job_id: str
|
||
api_key: str
|
||
user_id: str
|
||
platforms: List[str]
|
||
title: Optional[str] = None
|
||
description: Optional[str] = None
|
||
scheduled_date: Optional[str] = None
|
||
timezone: Optional[str] = "UTC"
|
||
|
||
|
||
@app.post("/api/saasshorts/post")
|
||
async def saasshorts_post_to_socials(req: SaaSPostRequest):
|
||
"""Post an AI Shorts video to social media via Upload-Post."""
|
||
if req.job_id not in saas_jobs:
|
||
raise HTTPException(status_code=404, detail="Job not found")
|
||
|
||
job = saas_jobs[req.job_id]
|
||
result = job.get("result")
|
||
if not result or not result.get("video_url"):
|
||
raise HTTPException(status_code=400, detail="No video available for this job")
|
||
|
||
try:
|
||
# Resolve video file path
|
||
video_url = result["video_url"] # e.g. /videos/saas_xxx/slug_final.mp4
|
||
rel_path = video_url.replace("/videos/", "")
|
||
file_path = os.path.join(OUTPUT_DIR, rel_path)
|
||
|
||
if not os.path.exists(file_path):
|
||
raise HTTPException(status_code=404, detail=f"Video file not found")
|
||
|
||
script = result.get("script", {})
|
||
final_title = req.title or script.get("title", "AI Short")
|
||
final_description = req.description or script.get("caption", "")
|
||
if not final_description:
|
||
final_description = script.get("full_narration", "Check this out!")
|
||
|
||
url = "https://api.upload-post.com/api/upload"
|
||
headers = {"Authorization": f"Apikey {req.api_key}"}
|
||
|
||
data_payload = {
|
||
"user": req.user_id,
|
||
"title": final_title,
|
||
"platform[]": req.platforms,
|
||
"async_upload": "true",
|
||
}
|
||
|
||
if req.scheduled_date:
|
||
data_payload["scheduled_date"] = req.scheduled_date
|
||
if req.timezone:
|
||
data_payload["timezone"] = req.timezone
|
||
|
||
if "tiktok" in req.platforms:
|
||
data_payload["tiktok_title"] = final_description
|
||
if "instagram" in req.platforms:
|
||
data_payload["instagram_title"] = final_description
|
||
data_payload["media_type"] = "REELS"
|
||
if "youtube" in req.platforms:
|
||
data_payload["youtube_title"] = final_title
|
||
data_payload["youtube_description"] = final_description
|
||
data_payload["privacyStatus"] = "public"
|
||
|
||
filename = os.path.basename(file_path)
|
||
with open(file_path, "rb") as f:
|
||
file_content = f.read()
|
||
|
||
files = {"video": (filename, file_content, "video/mp4")}
|
||
|
||
with httpx.Client(timeout=120.0) as client:
|
||
print(f"📡 [AI Shorts] Sending to Upload-Post: {req.platforms}")
|
||
response = client.post(url, headers=headers, data=data_payload, files=files)
|
||
|
||
if response.status_code not in [200, 201, 202]:
|
||
raise HTTPException(status_code=response.status_code, detail=f"Upload-Post Error: {response.text}")
|
||
|
||
return response.json()
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
print(f"❌ [AI Shorts] Post Exception: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@app.get("/gallery", response_class=HTMLResponse)
|
||
async def gallery_html_page():
|
||
"""SEO gallery page with all generated UGC videos."""
|
||
import html as html_mod
|
||
loop = asyncio.get_running_loop()
|
||
videos = await loop.run_in_executor(None, list_video_gallery, 100)
|
||
|
||
cards_html = ""
|
||
ld_items = []
|
||
for i, v in enumerate(videos):
|
||
title = html_mod.escape(v.get("title", "Untitled"))
|
||
video_url = v.get("video_url", "")
|
||
actor_url = v.get("actor_url", "")
|
||
video_id = v.get("video_id", "")
|
||
duration = v.get("duration", 0)
|
||
mode = v.get("video_mode", "")
|
||
product = html_mod.escape(v.get("product_name", ""))
|
||
caption = html_mod.escape(v.get("caption", "")[:120])
|
||
|
||
mode_badge = '<span style="background:#22c55e;color:#000;padding:2px 8px;border-radius:9999px;font-size:10px;font-weight:700">LOW COST</span>' if mode == "lowcost" else '<span style="background:#8b5cf6;color:#fff;padding:2px 8px;border-radius:9999px;font-size:10px;font-weight:700">PREMIUM</span>'
|
||
|
||
cards_html += f'''
|
||
<a href="/video/{video_id}" style="text-decoration:none;color:inherit">
|
||
<div style="background:#18181b;border-radius:16px;overflow:hidden;border:1px solid #27272a;transition:transform 0.2s" onmouseover="this.style.transform='scale(1.02)'" onmouseout="this.style.transform='scale(1)'">
|
||
<div style="position:relative;aspect-ratio:9/16;background:#000">
|
||
<video src="{video_url}" poster="{actor_url}" muted playsinline preload="metadata"
|
||
onmouseenter="this.play()" onmouseleave="this.pause();this.currentTime=0"
|
||
style="width:100%;height:100%;object-fit:cover"></video>
|
||
<div style="position:absolute;top:8px;right:8px">{mode_badge}</div>
|
||
</div>
|
||
<div style="padding:12px">
|
||
<h2 style="font-size:14px;font-weight:600;margin:0 0 4px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{title}</h2>
|
||
<p style="font-size:11px;color:#71717a;margin:0">{duration:.0f}s · {product}</p>
|
||
</div>
|
||
</div>
|
||
</a>'''
|
||
|
||
ld_items.append(f'{{"@type":"ListItem","position":{i+1},"url":"https://openshorts.app/video/{video_id}","name":"{title}"}}')
|
||
|
||
ld_json = f'{{"@context":"https://schema.org","@type":"CollectionPage","name":"AI UGC Video Gallery","mainEntity":{{"@type":"ItemList","numberOfItems":{len(videos)},"itemListElement":[{",".join(ld_items)}]}}}}'
|
||
|
||
return f'''<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>AI UGC Video Gallery | OpenShorts</title>
|
||
<meta name="description" content="Browse {len(videos)} AI-generated UGC marketing videos. Create viral TikTok and Instagram Reels for your SaaS product.">
|
||
<meta name="robots" content="index, follow">
|
||
<meta property="og:title" content="AI UGC Video Gallery | OpenShorts">
|
||
<meta property="og:type" content="website">
|
||
<meta property="og:description" content="Browse AI-generated UGC marketing videos for SaaS products.">
|
||
<script type="application/ld+json">{ld_json}</script>
|
||
<style>
|
||
*{{margin:0;padding:0;box-sizing:border-box}}
|
||
body{{background:#0a0a0c;color:#e4e4e7;font-family:-apple-system,BlinkMacSystemFont,sans-serif}}
|
||
.grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:20px;padding:20px;max-width:1400px;margin:0 auto}}
|
||
nav{{padding:20px 40px;border-bottom:1px solid #27272a;display:flex;align-items:center;justify-content:space-between}}
|
||
h1{{font-size:28px;font-weight:700;padding:40px 20px 0;text-align:center}}
|
||
.subtitle{{text-align:center;color:#71717a;font-size:14px;padding:8px 20px 20px}}
|
||
.cta{{display:inline-block;background:#8b5cf6;color:#fff;padding:10px 24px;border-radius:12px;text-decoration:none;font-weight:600;font-size:14px}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<nav><strong style="font-size:18px">OpenShorts</strong><a href="/" class="cta">Create Your Video</a></nav>
|
||
<h1>AI-Generated UGC Videos</h1>
|
||
<p class="subtitle">{len(videos)} videos generated · Low Cost & Premium modes</p>
|
||
<div class="grid">{cards_html}</div>
|
||
<div style="text-align:center;padding:40px"><a href="/" class="cta">Create Your Own UGC Video</a></div>
|
||
</body></html>'''
|
||
|
||
|
||
@app.get("/video/{video_id}", response_class=HTMLResponse)
|
||
async def video_html_page(video_id: str):
|
||
"""SEO individual video page with og:video meta tags."""
|
||
import html as html_mod
|
||
loop = asyncio.get_running_loop()
|
||
videos = await loop.run_in_executor(None, list_video_gallery, 200)
|
||
meta = next((v for v in videos if v.get("video_id") == video_id), None)
|
||
if not meta:
|
||
raise HTTPException(status_code=404, detail="Video not found")
|
||
|
||
title = html_mod.escape(meta.get("title", "Untitled"))
|
||
caption = html_mod.escape(meta.get("caption", ""))
|
||
narration = html_mod.escape(meta.get("full_narration", ""))
|
||
video_url = meta.get("video_url", "")
|
||
actor_url = meta.get("actor_url", "")
|
||
duration = meta.get("duration", 0)
|
||
mode = meta.get("video_mode", "")
|
||
product = html_mod.escape(meta.get("product_name", ""))
|
||
product_url = html_mod.escape(meta.get("product_url", ""))
|
||
language = meta.get("language", "en")
|
||
hashtags = " ".join(meta.get("hashtags", []))
|
||
cost = meta.get("cost_estimate", {}).get("total", 0)
|
||
created = meta.get("created_at", "")
|
||
actor_desc = html_mod.escape(meta.get("actor_description", ""))
|
||
|
||
ld_json = f'{{"@context":"https://schema.org","@type":"VideoObject","name":"{title}","description":"{caption}","thumbnailUrl":"{actor_url}","contentUrl":"{video_url}","uploadDate":"{created}","duration":"PT{int(duration)}S","width":1080,"height":1920,"inLanguage":"{language}"}}'
|
||
|
||
mode_label = "Low Cost" if mode == "lowcost" else "Premium"
|
||
|
||
return f'''<!DOCTYPE html>
|
||
<html lang="{language}">
|
||
<head>
|
||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>{title} - AI UGC Video | OpenShorts</title>
|
||
<meta name="description" content="{caption} {hashtags}">
|
||
<meta property="og:type" content="video.other">
|
||
<meta property="og:title" content="{title}">
|
||
<meta property="og:description" content="{caption}">
|
||
<meta property="og:video" content="{video_url}">
|
||
<meta property="og:video:type" content="video/mp4">
|
||
<meta property="og:video:width" content="1080">
|
||
<meta property="og:video:height" content="1920">
|
||
<meta property="og:image" content="{actor_url}">
|
||
<meta name="twitter:card" content="player">
|
||
<meta name="twitter:title" content="{title}">
|
||
<meta name="twitter:image" content="{actor_url}">
|
||
<script type="application/ld+json">{ld_json}</script>
|
||
<style>
|
||
*{{margin:0;padding:0;box-sizing:border-box}}
|
||
body{{background:#0a0a0c;color:#e4e4e7;font-family:-apple-system,BlinkMacSystemFont,sans-serif}}
|
||
nav{{padding:20px 40px;border-bottom:1px solid #27272a;display:flex;align-items:center;gap:16px}}
|
||
nav a{{color:#a1a1aa;text-decoration:none;font-size:14px}}
|
||
.container{{max-width:1000px;margin:0 auto;padding:40px 20px;display:grid;grid-template-columns:1fr 1fr;gap:40px}}
|
||
@media(max-width:768px){{.container{{grid-template-columns:1fr}}}}
|
||
video{{width:100%;border-radius:16px;background:#000}}
|
||
h1{{font-size:22px;font-weight:700;margin-bottom:8px}}
|
||
.meta{{color:#71717a;font-size:13px;margin-bottom:20px}}
|
||
.section{{margin-bottom:20px}}
|
||
.section h2{{font-size:13px;color:#71717a;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px}}
|
||
.section p{{font-size:14px;line-height:1.6}}
|
||
.badge{{display:inline-block;padding:3px 10px;border-radius:9999px;font-size:11px;font-weight:700}}
|
||
.cta{{display:inline-block;background:#8b5cf6;color:#fff;padding:10px 24px;border-radius:12px;text-decoration:none;font-weight:600;font-size:14px;margin-top:20px}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<nav><strong>OpenShorts</strong><a href="/gallery">Gallery</a><span style="color:#3f3f46">›</span><span style="color:#e4e4e7;font-size:14px">{title}</span></nav>
|
||
<div class="container">
|
||
<div><video src="{video_url}" poster="{actor_url}" controls autoplay playsinline style="aspect-ratio:9/16;object-fit:cover"></video></div>
|
||
<div>
|
||
<h1>{title}</h1>
|
||
<p class="meta">{duration:.0f}s · {mode_label} · ${cost:.2f} · {product}</p>
|
||
<div class="section"><h2>Caption</h2><p>{caption}</p><p style="color:#8b5cf6;margin-top:4px">{hashtags}</p></div>
|
||
<div class="section"><h2>Script</h2><p>{narration}</p></div>
|
||
<div class="section"><h2>Actor</h2><p>{actor_desc}</p></div>
|
||
{f'<div class="section"><h2>Product</h2><p><a href="{product_url}" style="color:#8b5cf6" target="_blank">{product}</a></p></div>' if product_url else ''}
|
||
<a href="/gallery">← Back to Gallery</a>
|
||
<br><a href="/" class="cta">Create Your Own</a>
|
||
</div>
|
||
</div>
|
||
</body></html>'''
|
||
|
||
|
||
@app.get("/api/saasshorts/actor-gallery")
|
||
async def saasshorts_actor_gallery():
|
||
"""List all previously generated actor images from public S3."""
|
||
try:
|
||
loop = asyncio.get_running_loop()
|
||
images = await loop.run_in_executor(None, list_actor_gallery)
|
||
return {"images": images}
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
class SaaSGenerateRequest(BaseModel):
|
||
script: dict
|
||
voice_id: Optional[str] = None
|
||
actor_description: Optional[str] = None
|
||
selected_actor_url: Optional[str] = None # Pre-selected actor image URL
|
||
retry_job_id: Optional[str] = None
|
||
video_mode: str = "lowcost" # "lowcost" or "premium"
|
||
|
||
|
||
@app.post("/api/saasshorts/generate")
|
||
async def saasshorts_generate(
|
||
req: SaaSGenerateRequest,
|
||
x_fal_key: Optional[str] = Header(None, alias="X-Fal-Key"),
|
||
x_elevenlabs_key: Optional[str] = Header(None, alias="X-ElevenLabs-Key"),
|
||
):
|
||
"""Generate a SaaS UGC video from a script. Returns a job_id for polling."""
|
||
fal_key = x_fal_key
|
||
elevenlabs_key = x_elevenlabs_key
|
||
|
||
if not fal_key:
|
||
raise HTTPException(status_code=400, detail="Missing fal.ai API Key (X-Fal-Key header)")
|
||
if not elevenlabs_key:
|
||
raise HTTPException(status_code=400, detail="Missing ElevenLabs API Key (X-ElevenLabs-Key header)")
|
||
|
||
# Support retry: reuse output_dir so cached assets (image, voice, head, broll) are kept
|
||
reused = False
|
||
if req.retry_job_id:
|
||
# Check memory first, then disk
|
||
old_dir = os.path.join(OUTPUT_DIR, f"saas_{req.retry_job_id}")
|
||
if req.retry_job_id in saas_jobs:
|
||
old_dir = saas_jobs[req.retry_job_id]["output_dir"]
|
||
|
||
if os.path.isdir(old_dir):
|
||
job_id = req.retry_job_id
|
||
job_output_dir = old_dir
|
||
reused = True
|
||
# Clear the 0-byte final video so pipeline re-generates it
|
||
for f in os.listdir(old_dir):
|
||
fp = os.path.join(old_dir, f)
|
||
if f.endswith("_final.mp4") and os.path.getsize(fp) == 0:
|
||
os.remove(fp)
|
||
saas_jobs[job_id] = {
|
||
"status": "processing",
|
||
"logs": [f"Retrying job {job_id[:8]}... reusing cached assets from disk."],
|
||
"result": None,
|
||
"output_dir": job_output_dir,
|
||
}
|
||
|
||
if not reused:
|
||
job_id = str(uuid.uuid4())
|
||
job_output_dir = os.path.join(OUTPUT_DIR, f"saas_{job_id}")
|
||
os.makedirs(job_output_dir, exist_ok=True)
|
||
saas_jobs[job_id] = {
|
||
"status": "processing",
|
||
"logs": ["SaaSShorts job started."],
|
||
"result": None,
|
||
"output_dir": job_output_dir,
|
||
}
|
||
|
||
# If user selected a pre-generated actor, resolve it to a local path
|
||
selected_actor_path = None
|
||
if req.selected_actor_url:
|
||
if req.selected_actor_url.startswith("http"):
|
||
# Download from S3 public URL to job output dir
|
||
import httpx
|
||
try:
|
||
actor_local = os.path.join(job_output_dir, "selected_actor.png")
|
||
with httpx.Client(timeout=30.0) as client:
|
||
resp = client.get(req.selected_actor_url)
|
||
if resp.status_code == 200:
|
||
with open(actor_local, "wb") as f:
|
||
f.write(resp.content)
|
||
selected_actor_path = actor_local
|
||
except Exception:
|
||
pass
|
||
else:
|
||
src = os.path.join(OUTPUT_DIR, req.selected_actor_url.replace("/videos/", ""))
|
||
if os.path.exists(src):
|
||
selected_actor_path = src
|
||
|
||
config = {
|
||
"fal_key": fal_key,
|
||
"elevenlabs_key": elevenlabs_key,
|
||
"voice_id": req.voice_id or "21m00Tcm4TlvDq8ikWAM",
|
||
"actor_description": req.actor_description,
|
||
"selected_actor_path": selected_actor_path,
|
||
"video_mode": req.video_mode,
|
||
}
|
||
|
||
async def run_generation():
|
||
await concurrency_semaphore.acquire()
|
||
try:
|
||
loop = asyncio.get_running_loop()
|
||
|
||
def log_msg(msg):
|
||
print(f"[SaaSShorts Job {job_id[:8]}] {msg}")
|
||
if job_id in saas_jobs:
|
||
saas_jobs[job_id]["logs"].append(msg)
|
||
|
||
def run():
|
||
return generate_full_video(req.script, config, job_output_dir, log_msg)
|
||
|
||
result = await loop.run_in_executor(None, run)
|
||
|
||
if job_id in saas_jobs:
|
||
video_filename = result["video_filename"]
|
||
saas_jobs[job_id]["status"] = "completed"
|
||
saas_jobs[job_id]["result"] = {
|
||
"video_url": f"/videos/saas_{job_id}/{video_filename}",
|
||
"video_filename": video_filename,
|
||
"duration": result.get("duration", 0),
|
||
"cost_estimate": result.get("cost_estimate", {}),
|
||
"script": req.script,
|
||
}
|
||
saas_jobs[job_id]["logs"].append("Video generation completed!")
|
||
|
||
# Upload to public gallery (non-blocking)
|
||
try:
|
||
gallery_meta = {
|
||
"title": req.script.get("title", "Untitled"),
|
||
"hook_text": req.script.get("hook_text", ""),
|
||
"caption": req.script.get("caption", ""),
|
||
"hashtags": req.script.get("hashtags", []),
|
||
"full_narration": req.script.get("full_narration", ""),
|
||
"actor_description": req.script.get("actor_description", ""),
|
||
"style": req.script.get("style", "ugc"),
|
||
"language": req.script.get("language", "en"),
|
||
"duration": result.get("duration", 0),
|
||
"video_mode": req.video_mode,
|
||
"product_name": req.script.get("_product_name", ""),
|
||
"product_url": req.script.get("_product_url", ""),
|
||
"segments": req.script.get("segments", []),
|
||
"cost_estimate": result.get("cost_estimate", {}),
|
||
}
|
||
gallery_result = upload_video_to_gallery(
|
||
video_path=result["video_path"],
|
||
actor_image_path=result.get("actor_image", ""),
|
||
metadata=gallery_meta,
|
||
video_id=job_id[:8],
|
||
)
|
||
if gallery_result:
|
||
saas_jobs[job_id]["result"]["gallery_video_id"] = gallery_result["video_id"]
|
||
log_msg("📤 Uploaded to public gallery.")
|
||
except Exception as gallery_err:
|
||
log_msg(f"⚠️ Gallery upload skipped: {gallery_err}")
|
||
|
||
except Exception as e:
|
||
print(f"[SaaSShorts] ❌ Job {job_id} failed: {e}")
|
||
if job_id in saas_jobs:
|
||
saas_jobs[job_id]["status"] = "failed"
|
||
saas_jobs[job_id]["logs"].append(f"Error: {str(e)}")
|
||
finally:
|
||
concurrency_semaphore.release()
|
||
|
||
asyncio.create_task(run_generation())
|
||
|
||
return {"job_id": job_id, "status": "processing"}
|
||
|
||
|
||
@app.get("/api/saasshorts/status/{job_id}")
|
||
async def saasshorts_status(job_id: str):
|
||
"""Poll SaaSShorts job status."""
|
||
if job_id not in saas_jobs:
|
||
raise HTTPException(status_code=404, detail="SaaSShorts job not found")
|
||
|
||
job = saas_jobs[job_id]
|
||
return {
|
||
"status": job["status"],
|
||
"logs": job["logs"],
|
||
"result": job.get("result"),
|
||
}
|
||
|
||
|
||
@app.get("/api/saasshorts/voices")
|
||
async def saasshorts_voices(
|
||
x_elevenlabs_key: Optional[str] = Header(None, alias="X-ElevenLabs-Key"),
|
||
):
|
||
"""List available ElevenLabs voices."""
|
||
if x_elevenlabs_key:
|
||
try:
|
||
loop = asyncio.get_event_loop()
|
||
voices = await loop.run_in_executor(
|
||
None, get_elevenlabs_voices, x_elevenlabs_key
|
||
)
|
||
if voices:
|
||
return {"voices": voices, "source": "elevenlabs"}
|
||
except Exception:
|
||
pass
|
||
|
||
# Fallback to default voices
|
||
return {
|
||
"voices": [
|
||
{"voice_id": vid, "name": name, "category": "default"}
|
||
for name, vid in DEFAULT_VOICES.items()
|
||
],
|
||
"source": "defaults",
|
||
}
|