- Add Python/FastAPI backend with Celery workers - Add video generation with FFmpeg (spinning record animation) - Add API endpoints: submissions, status polling, webhook, results - Add database schema and Alembic migrations - Update frontend pages with API integration - Add project documentation and spec Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
80 KiB
Pets at Home
Project/Campaign: Valentine's Day 2026 Oneline Brief: Song Generator Microsite using LLMs
[TOC]
Overview
A microsite that generates AI-powered love songs (delivered as an MP4 video featuring the pet photo) for a pet and its owner. Both the pet and owner names are mentioned in the vocals. The application handles form submission, asynchronous song generation via external API, and result delivery through the web interface.
Core User Flow
1. Form Submission
Page: Landing/Form Page
Required Fields (in form order):
- Pet Name (text input, 2-100 chars, letters and spaces only)
- Pet Type (dropdown: Dog, Cat, Bird, Fish, Other)
- Pet Photo (file upload - image formats: JPG, PNG, max 5MB, minimum 400×400px)
- Music Vibe (dropdown: Chill, Energetic, Romantic, Party, Relaxing, Other)
- Your Name / Owner Name (text input, 2-100 chars, letters and spaces only)
Legal Disclaimers:
- Terms & Conditions and Privacy Policy links to be hosted in the page footer. No mandatory checkbox required for this POC.
Actions:
- Session Check: Before submission, check if
cookie_idexists in localStorage (submission_data) - Image Processing: Allow user to crop image to a 1:1 square ratio.
- Minimum input dimensions: 400×400px (enforced client-side)
- Maximum file size: 5MB (enforced client-side)
- Output dimensions: 600×600px
- Output format: JPEG, 0.9 quality
- Typical file size: 50-150KB (well under 5MB limit)
- Cropping handled client-side via Cropper.js v1.6.2
- API Call: Send data to internal API endpoint
POST /api/submissionsincluding:- Form fields (pet name, pet type, photo, music vibe, owner name)
cookie_id(if exists in browser, otherwise send as null/empty)
- On Success:
- Receive
session_idfrom backend - If
cookie_idwas returned by backend (first-time user), store it in localStorage viaSessionManager.updateSession() - Push session object to
localStoragearray:[{created_at: timestamp, session_id: xxxxx}] - Navigate to loading/waiting screen
- Receive
- On Failure (Rate Limit Exceeded):
- Backend returns error: "Submission limit reached"
- Show "Sold Out" page/message to user
- Do not allow retry
- On Failure (Other Errors):
- Keep form data intact
- Trigger a "toast" notification with error message
- Allow user to retry
2. Loading Screen with Polling
Page: /waiting/{session_id}
Display:
- "Calculating results..." message
- Loading animation
Polling Logic:
- Poll internal API endpoint
GET /api/submissions/{session_id}/status - Polling interval: 10 seconds
- Maximum polling duration: 5 minutes (then show error/retry option)
- The system checks the DB column
entry_statusfor values - success or fail. - If status is success, it will show the result page with success messaging.
- If status is fail, it will show the result page with failure messaging, prompting the user to take the journey again.
3. Result Page
Page: /result/{session_id}
Result status is success
The page displays the below
- Video player (HTML5 MP4 player) displaying the generated song/video
- Full lyrics displayed
- "Create another song" secondary CTA button
- Copy URL button that copies the full URL to user device clipboard -
https://my-domain.com/result/{session_id} - Download video file button
- Optional: Full lyrics displayed
Result status is failure
The page displays the below
- Failure message
- "Retake the journey" primary CTA button
Backend Specification
Flow Summary
- User submits form → Data saved to DB with "pending" status
- Celery Beat task picks up pending submissions → Sends to Sonauto API
- Sonauto processes asynchronously → Sends webhook callback when complete
- Webhook triggers Celery task chain → Fetches details, downloads audio, creates video
- Video creation completes → Updates status to "success"
- User polls status endpoint → Receives "success" → Redirected to result page
API Endpoints Specification
1. POST /api/submissions
Purpose: Accept form submission, validate rate limits via cookie tracking, save to database, and return session ID
Request Payload:
{
"pet_name": "Toby",
"pet_type": "Dog",
"photo": "<base64_encoded_cropped_image>",
"music_vibe": "Romantic",
"owner_name": "Matt",
"cookie_id": "cookie_abc123xyz" // Optional: null or empty if first-time user
}
Validation (in form order):
pet_name: Required, 2-100 chars, letters and spaces onlypet_type: Required, one of: Dog, Cat, Bird, Fish, Otherphoto: Required, valid base64 JPEG image, 600×600px, ~50-150KB typicalmusic_vibe: Required, one of: Chill, Energetic, Romantic, Party, Relaxing, Otherowner_name: Required, 2-100 chars, letters and spaces onlycookie_id: Optional, string
Process:
-
Validate all form fields
-
Cookie Rate Limiting Check:
- If
cookie_idis present in request:- Query database:
SELECT COUNT(*) FROM submissions WHERE cookie_id = '{received_cookie_id}' - If count >=
FORM_SUBMIT_RETRY(default: 10):- REJECT submission
- Return error response with status 429 (Too Many Requests)
- User sees "Sold Out" page
- If count <
FORM_SUBMIT_RETRY:- ACCEPT submission, proceed to step 3
- Query database:
- If
cookie_idis null/empty (first-time user):- Generate new
cookie_idusing Cuid2 algorithm - Proceed to step 3
- Generate new
- If
-
Generate unique
session_idusing Cuid2 algorithm -
Save uploaded image to
IMG_STORAGE/{session_id}.jpg -
Insert record into
submissionstable:INSERT INTO submissions ( session_id, cookie_id, owner_name, pet_name, pet_type, music_vibe, photo_path, entry_status, created_at ) VALUES ( 'clxyz123...', 'cookie_abc...', 'Matt', 'Toby', 'Dog', 'Romantic', '/uploads/clxyz123.jpg', 'pending', NOW() ); -
Return response to frontend
Success Response (200):
{
"success": true,
"session_id": "clxyz123abc456def789",
"cookie_id": "cookie_abc123xyz", // Only returned if new cookie was generated
"message": "Submission received successfully"
}
Note: If user already had a cookie, the response does NOT include cookie_id field (frontend keeps using existing cookie).
Error Response - Rate Limit Exceeded (429):
{
"success": false,
"error": "rate_limit_exceeded",
"message": "You have reached the maximum number of submissions. Please check back later.",
"attempts_used": 10,
"max_attempts": 10
}
Error Response - Validation Failed (400):
{
"success": false,
"error": "Invalid image format",
"message": "Please upload JPG or PNG image under 5MB"
}
Error Response - Server Error (500):
{
"success": false,
"error": "server_error",
"message": "Something went wrong. Please try again."
}
Python Implementation (FastAPI):
from fastapi import APIRouter, HTTPException, Response
from pydantic import BaseModel, Field, field_validator
import base64
import re
from cuid2 import cuid_wrapper
from pathlib import Path
router = APIRouter()
cuid_generator = cuid_wrapper()
# Pydantic Schemas
class SubmissionRequest(BaseModel):
pet_name: str = Field(..., min_length=2, max_length=100)
pet_type: str
photo: str # base64 encoded JPEG
music_vibe: str
owner_name: str = Field(..., min_length=2, max_length=100)
cookie_id: str | None = None
@field_validator('pet_name', 'owner_name')
@classmethod
def validate_name(cls, v):
if not re.match(r'^[A-Za-z ]+$', v):
raise ValueError('Only letters and spaces allowed')
return v
@field_validator('pet_type')
@classmethod
def validate_pet_type(cls, v):
allowed = ['Dog', 'Cat', 'Bird', 'Fish', 'Other']
if v not in allowed:
raise ValueError(f'Must be one of: {", ".join(allowed)}')
return v
@field_validator('music_vibe')
@classmethod
def validate_music_vibe(cls, v):
allowed = ['Chill', 'Energetic', 'Romantic', 'Party', 'Relaxing', 'Other']
if v not in allowed:
raise ValueError(f'Must be one of: {", ".join(allowed)}')
return v
class SubmissionResponse(BaseModel):
success: bool
session_id: str
cookie_id: str | None = None
message: str
@router.post("/api/submissions", response_model=SubmissionResponse)
async def create_submission(payload: SubmissionRequest, db: Session = Depends(get_db)):
# Check rate limit if cookie_id provided
if payload.cookie_id:
count = db.query(Submission).filter_by(cookie_id=payload.cookie_id).count()
if count >= FORM_SUBMIT_RETRY:
raise HTTPException(
status_code=429,
detail={
"success": False,
"error": "rate_limit_exceeded",
"message": "You have reached the maximum number of submissions.",
"attempts_used": count,
"max_attempts": FORM_SUBMIT_RETRY
}
)
cookie_id = payload.cookie_id
is_new_cookie = False
else:
# Generate new cookie for first-time user
cookie_id = f"cookie_{cuid_generator()}"
is_new_cookie = True
# Generate session_id
session_id = cuid_generator()
# Decode and save base64 image
try:
# Remove data URL prefix if present
photo_data = payload.photo
if photo_data.startswith('data:'):
photo_data = photo_data.split(',', 1)[1]
image_bytes = base64.b64decode(photo_data)
photo_path = Path(IMG_STORAGE) / f"{session_id}.jpg"
photo_path.write_bytes(image_bytes)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid image data: {str(e)}")
# Create submission record
submission = Submission(
session_id=session_id,
cookie_id=cookie_id,
pet_name=payload.pet_name,
pet_type=payload.pet_type,
photo_path=str(photo_path),
music_vibe=payload.music_vibe,
owner_name=payload.owner_name,
entry_status='pending'
)
db.add(submission)
db.commit()
return SubmissionResponse(
success=True,
session_id=session_id,
cookie_id=cookie_id if is_new_cookie else None,
message="Submission received successfully"
)
2. GET /api/submissions/{session_id}/status
Purpose: Poll endpoint for frontend to check processing status
URL Parameters:
session_id: The unique session identifier
Process:
- Query database for record with matching
session_id - Return
entry_statusfield value
Success Response (200):
{
"session_id": "clxyz123abc456def789",
"status": "pending", // or "processing", "success", "fail"
"created_at": "2026-02-14T10:30:00Z"
}
Status Values:
pending: Waiting in queueprocessing: Being processed by Sonauto APIsuccess: Video generation complete, ready to viewfail: Generation failed at any stage
Error Response (404):
{
"success": false,
"error": "Session not found"
}
Python Implementation (FastAPI):
class StatusResponse(BaseModel):
session_id: str
status: str
created_at: str
@router.get("/api/submissions/{session_id}/status", response_model=StatusResponse)
async def get_submission_status(session_id: str, db: Session = Depends(get_db)):
submission = db.get(Submission, session_id)
if not submission:
raise HTTPException(status_code=404, detail="Session not found")
return StatusResponse(
session_id=submission.session_id,
status=submission.entry_status,
created_at=submission.created_at.isoformat() + "Z"
)
3. POST /api/webhook
Purpose: Receive callback from Sonauto API when song generation completes
Request Payload (from Sonauto):
{
"task_id": "0b2a66c0-d9f2-43d2-9fa7-b9c95c29324d",
"status": "SUCCESS",
"song_paths": ["pubapi/generations2/audio_0b2a66c0...._0.mp3"]
}
Process:
- Extract
task_idfrom payload - Validate
task_idexists in database (inLLM_task_idcolumn) - If status is "SUCCESS":
- Update
received_from_LLMtimestamp - Trigger Celery task chain:
fetch_generation_details→download_audio→create_video
- Update
- If status is "FAILURE":
- Update
LLM_status= "fail" - Update
entry_status= "fail" - Update
received_from_LLMtimestamp
- Update
Python Implementation (FastAPI):
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
router = APIRouter()
class WebhookPayload(BaseModel):
task_id: str
status: str
song_paths: list[str] | None = None
error_message: str | None = None
@router.post("/api/webhook")
async def handle_webhook(payload: WebhookPayload, db: Session = Depends(get_db)):
submission = db.query(Submission).filter_by(LLM_task_id=payload.task_id).first()
if not submission:
logger.warning(f"Webhook received for unknown task_id: {payload.task_id}")
raise HTTPException(status_code=404, detail="Unknown task_id")
submission.received_from_LLM = datetime.utcnow()
if payload.status == "SUCCESS":
db.commit()
# Trigger chained processing tasks
trigger_post_webhook_processing(submission.session_id)
logger.info(f"Webhook SUCCESS for {submission.session_id}, triggered processing chain")
elif payload.status == "FAILURE":
submission.LLM_status = 'fail'
submission.entry_status = 'fail'
db.commit()
logger.warning(f"Webhook FAILURE for {submission.session_id}: {payload.error_message}")
return {"success": True, "message": "Webhook processed"}
Success Response (200):
{
"success": true,
"message": "Webhook processed"
}
Security Considerations:
- Validate that
task_idexists in database before processing - Log all webhook requests for debugging
4. GET /api/results/{session_id}
Purpose: Retrieve final results (video, lyrics) for results page
URL Parameters:
session_id: The unique session identifier
Process:
- Query database for record with matching
session_id - If
entry_status!= "success", return appropriate error - Return video path (direct file serving), lyrics, and metadata
Success Response (200):
{
"success": true,
"session_id": "clxyz123abc456def789",
"status": "success",
"video_url": "/storage/video/clxyz123abc456def789.mp4",
"lyrics": "[Chorus]\nMatt and Toby, best friends...",
"owner_name": "Matt",
"pet_name": "Toby",
"created_at": "2026-02-14T10:30:00Z"
}
Error Response (404/400):
{
"success": false,
"status": "fail",
"message": "Video generation failed. Please try again."
}
Security:
- No authentication required (public share links)
- Session IDs are cryptographically random (Cuid2)
- Consider rate limiting (e.g., 100 requests/hour per IP)
Python Implementation (FastAPI):
class ResultResponse(BaseModel):
success: bool
session_id: str
status: str
video_url: str | None = None
lyrics: str | None = None
owner_name: str
pet_name: str
created_at: str
message: str | None = None
@router.get("/api/results/{session_id}")
async def get_results(session_id: str, db: Session = Depends(get_db)):
submission = db.get(Submission, session_id)
if not submission:
raise HTTPException(status_code=404, detail="Session not found")
if submission.entry_status == 'success':
return ResultResponse(
success=True,
session_id=submission.session_id,
status=submission.entry_status,
video_url=f"/storage/video/{submission.session_id}.mp4",
lyrics=submission.lyrics,
owner_name=submission.owner_name,
pet_name=submission.pet_name,
created_at=submission.created_at.isoformat() + "Z"
)
else:
return ResultResponse(
success=False,
session_id=submission.session_id,
status=submission.entry_status,
owner_name=submission.owner_name,
pet_name=submission.pet_name,
created_at=submission.created_at.isoformat() + "Z",
message="Video generation failed. Please try again."
)
5. GET /api/health
Purpose: Health check endpoint for load balancers and monitoring
Response (200):
{
"status": "healthy",
"timestamp": "2026-02-14T10:30:00Z",
"redis": "connected",
"database": "connected",
"celery_workers": 4
}
Response (503 - Unhealthy):
{
"status": "unhealthy",
"timestamp": "2026-02-14T10:30:00Z",
"redis": "disconnected",
"database": "connected",
"celery_workers": 0
}
Python Implementation (FastAPI):
from fastapi import APIRouter, Response
from fastapi.responses import JSONResponse
router = APIRouter()
@router.get("/api/health")
async def health_check(db: Session = Depends(get_db)):
checks = {
"timestamp": datetime.utcnow().isoformat() + "Z",
"redis": "connected",
"database": "connected",
"celery_workers": 0
}
# Check Redis
try:
redis_client.ping()
except Exception:
checks["redis"] = "disconnected"
# Check Database
try:
db.execute(text("SELECT 1"))
except Exception:
checks["database"] = "disconnected"
# Check Celery workers
try:
inspect = celery_app.control.inspect()
active = inspect.active()
checks["celery_workers"] = len(active) if active else 0
except Exception:
checks["celery_workers"] = 0
is_healthy = (
checks["redis"] == "connected" and
checks["database"] == "connected" and
checks["celery_workers"] > 0
)
checks["status"] = "healthy" if is_healthy else "unhealthy"
status_code = 200 if is_healthy else 503
return JSONResponse(content=checks, status_code=status_code)
6. GET /api/admin/queue-status
Purpose: Admin endpoint to view queue status (optional, for debugging)
Note: Should be protected by basic auth or IP whitelist in production.
Response (200):
{
"pending_submissions": 5,
"processing_submissions": 3,
"active_celery_tasks": 2,
"sonauto_credits": 8500,
"last_credits_check": "2026-02-14T10:25:00Z"
}
Python Implementation (FastAPI):
from fastapi import APIRouter, Depends
router = APIRouter()
@router.get("/api/admin/queue-status")
async def queue_status(db: Session = Depends(get_db)):
# Add basic auth dependency here for production
pending = db.query(Submission).filter_by(entry_status='pending').count()
processing = db.query(Submission).filter_by(entry_status='processing').count()
credits = redis_client.get("sonauto_credits")
return {
"pending_submissions": pending,
"processing_submissions": processing,
"sonauto_credits": int(credits) if credits else 0,
}
Data Retention & Cleanup
Purpose: Prevent unbounded storage growth by cleaning up old files and records.
Celery Beat Task (runs daily at 3 AM):
app.conf.beat_schedule['cleanup-old-files'] = {
'task': 'tasks.cleanup_old_files',
'schedule': crontab(hour=3, minute=0),
}
@shared_task(bind=True)
def cleanup_old_files(self):
"""Delete files and optionally records older than FILE_RETENTION_DAYS."""
from app.database import SessionLocal
db = SessionLocal()
try:
cutoff_date = datetime.utcnow() - timedelta(days=FILE_RETENTION_DAYS)
old_submissions = db.query(Submission).filter(
Submission.created_at < cutoff_date
).all()
deleted_count = 0
for submission in old_submissions:
# Delete associated files
for path_attr in ['photo_path', 'generated_song_path', 'generated_video_path']:
file_path = getattr(submission, path_attr)
if file_path and Path(file_path).exists():
Path(file_path).unlink()
deleted_count += 1
# Optionally delete the database record
# db.delete(submission)
db.commit()
logger.info(f"Cleanup: deleted {deleted_count} files from {len(old_submissions)} old submissions")
return {"files_deleted": deleted_count, "submissions_processed": len(old_submissions)}
finally:
db.close()
Storage Monitoring:
- Log warning if storage directory exceeds 80% capacity
- Consider adding disk space check to health endpoint
Background Workers (Celery with Redis)
The system uses Celery with Redis as the message broker for asynchronous task processing.
Why Celery Workers Are Required:
The following operations MUST run in Celery workers (not in the web process):
| Task | Reason |
|---|---|
send_to_sonauto |
External API call with potential latency/retries |
fetch_generation_details |
External API call |
download_audio |
Large file download (MP3, ~3-5MB) |
create_video |
CPU-intensive, 30-90 second operation - generates video frames, runs FFmpeg encoding |
Video generation in particular would cause request timeouts and resource exhaustion if run synchronously in the web process.
Dependencies:
celery[redis]>=5.3.0
redis>=4.5.0
requests>=2.28.0
Celery Configuration (celery_app.py):
from celery import Celery
app = Celery('pah_workers')
app.config_from_object({
'broker_url': 'redis://localhost:6379/0',
'result_backend': 'redis://localhost:6379/0',
'task_serializer': 'json',
'result_serializer': 'json',
'accept_content': ['json'],
'timezone': 'UTC',
'task_track_started': True,
'task_acks_late': True,
'worker_prefetch_multiplier': 1, # Prevent grabbing too many tasks
})
Running Celery Workers:
# Start worker for all queues
celery -A celery_app worker --loglevel=info --concurrency=4
# Start beat scheduler for periodic tasks
celery -A celery_app beat --loglevel=info
Worker Concurrency Considerations:
Video generation is CPU and memory intensive. Recommended concurrency settings:
- Small server (2 CPU, 4GB RAM):
--concurrency=2to prevent memory exhaustion - Medium server (4 CPU, 8GB RAM):
--concurrency=4(default) - Large server (8+ CPU, 16GB+ RAM):
--concurrency=6-8
Each video generation task uses approximately:
- ~200-400MB RAM (image buffers + FFmpeg)
- 1 CPU core at 100% during frame generation
- 30-90 seconds execution time
For high-volume deployments, consider using separate queues:
# Dedicated worker for video generation (lower concurrency)
celery -A celery_app worker --loglevel=info --concurrency=2 -Q video
# Worker for lighter tasks (API calls, downloads)
celery -A celery_app worker --loglevel=info --concurrency=8 -Q default
Task 1: Process Pending Submission (send_to_sonauto)
Triggered by: Celery Beat scheduler (every 1 minute)
Purpose: Process pending submissions by sending them to Sonauto API while respecting rate limits
Configuration Constants:
MAX_CONCURRENT_REQUESTS = 10 # Sonauto's parallel request limit
MIN_AVAILABLE_CREDITS = 5000 # Threshold for warnings
MAX_RETRIES = 3 # Maximum retry attempts per submission
Celery Beat Schedule:
app.conf.beat_schedule = {
'process-pending-submissions': {
'task': 'tasks.process_pending_queue',
'schedule': 60.0, # Every 60 seconds
},
'check-sonauto-credits': {
'task': 'tasks.check_credits',
'schedule': 600.0, # Every 10 minutes
},
'check-stale-submissions': {
'task': 'tasks.check_timeouts',
'schedule': 300.0, # Every 5 minutes
},
}
Task Implementation:
from celery import shared_task
from celery.utils.log import get_task_logger
logger = get_task_logger(__name__)
@shared_task(bind=True)
def process_pending_queue(self):
"""Process pending submissions and send to Sonauto API."""
from app.database import SessionLocal
# Check credits
credits = get_cached_credits()
if credits == 0:
logger.warning("Credits exhausted - skipping queue processing")
return {"processed": 0, "reason": "no_credits"}
db = SessionLocal()
try:
# Count active requests
active_count = db.query(Submission).filter(
Submission.sent_to_LLM.isnot(None),
Submission.received_from_LLM.is_(None)
).count()
available_slots = MAX_CONCURRENT_REQUESTS - active_count
if available_slots <= 0:
logger.info(f"No available slots ({active_count}/{MAX_CONCURRENT_REQUESTS} active)")
return {"processed": 0, "reason": "no_slots"}
# Get pending submissions
pending = db.query(Submission).filter(
Submission.entry_status == 'pending',
Submission.sent_to_LLM.is_(None),
Submission.retry_count < MAX_RETRIES
).order_by(Submission.created_at.asc()).limit(available_slots).all()
processed = 0
for submission in pending:
# Dispatch individual task for each submission
send_to_sonauto.delay(submission.session_id)
processed += 1
return {"processed": processed}
finally:
db.close()
@shared_task(
bind=True,
autoretry_for=(requests.RequestException,),
retry_backoff=True,
retry_backoff_max=60,
retry_kwargs={'max_retries': 3}
)
def send_to_sonauto(self, session_id: str):
"""Send a single submission to Sonauto API."""
from app.database import SessionLocal
db = SessionLocal()
try:
submission = db.get(Submission, session_id)
if not submission:
logger.error(f"Submission {session_id} not found")
return
payload = {
"tags": [submission.music_vibe, "love song", "valentine"],
"prompt": f"Create a heartfelt Valentine's Day love song celebrating the special bond between {submission.owner_name} and their beloved {submission.pet_type} {submission.pet_name}. Make it warm, genuine, and mention both names in the lyrics.",
"instrumental": False,
"output_format": "mp3",
"webhook_url": f"{WEBHOOK_BASE_URL}/api/webhook"
}
try:
response = requests.post(
f"{EXTERNAL_API_URL}/generations/v3",
json=payload,
headers={"Authorization": f"Bearer {SONAUTO_API_KEY}"},
timeout=API_REQUEST_TIMEOUT_SECONDS
)
response.raise_for_status()
data = response.json()
submission.LLM_task_id = data["task_id"]
submission.sent_to_LLM = datetime.utcnow()
submission.entry_status = 'processing'
db.commit()
logger.info(f"Sent {session_id} to Sonauto, task_id: {data['task_id']}")
except requests.RequestException as e:
submission.retry_count += 1
if submission.retry_count >= MAX_RETRIES:
submission.entry_status = 'fail'
logger.error(f"Submission {session_id} failed after {MAX_RETRIES} retries")
db.commit()
raise # Trigger Celery retry with exponential backoff
finally:
db.close()
Sonauto API Call Details:
Endpoint: POST {EXTERNAL_API_URL}/generations/v3
Documentation: https://sonauto.ai/developers#generations-v3-post
Request Headers:
{
"Content-Type": "application/json",
"Authorization": f"Bearer {SONAUTO_API_KEY}" # From environment variable
}
Request Payload:
{
"tags": ["<MUSIC_VIBE>", "love song", "valentine"],
"prompt": "Create a heartfelt Valentine's Day love song celebrating the special bond between <OWNER_NAME> and their beloved <PET_TYPE> <PET_NAME>. Make it warm, genuine, and mention both names in the lyrics.",
"instrumental": false,
"output_format": "mp3",
"webhook_url": "https://<YOUR_DOMAIN>/api/webhook"
}
Prompt Template:
Create a heartfelt Valentine's Day love song celebrating the special bond
between {owner_name} and their beloved {pet_type} {pet_name}.
Make it warm, genuine, and mention both names in the lyrics.
Expected Response:
{
"task_id": "2f2fcb2f-f024-4cfa-a282-f5bd46d1d044"
}
Error Handling:
- If API returns error, log it and retry on next worker run
- If credits insufficient, log warning but continue (Credits Monitor will update)
- If network timeout, mark for retry
- Maximum 3 retry attempts per submission before marking as "fail"
Task 2: Credits Monitor (check_credits)
Triggered by: Celery Beat scheduler (every 10 minutes)
Purpose: Check available Sonauto credits and cache in Redis
Configuration Constants:
MIN_AVAILABLE_CREDITS = 5000 # Warning threshold
Task Implementation:
@shared_task(bind=True)
def check_credits(self):
"""Check Sonauto credits and cache in Redis."""
try:
response = requests.get(
f"{EXTERNAL_API_URL}/credits/balance",
headers={"Authorization": f"Bearer {SONAUTO_API_KEY}"},
timeout=30
)
response.raise_for_status()
data = response.json()
num_credits = data.get("num_credits", 0)
# Cache in Redis (expires in 15 minutes as safety)
redis_client.setex("sonauto_credits", 900, num_credits)
if num_credits == 0:
logger.critical("Credits exhausted - submissions halted")
elif num_credits < MIN_AVAILABLE_CREDITS:
logger.warning(f"Credits below threshold: {num_credits}")
else:
logger.info(f"Credits available: {num_credits}")
return {"credits": num_credits}
except requests.RequestException as e:
logger.error(f"Failed to check credits: {e}")
return {"error": str(e)}
def get_cached_credits() -> int:
"""Get cached credits from Redis, default to 0 if not found."""
credits = redis_client.get("sonauto_credits")
return int(credits) if credits else 0
Sonauto Credits API Details:
Endpoint: GET {EXTERNAL_API_URL}/credits/balance
Documentation: https://sonauto.ai/developers#credits-balance-get
Request Headers:
{
"Authorization": f"Bearer {SONAUTO_API_KEY}"
}
Expected Response:
{
"num_credits": 12500,
"num_credits_payg": 0
}
Task 3: Timeout Checker (check_timeouts)
Triggered by: Celery Beat scheduler (every 5 minutes)
Purpose: Mark stale submissions as failed if webhook never arrives
Configuration Constants:
WEBHOOK_TIMEOUT_MINUTES = 10 # Max time to wait for Sonauto webhook
Task Implementation:
@shared_task(bind=True)
def check_timeouts(self):
"""Mark submissions as failed if webhook times out."""
from app.database import SessionLocal
db = SessionLocal()
try:
timeout_threshold = datetime.utcnow() - timedelta(minutes=WEBHOOK_TIMEOUT_MINUTES)
stale_submissions = db.query(Submission).filter(
Submission.sent_to_LLM.isnot(None),
Submission.received_from_LLM.is_(None),
Submission.sent_to_LLM < timeout_threshold
).all()
count = 0
for submission in stale_submissions:
submission.entry_status = 'fail'
submission.LLM_status = 'timeout'
count += 1
logger.warning(f"Submission {submission.session_id} timed out")
db.commit()
return {"timed_out": count}
finally:
db.close()
Task 4: Post-Webhook Processing Chain
Triggered by: Webhook endpoint when Sonauto returns SUCCESS
Purpose: Fetch generation details, download audio, create video (chained tasks)
Task Chain:
from celery import chain
def trigger_post_webhook_processing(session_id: str):
"""Trigger the chained tasks after successful webhook."""
workflow = chain(
fetch_generation_details.s(session_id),
download_audio.s(),
create_video.s()
)
workflow.apply_async()
Task Implementations:
@shared_task(
bind=True,
autoretry_for=(requests.RequestException,),
retry_backoff=True,
retry_backoff_max=60,
retry_kwargs={'max_retries': 3}
)
def fetch_generation_details(self, session_id: str) -> dict:
"""Fetch full generation details from Sonauto API."""
from app.database import SessionLocal
db = SessionLocal()
try:
submission = db.get(Submission, session_id)
response = requests.get(
f"{EXTERNAL_API_URL}/generations/{submission.LLM_task_id}",
headers={"Authorization": f"Bearer {SONAUTO_API_KEY}"},
timeout=30
)
response.raise_for_status()
data = response.json()
# Store full response and extract lyrics
submission.LLM_full_response = json.dumps(data)
submission.LLM_response = data["song_paths"][0]
submission.lyrics = data.get("lyrics", "")
submission.LLM_status = 'success'
db.commit()
logger.info(f"Fetched generation details for {session_id}")
# Return data needed by next task in chain
return {
"session_id": session_id,
"song_url": data["song_paths"][0],
"lyrics": data.get("lyrics", "")
}
finally:
db.close()
@shared_task(
bind=True,
autoretry_for=(requests.RequestException,),
retry_backoff=True,
retry_backoff_max=60,
retry_kwargs={'max_retries': 3}
)
def download_audio(self, result: dict) -> dict:
"""Download MP3 from Sonauto CDN."""
from app.database import SessionLocal
session_id = result["session_id"]
song_url = result["song_url"]
file_path = Path(AUDIO_STORAGE) / f"{session_id}.mp3"
response = requests.get(song_url, stream=True, timeout=120)
response.raise_for_status()
with open(file_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
# Update database
db = SessionLocal()
try:
submission = db.get(Submission, session_id)
submission.generated_song_path = str(file_path)
db.commit()
logger.info(f"Downloaded audio for {session_id}")
finally:
db.close()
# Return data needed by next task in chain
return {
"session_id": session_id,
"audio_path": str(file_path),
"lyrics": result.get("lyrics", "")
}
@shared_task(
bind=True,
autoretry_for=(Exception,),
retry_backoff=True,
retry_backoff_max=60,
retry_kwargs={'max_retries': 1} # Only 1 retry for video generation
)
def create_video(self, result: dict) -> dict:
"""Create MP4 video combining pet photo with audio using the video generator script."""
from app.database import SessionLocal
from app.config import VIDEO_STORAGE
from video_generator import create_video as video_gen
session_id = result["session_id"]
audio_path = result["audio_path"]
db = SessionLocal()
try:
submission = db.get(Submission, session_id)
# Mark video creation start
submission.video_creation_start = datetime.utcnow()
db.commit()
try:
# Set output path for the video
output_path = Path(VIDEO_STORAGE) / f"{session_id}.mp4"
# Call the video generator script's main function
# The script handles all image compositing, rotation animation,
# and FFmpeg encoding internally
video_gen.main(
pet_img_path=submission.photo_path,
audio_track_path=audio_path,
output_path=str(output_path) # Pass custom output path
)
# Verify video was created
if not output_path.exists():
raise FileNotFoundError(f"Video file not created at {output_path}")
# Mark success
submission.generated_video_path = str(output_path)
submission.video_creation_end = datetime.utcnow()
submission.entry_status = 'success'
db.commit()
logger.info(f"Video created for {session_id}: {output_path}")
return {"session_id": session_id, "video_path": str(output_path)}
except Exception as e:
submission.entry_status = 'fail'
submission.video_creation_end = datetime.utcnow()
db.commit()
logger.error(f"Video creation failed for {session_id}: {e}")
raise
finally:
db.close()
Error Handling in Chains:
- Each task has exponential backoff retry (10s → 30s → 60s)
- If any task in chain fails after retries, submission marked as 'fail'
- Celery's
link_errorcan be used for cleanup on failure
Detailed Process Flow (Step-by-Step)
Step 0: Form Submission
Trigger: User submits form on frontend
Actions:
-
Frontend checks for existing session data (via SessionManager):
- Check localStorage for
submission_data.cookie_id - Also check if submission limit reached (
entries.length >= 10) - If limit reached, show "Sold Out" modal and stop
- If
cookie_idexists, include in request payload - If not exists, send
cookie_idas null/empty
- Check localStorage for
-
Frontend validates form data client-side
-
Frontend crops image to 1:1 ratio, ensures < 5MB
-
Frontend sends POST to
/api/submissionswith form data +cookie_id -
Backend receives request and validates:
- Validates all form fields
-
Backend performs cookie-based rate limiting:
- If
cookie_idis present in request:- Query:
SELECT COUNT(*) FROM submissions WHERE cookie_id = '{cookie_id}' - If count >=
FORM_SUBMIT_RETRY(10):- Return 429 error: "Rate limit exceeded"
- Frontend shows "Sold Out" page
- STOP - No session created
- If count <
FORM_SUBMIT_RETRY:- Use existing
cookie_id, proceed to step 7
- Use existing
- Query:
- If
cookie_idis null/empty (first-time user):- Generate new
cookie_idusing Cuid2 - Proceed to step 7
- Generate new
- If
-
Generate unique
session_idusing Cuid2 -
Save cropped image to
{IMG_STORAGE}/{session_id}.jpg -
Insert record into database:
INSERT INTO submissions ( session_id, cookie_id, owner_name, pet_name, pet_type, music_vibe, photo_path, entry_status, created_at ) VALUES ( 'clxyz123...', 'cookie_abc...', 'Matt', 'Toby', 'Dog', 'Romantic', '/uploads/clxyz123.jpg', 'pending', NOW() ); -
Backend returns response to frontend:
- Returns
session_id(always) - Returns
cookie_id(only if newly generated)
- Returns
-
Frontend processes response (via SessionManager):
- If
cookie_idwas returned (first-time user):- Store in localStorage:
submission_data.cookie_id
- Store in localStorage:
- Add entry to localStorage:
submission_data.entries.push({session_id, timestamp}) - Redirect to waiting page
- If
Database State After Step 0:
session_id: clxyz123abc456def789
cookie_id: cookie_abc123xyz
entry_status: pending
created_at: 2026-02-14T10:30:00Z
(all other fields: NULL)
Example Scenarios:
Scenario A - First-time user:
- Request:
cookie_id = null(no localStorage data) - Backend generates:
cookie_id = "cookie_new123" - Response includes:
session_id+cookie_id - Frontend stores in localStorage:
{cookie_id: "cookie_new123", entries: [{session_id, timestamp}]}
Scenario B - Returning user (3rd submission):
- Request:
cookie_id = "cookie_abc123"(from localStorage) - Backend counts: 2 existing submissions with this cookie
- Count (2) < Limit (10) ✓
- Response includes:
session_idonly - Frontend adds to localStorage entries array
Scenario C - Rate limit reached (11th attempt):
- Request:
cookie_id = "cookie_abc123" - Backend counts: 10 existing submissions with this cookie
- Count (10) >= Limit (10) ✗
- Response: 429 error
- Frontend shows "Sold Out" modal
Step 1: Queue Processing & Sonauto API Call
Trigger: Celery Beat scheduled task process_pending_queue (runs every 1 minute)
Actions:
- Worker queries pending submissions:
SELECT * FROM submissions WHERE entry_status = 'pending' AND sent_to_LLM IS NULL ORDER BY created_at ASC; - Worker checks available slots (max 10 concurrent requests)
- Worker checks available credits from Redis cache
- IF credits > 0 AND slots available:
- Construct payload with form data:
{ "tags": ["Romantic", "love song", "valentine"], "prompt": "Create a heartfelt Valentine's Day love song celebrating the special bond between Matt and their beloved Dog Toby. Make it warm, genuine, and mention both names in the lyrics.", "instrumental": false, "output_format": "mp3", "webhook_url": "https://mysite.com/api/webhook" } - Send POST to
{EXTERNAL_API_URL}/generations/v3 - Receive response:
{"task_id": "0b2a66c0-d9f2..."} - Update database:
UPDATE submissions SET LLM_task_id = '0b2a66c0-d9f2...', sent_to_LLM = NOW(), entry_status = 'processing' WHERE session_id = 'clxyz123...';
- Construct payload with form data:
Database State After Step 1:
session_id: clxyz123abc456def789
entry_status: processing
LLM_task_id: 0b2a66c0-d9f2-43d2-9fa7-b9c95c29324d
sent_to_LLM: 2026-02-14T10:31:00Z
Error Handling:
- If Sonauto API returns error, Celery retries with exponential backoff (10s → 30s → 60s)
- After 3 failed retries,
entry_statusset to "fail" andretry_countincremented - If credits = 0, skip processing (Celery Beat will check again next run)
- Log all API calls and responses
Step 2: Webhook Callback Reception
Trigger: Sonauto API sends webhook when processing completes
Sonauto Sends (Success):
{
"task_id": "0b2a66c0-d9f2-43d2-9fa7-b9c95c29324d",
"status": "SUCCESS",
"song_paths": ["pubapi/generations2/audio_0b2a66c0...._0.mp3"]
}
Sonauto Sends (Failure):
{
"task_id": "0b2a66c0-d9f2-43d2-9fa7-b9c95c29324d",
"status": "FAILURE",
"error_message": "Generation failed due to content policy"
}
Actions:
- Webhook endpoint
/api/webhookreceives POST request - Extract
task_idfrom payload - Query database:
SELECT * FROM submissions WHERE LLM_task_id = '0b2a66c0-d9f2...'; - Validate that record exists (security check)
- IF status = "SUCCESS":
- Update database:
UPDATE submissions SET received_from_LLM = NOW() WHERE LLM_task_id = '0b2a66c0-d9f2...'; - Trigger Celery task chain:
fetch_generation_details→download_audio→create_video
- Update database:
- IF status = "FAILURE":
- Update database:
UPDATE submissions SET LLM_status = 'fail', entry_status = 'fail', received_from_LLM = NOW() WHERE LLM_task_id = '0b2a66c0-d9f2...'; - End journey (user will see failure message)
- Update database:
Database State After Step 2 (Success):
entry_status: processing
received_from_LLM: 2026-02-14T10:35:00Z
Database State After Step 2 (Failure):
entry_status: fail
LLM_status: fail
received_from_LLM: 2026-02-14T10:35:00Z
Timeout Mechanism:
- If no webhook received within 10 minutes (configurable:
WEBHOOK_TIMEOUT_MINUTES), mark as failed - Celery Beat scheduled task
check_timeoutsruns every 5 minutes to mark stale submissions as failed
Step 3: Fetch Full Generation Details
Trigger: Celery task fetch_generation_details (first task in post-webhook chain)
Actions:
- Use
task_idto fetch complete details from Sonauto - Send GET request to
{EXTERNAL_API_URL}/generations/{task_id} - Receive full response object (see example below)
- Parse response and extract:
song_paths[0]: Full URL to MP3 filelyrics: Complete song lyricsstatus: Confirm it's "SUCCESS"
- Store entire response in database:
UPDATE submissions SET LLM_full_response = '<entire_json_response>', LLM_response = '<song_url>', LLM_status = 'success' WHERE LLM_task_id = '0b2a66c0-d9f2...'; - Celery chain automatically proceeds to Step 4 (download audio)
Sonauto Get Generation API Details:
Endpoint: GET {EXTERNAL_API_URL}/generations/{task_id}
Documentation: https://sonauto.ai/developers#generations-taskid-get
Request Headers:
{
"Authorization": f"Bearer {SONAUTO_API_KEY}"
}
Example Success Response:
{
"id": "4273ad54-943a-4202-beac-4011918f4f3d",
"created_at": "2026-01-23T11:47:30.017936+00:00",
"status": "SUCCESS",
"alignment_status": null,
"model_version": "v2.2",
"song_paths": [
"https://cdn.sonauto.ai/pubapi/generations2/audio_4273ad54-943a-4202-beac-4011918f4f3d_0.mp3"
],
"error_message": null,
"lyrics": "[Chorus]\nMatt and Toby, best friends down the line\nFetchin' forever, hearts in a canine bind\nOne wags a tail, one holds the leash just right\nIt's puppy love by moonlight tonight\n\n[Post-Chorus]\nWoo-oo, woof woof, hearts are true\nWoo-oo, biscuits for two\n\n[Verse 2]\nMatt once tried Tinder, but it just went south\nUntil Toby slobbered all over his mouth\nShared a steak dinner, you shoulda seen Matt's face\nDog got the fries and left him the plate\n\n[Pre-Chorus]\nLove is muddy pawprints and fur on his jeans\nEvery Valentine's, you know what this means…\n\n[Chorus]\nMatt and Toby, best friends down the line\nFetchin' forever, hearts in a canine bind\nOne wags a tail, one holds the leash just right\nIt's puppy love by moonlight tonight\n\n[Post-Chorus]\nWoo-oo, woof woof, hearts are true\nWoo-oo, biscuits for two",
"prompt": "Create a funny Valentine's love ballad in a country and western style celebrating the love between Matt and a pet dog called Toby.",
"prompt_strength": 2.0,
"tags": ["rock", "energetic"],
"v2_params": {
"balance_strength": 0.7,
"bpm": "auto",
"seed": 3644529975
},
"extend_params": null,
"inpaint_params": null
}
Database State After Step 3:
LLM_status: success
LLM_response: https://cdn.sonauto.ai/pubapi/generations2/audio_4273ad54...mp3
LLM_full_response: {entire JSON object}
Step 4: Download Audio File
Trigger: Celery task download_audio (second task in chain, receives data from Step 3)
Actions:
- Extract song URL from
song_paths[0] - Download MP3 file from URL
- Save to
{AUDIO_STORAGE}/{session_id}.mp3 - Update database with file path:
UPDATE submissions SET generated_song_path = '/storage/audio/clxyz123.mp3' WHERE session_id = 'clxyz123...'; - Celery chain automatically proceeds to Step 5 (video creation)
Implementation Example (Python):
import requests
from pathlib import Path
from config import AUDIO_STORAGE
def download_audio(song_url: str, session_id: str) -> str:
"""Download MP3 from Sonauto CDN and save locally."""
file_path = Path(AUDIO_STORAGE) / f"{session_id}.mp3"
response = requests.get(song_url, stream=True, timeout=60)
response.raise_for_status()
with open(file_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
return str(file_path)
Database State After Step 4:
generated_song_path: /storage/audio/clxyz123abc456def789.mp3
Error Handling:
- If download fails, retry up to 3 times
- If all retries fail, mark
entry_statusas "fail" - Log download errors with full URL for debugging
Step 5: Video Creation
Trigger: Celery task create_video (final task in chain, receives data from Step 4)
IMPORTANT: Celery Worker Requirement
Video generation MUST execute within a Celery worker queue, not in the main web process. This is required because:
- Long-running operation: Video generation takes 30-90 seconds depending on audio length, which would block web requests and cause timeouts
- CPU-intensive: Image compositing and FFmpeg encoding are computationally expensive and would degrade API response times
- Memory usage: Loading images and streaming frames to FFmpeg requires significant memory that should be isolated from the web process
- Retry capability: Celery provides automatic retry with exponential backoff if FFmpeg fails
- Concurrency control: Worker concurrency settings prevent resource exhaustion from parallel video generation jobs
The create_video task runs as the final step in the post-webhook Celery chain:
fetch_generation_details → download_audio → create_video
Actions:
- Update database with start timestamp:
UPDATE submissions SET video_creation_start = NOW() WHERE session_id = 'clxyz123...'; - Retrieve inputs:
- Pet image:
{IMG_STORAGE}/{session_id}.jpg - Audio file:
{AUDIO_STORAGE}/{session_id}.mp3 - Static assets loaded by script from
video_generator/assets/
- Pet image:
- Call video generator script's
main()function (see Video Creation Process section) - Output:
{VIDEO_STORAGE}/{session_id}.mp4 - Update database with completion:
UPDATE submissions SET generated_video_path = '/storage/video/clxyz123.mp4', video_creation_end = NOW(), entry_status = 'success' WHERE session_id = 'clxyz123...';
Video Creation Process Details:
Implementation: The video generation uses a ready-to-integrate Python script located at video_generator_example/create-video.py.
Inputs:
- Pet image: 1:1 aspect ratio (cropped by user, 600×600px) - resized to 360×360px during processing
- Audio track: MP3 file from Sonauto
Static Assets (included in video_generator_example/assets/):
1080x1080-bg.png- Background image (1080×1080px)736-x-736-record.png- Vinyl record template (736×736px, transparent PNG)needle.png- Needle overlay (1080×1080px, transparent PNG)
Note: These assets should be copied to the backend deployment location (e.g., backend/assets/video/) and the script's ASSET_DIR constant updated accordingly.
Processing Approach (Optimized):
The script uses an efficient streaming approach that generates only ONE rotation cycle and uses FFmpeg's loop filter to seamlessly repeat it for the full audio duration. This is significantly faster than generating all frames.
-
Audio Analysis:
- Read MP3 duration using
mutagenlibrary - Validate audio is long enough for at least one full rotation
- Read MP3 duration using
-
Image Preparation:
- Load and resize all images to target resolution
- Pet image resized to 360×360px (scaled proportionally with video resolution)
- All images converted to RGBA for proper alpha compositing
-
Frame Generation (Single Rotation Cycle):
- Calculate frames needed for one 360° rotation based on RPM and frame rate
- At 20 RPM and 15 fps:
(60/20) * 15 = 45 framesper rotation - For each frame in the rotation cycle:
- Create composite frame with background
- Rotate pet image and vinyl template by calculated angle
- Overlay stationary needle on top
- Stream frame directly to FFmpeg stdin (no temp files)
-
Video Compilation (Streaming to FFmpeg):
- FFmpeg receives frames via stdin pipe
- Uses
-filter_complexwithloopfilter to repeat the rotation cycle - Combines looped video with audio track
- Outputs H.264 encoded MP4 with AAC audio
-
No Cleanup Needed:
- No temporary frame files created (streamed directly to FFmpeg)
Output:
- MP4 video file: 720×720px (configurable), 15fps, H.264 codec
- Duration matches audio track length exactly
- Vinyl record rotates clockwise at 20 RPM continuously
- Needle remains stationary creating realistic turntable effect
- Typical file size: ~2-5MB for a 2-minute song
Script Configuration (in create-video.py):
# Video settings (can be adjusted)
FINAL_VIDEO_WIDTH_PX = 720 # Output resolution
FINAL_VIDEO_HEIGHT_PX = 720
FRAME_RATE = 15 # Frames per second
VINYL_RPM = 20 # Rotation speed
# Quality settings (in FFmpeg command)
CRF = 28 # 18=high quality, 28=good/smaller, 32+=lower
AUDIO_BITRATE = "192k"
Dependencies:
- Python 3.13+
pillow- Image processingmutagen- MP3 duration readingffmpeg- Must be installed on system PATH
Running the Script:
# Using uv (recommended)
uv run create-video.py
# Or with pip-installed dependencies
python create-video.py
Integration with Backend:
from video_generator_example.create_video import main as generate_video
# In the create_video Celery task:
generate_video(
pet_img_path=submission.photo_path,
audio_track_path=audio_path
)
Database State After Step 5 (Success):
entry_status: success
generated_video_path: /storage/video/clxyz123abc456def789.mp4
video_creation_start: 2026-02-14T10:36:00Z
video_creation_end: 2026-02-14T10:37:30Z
Error Handling:
- If FFmpeg fails, retry once
- If retry fails, mark
entry_statusas "fail" - Log all FFmpeg output for debugging (captured via stderr)
- No temp file cleanup needed (streaming approach creates no temp files)
Configuration & Constants
All configurable values should be stored as environment variables or in a configuration file.
Redis & Celery:
REDIS_URL = redis://localhost:6379/0
CELERY_BROKER_URL = redis://localhost:6379/0
CELERY_RESULT_BACKEND = redis://localhost:6379/0
Queue & Rate Limiting:
MAX_CONCURRENT_REQUESTS = 10
MAX_RETRIES = 3 # Maximum retry attempts per submission
FORM_SUBMIT_RETRY = 10 # Maximum submissions allowed per cookie
Celery Beat Schedule:
QUEUE_PROCESSOR_INTERVAL = 60 # seconds (1 minute)
CREDITS_CHECK_INTERVAL = 600 # seconds (10 minutes)
TIMEOUT_CHECK_INTERVAL = 300 # seconds (5 minutes)
Credits Management:
MIN_AVAILABLE_CREDITS = 5000
Timeouts:
WEBHOOK_TIMEOUT_MINUTES = 10
API_REQUEST_TIMEOUT_SECONDS = 30
File Storage Paths:
IMG_STORAGE = ./storage/uploads
AUDIO_STORAGE = ./storage/audio
VIDEO_STORAGE = ./storage/video
External API:
EXTERNAL_API_URL = https://api.sonauto.ai/v1
SONAUTO_API_KEY = <your_api_key>
WEBHOOK_BASE_URL = https://yourdomain.com
Video Generation:
# These are configured in video_generator/create_video.py
VIDEO_FPS = 15 # Frame rate (lower = faster generation)
VINYL_RPM = 20 # Rotation speed
VIDEO_RESOLUTION = 720 # 720×720px output (configurable)
PET_IMAGE_SIZE = 360 # 360×360px (scaled proportionally)
VINYL_SIZE = 736 # 736×736px (scaled proportionally)
VIDEO_CRF = 28 # Quality (18=high, 28=good/smaller)
# Asset paths (relative to backend root)
VIDEO_ASSETS_DIR = ./assets/video
VIDEO_BACKGROUND = 1080x1080-bg.png
VIDEO_VINYL_TEMPLATE = 736-x-736-record.png
VIDEO_NEEDLE = needle.png
Data Retention:
FILE_RETENTION_DAYS = 30 # Delete generated files after 30 days
Error Handling & Edge Cases
1. Rate Limit Exceeded:
- User has reached maximum submissions allowed per
cookie_id(FORM_SUBMIT_RETRY= 10) - Client-side check via
SessionManager.hasReachedSubmissionLimit()shows modal immediately - Backend also returns 429 error if client-side check bypassed
- Frontend shows "Sold Out" modal with message:
- "Sorry, we're all sold out!"
- No retry option available
- User would need to clear localStorage or use different browser/device to reset
2. Form Validation Errors:
- Display inline validation messages
- Keep form data intact for user to correct
- Frontend handles before API call
3. Upload Failures:
- Retry mechanism on network errors
- Clear error message for file format/size issues
- Fallback: Ask user to re-upload
4. API Timeout:
- After 5 minutes of polling with no status change, show:
- "Processing is taking longer than expected"
- "We'll email you when ready" (optional feature)
- "Check back later" with bookmark link
5. External API Failures:
- Webhook includes failure status → Show user-friendly error
- Suggested message: "We couldn't generate your song. Please try again with different inputs."
- Log detailed error for debugging
6. Queue Full:
- When all 10 slots occupied, new submissions wait in queue
- Display estimated wait time on waiting page (optional)
- No "try again later" message needed (queue will process automatically)
7. Credits Exhausted:
- When credits = 0, Celery
process_pending_queuetask skips processing - Frontend shows "Sold Out" page when trying to submit
- Check credits before form submission (optional API endpoint)
- Alerting: Log critical message when credits reach 0, warning when below
MIN_AVAILABLE_CREDITS
8. Database Connection Issues:
- Implement connection pooling and retry logic
- Log errors and alert administrators
- Show generic error to user: "Service temporarily unavailable"
9. Video Generation Failures:
- FFmpeg errors logged with full output
- Retry once automatically
- If failed, mark
entry_status= "fail" - User sees: "Video creation failed. Please try again."
10. Webhook Never Arrives:
- Celery Beat
check_timeoutstask marks submission as failed after 10 minutes - User sees failure message with retry option
- Log timeout incidents for monitoring
11. Concurrent Access:
- Session IDs are unique (Cuid2), no collision risk
- Database uses row-level locking for updates
- No special handling needed for concurrent users
Alerting & Monitoring
Log Levels:
CRITICAL: Credits exhausted, database connection failedERROR: Submission failed after all retries, video generation failedWARNING: Credits below threshold, webhook timeout, retry attemptsINFO: Normal operations (submissions processed, tasks completed)
Recommended Monitoring:
- Set up log aggregation (e.g., CloudWatch, Datadog, ELK stack)
- Alert on
CRITICALandERRORlog levels - Dashboard for:
- Queue depth (pending submissions)
- Processing time (submission to video completion)
- Success/failure rates
- Sonauto credits remaining
Health Check Integration:
- Use
/api/healthendpoint with your load balancer or monitoring service - Alert if health check returns 503 or times out
Security Considerations
Public Access:
- No authentication required for viewing results (public share links)
- Session IDs are cryptographically random (Cuid2 provides ~2^120 bits of entropy)
- URLs are shareable:
https://yourdomain.com/result/clxyz123...
Rate Limiting:
- Cookie-based rate limiting (primary): Maximum 10 submissions per
cookie_id(seeFORM_SUBMIT_RETRYconstant) - IP-based rate limiting (secondary):
/api/submissionsendpoint: 20 requests per hour per IP (prevents rapid localStorage clearing)/api/results/{session_id}: 100 requests per hour per IP
- Use Redis or similar for distributed rate limiting
- Cookie-based limiting is the main defense against abuse
Input Validation:
- Sanitize all user inputs before database insertion
- Validate image file types (check magic bytes, not just extension)
- Limit image file size strictly to 5MB
- Escape special characters in names for database queries
Webhook Security:
- Validate
task_idexists in database before processing - Consider IP whitelisting for webhook endpoint (Sonauto's IP range)
- Log all webhook requests with timestamps for audit trail
- Implement webhook signature validation if Sonauto provides it
File Storage:
- Store user uploads in isolated directory
- Use session_id as filename (prevents directory traversal)
- Direct file serving acceptable for this temporary campaign (simpler deployment)
- Set proper file permissions (read-only for web server)
Database:
- Use parameterized queries (prevent SQL injection)
- Implement connection pooling with proper credential management
- Regular backups of submissions table
- Index on
session_idandLLM_task_idfor performance
Session Management
Session ID Generation:
- Use Cuid2 algorithm for collision-resistant, sortable IDs
- Example:
clxyz123abc456def789 - Store in database as primary key
Frontend Storage:
- Uses
SessionManagermodule inassets/js/home.js - Stores session data in localStorage under key
submission_data:{ "cookie_id": "cookie_abc123xyz", "entries": [ { "session_id": "clxyz123abc456def789", "timestamp": "2026-02-14T10:30:00Z" } ] } cookie_idpersists across submissions for rate limitingentriesarray tracks all user submissions- Allows users to revisit their creations
- Persists across browser sessions
Session Tracking (Rate Limiting):
- Storage Key:
submission_datain localStorage - Purpose: Core rate limiting mechanism to prevent abuse
- Data Structure:
{ "cookie_id": "cookie_abc123xyz", // Persisted identifier for rate limiting "entries": [ { "session_id": "clxyz123...", "timestamp": "2026-02-14T10:30:00Z" } ] } - Generation:
cookie_idgenerated by backend on user's first form submission using Cuid2 algorithm- Stored in localStorage and sent with subsequent requests
- Rate Limiting:
- Client-side:
SessionManager.hasReachedSubmissionLimit(10)checks entries count - Server-side: Each
cookie_idis limited toFORM_SUBMIT_RETRY(10) submissions - Backend counts existing submissions per cookie before accepting new ones
- If limit reached, user sees "Sold Out" modal
- Client-side:
- Browser Behavior:
- Persists across page refreshes and browser restarts
- Clearing localStorage resets the limit (new cookie generated on next submission)
- Different browsers/devices get different identifiers
Sharing:
- Results are publicly accessible via session_id
- No login required to view shared links
- Share URL format:
https://yourdomain.com/result/{session_id}
Database Schema
Table Specification: submissions
| Column Name | Data Type | Constraints / Attributes | Default Value | Description / Comments |
|---|---|---|---|---|
session_id |
String | Primary Key, Unique | NULL | Unique identifier for the user session. |
created_at |
Timestamp | useCurrent() |
CURRENT_TIMESTAMP |
The timestamp when the record was created. |
cookie_id |
String | Indexed (not unique) | NULL | Cookie identifier for rate limiting (multiple submissions per cookie allowed). |
owner_name |
String | Required | NULL | Pet owner's name (2-100 chars, letters and spaces only). |
photo_path |
String | Required | NULL | File path/location of the uploaded photo. |
pet_name |
String | Required | NULL | User-provided name of the pet (2-100 chars, letters and spaces only). |
pet_type |
String | Required | NULL | Type of pet: Dog, Cat, Bird, Fish, or Other. |
music_vibe |
String | Required | NULL | Selected music vibe: Chill, Energetic, Romantic, Party, Relaxing, or Other. |
retry_count |
Integer | Nullable | 0 | Number of retry attempts for failed API calls. |
sent_to_LLM |
Timestamp | Nullable | NULL | Time the request was sent to the Sonauto/LLM service. |
LLM_task_id |
String | Nullable | NULL | Service-specific ID used to track the task progress. |
received_from_LLM |
Timestamp | Nullable | NULL | Time the callback was received from the service. |
LLM_response |
Text | Nullable | NULL | Processed callback data from the service. |
LLM_full_response |
Text | Nullable | NULL | Raw/Full response data from the service. |
generated_song_path |
Text | Nullable | NULL | Storage location of the generated audio file. |
LLM_status |
String | Nullable | NULL | Status of AI generation (e.g., success, fail). |
lyrics |
Text | Nullable | NULL | Extracted song lyrics from LLM response. |
video_creation_start |
Timestamp | Nullable | NULL | Timestamp for the beginning of the video render process. |
video_creation_end |
Timestamp | Nullable | NULL | Timestamp for the completion of the video render process. |
generated_video_path |
String | Nullable | NULL | Storage location of the final rendered video file. |
entry_status |
String | Nullable | 'pending' |
Overall workflow status: pending, processing, success, fail. |
Frontend Integration
The frontend uses PHP templates with Alpine.js for reactive form handling. Most form logic is implemented; some API integration remains.
Current Implementation
Technology Stack:
- Alpine.js v3.15.5 - Reactive form state management
- Cropper.js v1.6.2 - Image cropping
- Vanilla JavaScript - SessionManager module, result page controls
File Structure:
index.php # Form page (Alpine.js integration complete)
waiting.php # Loading page (static, needs polling)
result.php # Results page (UI complete, needs API integration)
header.php # Shared header component
footer.php # Shared footer component
opengraph.php # Open Graph and Twitter Card meta tags
assets/
├── js/home.js # Alpine.js form component + SessionManager
├── css/style.css # Complete styling with responsive breakpoints
└── images/ # All UI assets
Files Status
1. index.php (Form Page) - MOSTLY COMPLETE
Implemented in assets/js/home.js:
- ✅ Alpine.js component
petSongFormwith reactive form state - ✅
SessionManagermodule for localStorage handling - ✅ Read
cookie_idfrom localStorage on page load - ✅ Form submission via fetch API to
POST /api/submissions - ✅ Build JSON payload from form fields + cropped image base64
- ✅ Include
cookie_idin request payload - ✅ Client-side rate limit check before submission
- ✅ Image validation (5MB max, 400×400px minimum)
- ✅ Cropper.js integration with accept/cancel workflow
- ✅ "Sold Out" modal UI
Still Required:
- ❌ Add
closeErrorModal()function (called in HTML but not defined) - ❌ Handle 429 response to show "Sold Out" modal (currently throws generic error)
- ❌ Update redirect from
success.htmltowaiting.php?session_id={id}
2. waiting.php - NEEDS IMPLEMENTATION
Current state: Static page showing loading GIF animation
Required JavaScript additions:
- Extract
session_idfrom URL query parameter - Implement polling loop:
- Call
GET /api/submissions/{session_id}/statusevery 10 seconds - Maximum polling duration: 5 minutes (30 attempts)
- On
status: "success": redirect to/result.php?session_id={id} - On
status: "fail": redirect to/result.php?session_id={id}(shows failure message) - On timeout (5 minutes): show error message with retry option
- Call
3. result.php - UI COMPLETE, NEEDS API INTEGRATION
Current state: Full UI implemented with placeholder content
Implemented:
- ✅ Rotating vinyl record with pet photo placeholder
- ✅ Play/pause toggle (controls record rotation animation)
- ✅ Share icons (TikTok, Instagram, Facebook)
- ✅ Download button UI
- ✅ Copy URL button UI
- ✅ "Need another song" button
- ✅ Promotional banners (head-tails, club-member)
- ✅ Lyrics display area (placeholder text)
Required JavaScript additions:
- Extract
session_idfrom URL query parameter - Call
GET /api/results/{session_id}on page load - On success:
- Replace rotating record with HTML5 video player OR
- Load pet photo into record center and play audio separately
- Display actual lyrics from API response
- Wire download button to
video_url - Wire copy URL button to copy current page URL
- Wire share icons to respective platforms
- On failure status:
- Show failure message
- "Need another song" button already links to form page
Video Player Option (replace rotating record):
<video id="video-player" controls playsinline>
<source src="" type="video/mp4">
Your browser does not support the video tag.
</video>
Copy URL Implementation:
document.querySelector('.copy-btn').addEventListener('click', () => {
navigator.clipboard.writeText(window.location.href);
// Show "Copied!" feedback
});
4. opengraph.php - COMPLETE
Implemented Open Graph and Twitter Card meta tags for social sharing:
- Twitter Card (summary_large_image)
- Open Graph base data (url, title, description, site_name, locale)
- Open Graph image (1200×630px)
URL Routing
The frontend needs URL routing to support clean URLs:
/→ index.php (form)/waiting/{session_id}→ waiting.php/result/{session_id}→ result.php
Apache .htaccess (place in document root):
RewriteEngine On
# Don't rewrite existing files or directories
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# Rewrite /waiting/{session_id} to waiting.php?session_id={session_id}
RewriteRule ^waiting/([a-zA-Z0-9]+)$ waiting.php?session_id=$1 [L,QSA]
# Rewrite /result/{session_id} to result.php?session_id={session_id}
RewriteRule ^result/([a-zA-Z0-9]+)$ result.php?session_id=$1 [L,QSA]
PHP to read session_id from URL:
// In waiting.php and result.php
$session_id = $_GET['session_id'] ?? null;
if (!$session_id) {
header('Location: /');
exit;
}
Deployment (Docker)
The backend runs in Docker containers with PostgreSQL database, Redis for Celery, and the FastAPI application. The frontend (PHP) is served separately via Apache on the host server.
Architecture Overview
┌─────────────────────────────────────────────────────────┐
│ Apache (Host) │
│ - Serves PHP frontend (index.php, waiting.php, etc.) │
│ - Proxies /api/* requests to Docker backend │
│ - Serves /storage/* files directly │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Docker Compose Stack │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ FastAPI │ │ Celery │ │ Celery │ │
│ │ (web) │ │ Worker │ │ Beat │ │
│ │ port 8000 │ │ │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ PostgreSQL + Redis │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Apache Configuration
Add proxy rules to forward API requests to the Docker backend:
# /etc/apache2/sites-available/pah.conf
<VirtualHost *:80>
ServerName your-domain.com
DocumentRoot /var/www/pah
# Serve PHP frontend
<Directory /var/www/pah>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
# Proxy API requests to Docker backend
ProxyPreserveHost On
ProxyPass /api/ http://localhost:8000/api/
ProxyPassReverse /api/ http://localhost:8000/api/
# Serve storage files directly (or proxy if preferred)
Alias /storage /var/www/pah/storage
<Directory /var/www/pah/storage>
Require all granted
</Directory>
</VirtualHost>
Enable required Apache modules:
sudo a2enmod proxy proxy_http rewrite
sudo systemctl restart apache2
Docker Services
# docker-compose.yml
version: '3.8'
services:
web:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://pah:pah_password@db:5432/pah
- REDIS_URL=redis://redis:6379/0
- CELERY_BROKER_URL=redis://redis:6379/0
- SONAUTO_API_KEY=${SONAUTO_API_KEY}
- WEBHOOK_BASE_URL=${WEBHOOK_BASE_URL}
volumes:
- ./storage:/app/storage
depends_on:
- db
- redis
celery_worker:
build: .
command: celery -A celery_app worker --loglevel=info --concurrency=4
environment:
- DATABASE_URL=postgresql://pah:pah_password@db:5432/pah
- REDIS_URL=redis://redis:6379/0
- CELERY_BROKER_URL=redis://redis:6379/0
- SONAUTO_API_KEY=${SONAUTO_API_KEY}
- WEBHOOK_BASE_URL=${WEBHOOK_BASE_URL}
volumes:
- ./storage:/app/storage
depends_on:
- db
- redis
celery_beat:
build: .
command: celery -A celery_app beat --loglevel=info
environment:
- DATABASE_URL=postgresql://pah:pah_password@db:5432/pah
- REDIS_URL=redis://redis:6379/0
- CELERY_BROKER_URL=redis://redis:6379/0
depends_on:
- db
- redis
db:
image: postgres:15
environment:
- POSTGRES_USER=pah
- POSTGRES_PASSWORD=pah_password
- POSTGRES_DB=pah
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
postgres_data:
Dockerfile
FROM python:3.11-slim
# Install FFmpeg for video generation
RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Create storage directories
RUN mkdir -p storage/uploads storage/audio storage/video
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
requirements.txt
fastapi>=0.100.0
uvicorn[standard]>=0.23.0
sqlalchemy>=2.0.0
alembic>=1.11.0
celery[redis]>=5.3.0
redis>=4.5.0
requests>=2.28.0
psycopg2-binary>=2.9.0
Pillow>=10.0.0
cuid2>=2.0.0
python-multipart>=0.0.6
pydantic>=2.0.0
pydantic-settings>=2.0.0
mutagen>=1.47.0 # For reading MP3 duration in video generation
Database Migrations (Alembic)
Initialize Alembic:
# Inside the backend directory
alembic init alembic
# Edit alembic.ini to set sqlalchemy.url or use env var
alembic/env.py configuration:
from app.models import Base
from app.config import settings
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
target_metadata = Base.metadata
Create and run migrations:
# Create migration
alembic revision --autogenerate -m "Create submissions table"
# Apply migration
alembic upgrade head
SQLAlchemy Model (app/models.py):
from sqlalchemy import Column, String, Integer, Text, DateTime, Index
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime
Base = declarative_base()
class Submission(Base):
__tablename__ = 'submissions'
session_id = Column(String(255), primary_key=True)
created_at = Column(DateTime, default=datetime.utcnow)
cookie_id = Column(String(255), index=True)
pet_name = Column(String(100), nullable=False)
pet_type = Column(String(50), nullable=False)
photo_path = Column(String(500), nullable=False)
music_vibe = Column(String(50), nullable=False)
owner_name = Column(String(100), nullable=False)
retry_count = Column(Integer, default=0)
sent_to_LLM = Column(DateTime)
LLM_task_id = Column(String(255))
received_from_LLM = Column(DateTime)
LLM_response = Column(Text)
LLM_full_response = Column(Text)
generated_song_path = Column(Text)
LLM_status = Column(String(50))
lyrics = Column(Text)
video_creation_start = Column(DateTime)
video_creation_end = Column(DateTime)
generated_video_path = Column(String(500))
entry_status = Column(String(50), default='pending')
__table_args__ = (
Index('ix_submissions_llm_task_id', 'LLM_task_id'),
)
FastAPI Application Structure:
backend/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI app, routes
│ ├── models.py # SQLAlchemy models
│ ├── schemas.py # Pydantic schemas
│ ├── config.py # Settings
│ └── database.py # DB session management
├── tasks/
│ ├── __init__.py
│ ├── celery_app.py # Celery configuration
│ └── workers.py # Celery tasks
├── video_generator/
│ ├── __init__.py
│ ├── create_video.py # Video generation script (from video_generator_example/)
│ └── assets/ # Video assets (copied from video_generator_example/assets/)
│ ├── 1080x1080-bg.png
│ ├── 736-x-736-record.png
│ └── needle.png
├── alembic/
│ └── versions/
├── alembic.ini
├── requirements.txt
└── Dockerfile
Video Generator Setup:
Copy the video generator from the example folder to the backend:
# From project root
mkdir -p backend/video_generator
cp video_generator_example/create-video.py backend/video_generator/create_video.py
cp -r video_generator_example/assets backend/video_generator/
# Update ASSET_DIR in create_video.py to point to the new location
# ASSET_DIR = "./video_generator/assets"
The script's main() function should be modified to accept an optional output_path parameter for integration with the Celery task:
def main(pet_img_path, audio_track_path, output_path=None):
"""Main execution function.
Args:
pet_img_path: Path to the pet image
audio_track_path: Path to the audio MP3 file
output_path: Optional custom output path (uses default if not provided)
"""
global OUTPUT_VIDEO
if output_path:
OUTPUT_VIDEO = output_path
# ... rest of the function
CORS Configuration (app/main.py):
Since the PHP frontend makes JavaScript fetch calls to the API (via Apache proxy), CORS middleware should be configured:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="PAH Backend API")
# CORS configuration
# When using Apache proxy, requests appear same-origin, but configure anyway for flexibility
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost",
"http://localhost:8000",
"https://your-domain.com", # Production domain
],
allow_credentials=True,
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
Note: When Apache proxies /api/* to the backend, the browser sees same-origin requests, so CORS may not be strictly necessary. However, including it provides flexibility for direct API access during development.
Running the Stack
# Start all services
docker-compose up -d
# Run database migrations
docker-compose exec web alembic upgrade head
# View logs
docker-compose logs -f
# View specific service logs
docker-compose logs -f web
docker-compose logs -f celery_worker
# Stop all services
docker-compose down
# Stop and remove volumes (WARNING: deletes database)
docker-compose down -v
Environment Variables (.env file)
# Required
SONAUTO_API_KEY=your_sonauto_api_key_here
WEBHOOK_BASE_URL=https://your-domain.com
# Optional (have defaults in docker-compose)
DATABASE_URL=postgresql://pah:pah_password@db:5432/pah
REDIS_URL=redis://redis:6379/0
Storage Volumes
The storage directory should be shared between Docker and Apache:
- Mount the same directory in docker-compose (
./storage:/app/storage) - Symlink or configure Apache to serve from the same location
# On host, ensure storage directory exists and has correct permissions
mkdir -p /var/www/pah/storage/{uploads,audio,video}
chmod -R 755 /var/www/pah/storage
# In docker-compose, mount to this location:
volumes:
- /var/www/pah/storage:/app/storage
Storage contents:
storage/uploads/- Uploaded pet images ({session_id}.jpg)storage/audio/- Downloaded MP3 files from Sonauto ({session_id}.mp3)storage/video/- Generated MP4 videos ({session_id}.mp4)