pahvalentines/documents/spec.md
michael 9d53adaaf3 Add backend API, video generator, and frontend updates
- 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>
2026-01-30 10:31:58 -06:00

2556 lines
80 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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_id` exists 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/submissions` including:
- 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_id` from backend
- If `cookie_id` was returned by backend (first-time user), store it in localStorage via `SessionManager.updateSession()`
- Push session object to `localStorage` array: `[{created_at: timestamp, session_id: xxxxx}]`
- Navigate to loading/waiting screen
- **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_status` for 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
1. User submits form → Data saved to DB with "pending" status
2. Celery Beat task picks up pending submissions → Sends to Sonauto API
3. Sonauto processes asynchronously → Sends webhook callback when complete
4. Webhook triggers Celery task chain → Fetches details, downloads audio, creates video
5. Video creation completes → Updates status to "success"
6. 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:**
```json
{
"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 only
- `pet_type`: Required, one of: Dog, Cat, Bird, Fish, Other
- `photo`: Required, valid base64 JPEG image, 600×600px, ~50-150KB typical
- `music_vibe`: Required, one of: Chill, Energetic, Romantic, Party, Relaxing, Other
- `owner_name`: Required, 2-100 chars, letters and spaces only
- `cookie_id`: Optional, string
**Process:**
1. Validate all form fields
2. **Cookie Rate Limiting Check:**
- If `cookie_id` is 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
- If `cookie_id` is null/empty (first-time user):
- Generate new `cookie_id` using Cuid2 algorithm
- Proceed to step 3
3. Generate unique `session_id` using Cuid2 algorithm
4. Save uploaded image to `IMG_STORAGE/{session_id}.jpg`
5. Insert record into `submissions` table:
```sql
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()
);
```
6. Return response to frontend
**Success Response (200):**
```json
{
"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):**
```json
{
"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):**
```json
{
"success": false,
"error": "Invalid image format",
"message": "Please upload JPG or PNG image under 5MB"
}
```
**Error Response - Server Error (500):**
```json
{
"success": false,
"error": "server_error",
"message": "Something went wrong. Please try again."
}
```
**Python Implementation (FastAPI):**
```python
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:**
1. Query database for record with matching `session_id`
2. Return `entry_status` field value
**Success Response (200):**
```json
{
"session_id": "clxyz123abc456def789",
"status": "pending", // or "processing", "success", "fail"
"created_at": "2026-02-14T10:30:00Z"
}
```
**Status Values:**
- `pending`: Waiting in queue
- `processing`: Being processed by Sonauto API
- `success`: Video generation complete, ready to view
- `fail`: Generation failed at any stage
**Error Response (404):**
```json
{
"success": false,
"error": "Session not found"
}
```
**Python Implementation (FastAPI):**
```python
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):**
```json
{
"task_id": "0b2a66c0-d9f2-43d2-9fa7-b9c95c29324d",
"status": "SUCCESS",
"song_paths": ["pubapi/generations2/audio_0b2a66c0...._0.mp3"]
}
```
**Process:**
1. Extract `task_id` from payload
2. Validate `task_id` exists in database (in `LLM_task_id` column)
3. If status is "SUCCESS":
- Update `received_from_LLM` timestamp
- Trigger Celery task chain: `fetch_generation_details` → `download_audio` → `create_video`
4. If status is "FAILURE":
- Update `LLM_status` = "fail"
- Update `entry_status` = "fail"
- Update `received_from_LLM` timestamp
**Python Implementation (FastAPI):**
```python
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):**
```json
{
"success": true,
"message": "Webhook processed"
}
```
**Security Considerations:**
- Validate that `task_id` exists 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:**
1. Query database for record with matching `session_id`
2. If `entry_status` != "success", return appropriate error
3. Return video path (direct file serving), lyrics, and metadata
**Success Response (200):**
```json
{
"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):**
```json
{
"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):**
```python
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):**
```json
{
"status": "healthy",
"timestamp": "2026-02-14T10:30:00Z",
"redis": "connected",
"database": "connected",
"celery_workers": 4
}
```
**Response (503 - Unhealthy):**
```json
{
"status": "unhealthy",
"timestamp": "2026-02-14T10:30:00Z",
"redis": "disconnected",
"database": "connected",
"celery_workers": 0
}
```
**Python Implementation (FastAPI):**
```python
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):**
```json
{
"pending_submissions": 5,
"processing_submissions": 3,
"active_celery_tasks": 2,
"sonauto_credits": 8500,
"last_credits_check": "2026-02-14T10:25:00Z"
}
```
**Python Implementation (FastAPI):**
```python
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):**
```python
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):**
```python
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:**
```bash
# 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=2` to 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:
```bash
# 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:**
```python
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:**
```python
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:**
```python
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:**
```python
{
"Content-Type": "application/json",
"Authorization": f"Bearer {SONAUTO_API_KEY}" # From environment variable
}
```
**Request Payload:**
```json
{
"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:**
```json
{
"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:**
```python
MIN_AVAILABLE_CREDITS = 5000 # Warning threshold
```
**Task Implementation:**
```python
@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:**
```python
{
"Authorization": f"Bearer {SONAUTO_API_KEY}"
}
```
**Expected Response:**
```json
{
"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:**
```python
WEBHOOK_TIMEOUT_MINUTES = 10 # Max time to wait for Sonauto webhook
```
**Task Implementation:**
```python
@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:**
```python
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:**
```python
@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_error` can be used for cleanup on failure
---
### Detailed Process Flow (Step-by-Step)
#### Step 0: Form Submission
**Trigger:** User submits form on frontend
**Actions:**
1. **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_id` exists, include in request payload
- If not exists, send `cookie_id` as null/empty
2. Frontend validates form data client-side
3. Frontend crops image to 1:1 ratio, ensures < 5MB
4. Frontend sends POST to `/api/submissions` with form data + `cookie_id`
5. **Backend receives request and validates:**
- Validates all form fields
6. **Backend performs cookie-based rate limiting:**
- **If `cookie_id` is 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
- **If `cookie_id` is null/empty (first-time user):**
- Generate new `cookie_id` using Cuid2
- Proceed to step 7
7. Generate unique `session_id` using Cuid2
8. Save cropped image to `{IMG_STORAGE}/{session_id}.jpg`
9. Insert record into database:
```sql
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()
);
```
10. Backend returns response to frontend:
- Returns `session_id` (always)
- Returns `cookie_id` (only if newly generated)
11. **Frontend processes response (via SessionManager):**
- If `cookie_id` was returned (first-time user):
- Store in localStorage: `submission_data.cookie_id`
- Add entry to localStorage: `submission_data.entries.push({session_id, timestamp})`
- Redirect to waiting page
**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_id` only
- 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:**
1. Worker queries pending submissions:
```sql
SELECT * FROM submissions
WHERE entry_status = 'pending'
AND sent_to_LLM IS NULL
ORDER BY created_at ASC;
```
2. Worker checks available slots (max 10 concurrent requests)
3. Worker checks available credits from Redis cache
4. IF credits > 0 AND slots available:
- Construct payload with form data:
```json
{
"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:
```sql
UPDATE submissions SET
LLM_task_id = '0b2a66c0-d9f2...',
sent_to_LLM = NOW(),
entry_status = 'processing'
WHERE session_id = 'clxyz123...';
```
**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_status` set to "fail" and `retry_count` incremented
- 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):**
```json
{
"task_id": "0b2a66c0-d9f2-43d2-9fa7-b9c95c29324d",
"status": "SUCCESS",
"song_paths": ["pubapi/generations2/audio_0b2a66c0...._0.mp3"]
}
```
**Sonauto Sends (Failure):**
```json
{
"task_id": "0b2a66c0-d9f2-43d2-9fa7-b9c95c29324d",
"status": "FAILURE",
"error_message": "Generation failed due to content policy"
}
```
**Actions:**
1. Webhook endpoint `/api/webhook` receives POST request
2. Extract `task_id` from payload
3. Query database:
```sql
SELECT * FROM submissions WHERE LLM_task_id = '0b2a66c0-d9f2...';
```
4. Validate that record exists (security check)
5. IF status = "SUCCESS":
- Update database:
```sql
UPDATE submissions SET
received_from_LLM = NOW()
WHERE LLM_task_id = '0b2a66c0-d9f2...';
```
- Trigger Celery task chain: `fetch_generation_details` → `download_audio` → `create_video`
6. IF status = "FAILURE":
- Update database:
```sql
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)
**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_timeouts` runs 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:**
1. Use `task_id` to fetch complete details from Sonauto
2. Send GET request to `{EXTERNAL_API_URL}/generations/{task_id}`
3. Receive full response object (see example below)
4. Parse response and extract:
- `song_paths[0]`: Full URL to MP3 file
- `lyrics`: Complete song lyrics
- `status`: Confirm it's "SUCCESS"
5. Store entire response in database:
```sql
UPDATE submissions SET
LLM_full_response = '<entire_json_response>',
LLM_response = '<song_url>',
LLM_status = 'success'
WHERE LLM_task_id = '0b2a66c0-d9f2...';
```
6. 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:**
```python
{
"Authorization": f"Bearer {SONAUTO_API_KEY}"
}
```
**Example Success Response:**
```json
{
"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:**
1. Extract song URL from `song_paths[0]`
2. Download MP3 file from URL
3. Save to `{AUDIO_STORAGE}/{session_id}.mp3`
4. Update database with file path:
```sql
UPDATE submissions SET
generated_song_path = '/storage/audio/clxyz123.mp3'
WHERE session_id = 'clxyz123...';
```
5. Celery chain automatically proceeds to Step 5 (video creation)
**Implementation Example (Python):**
```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_status` as "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:
1. **Long-running operation:** Video generation takes 30-90 seconds depending on audio length, which would block web requests and cause timeouts
2. **CPU-intensive:** Image compositing and FFmpeg encoding are computationally expensive and would degrade API response times
3. **Memory usage:** Loading images and streaming frames to FFmpeg requires significant memory that should be isolated from the web process
4. **Retry capability:** Celery provides automatic retry with exponential backoff if FFmpeg fails
5. **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:**
1. Update database with start timestamp:
```sql
UPDATE submissions SET
video_creation_start = NOW()
WHERE session_id = 'clxyz123...';
```
2. 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/`
3. Call video generator script's `main()` function (see Video Creation Process section)
4. Output: `{VIDEO_STORAGE}/{session_id}.mp4`
5. Update database with completion:
```sql
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.
1. **Audio Analysis:**
- Read MP3 duration using `mutagen` library
- Validate audio is long enough for at least one full rotation
2. **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
3. **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 frames` per 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)
4. **Video Compilation (Streaming to FFmpeg):**
- FFmpeg receives frames via stdin pipe
- Uses `-filter_complex` with `loop` filter to repeat the rotation cycle
- Combines looped video with audio track
- Outputs H.264 encoded MP4 with AAC audio
5. **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`):**
```python
# 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 processing
- `mutagen` - MP3 duration reading
- `ffmpeg` - Must be installed on system PATH
**Running the Script:**
```bash
# Using uv (recommended)
uv run create-video.py
# Or with pip-installed dependencies
python create-video.py
```
**Integration with Backend:**
```python
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_status` as "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_queue` task 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_timeouts` task 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 failed
- `ERROR`: Submission failed after all retries, video generation failed
- `WARNING`: Credits below threshold, webhook timeout, retry attempts
- `INFO`: Normal operations (submissions processed, tasks completed)
**Recommended Monitoring:**
- Set up log aggregation (e.g., CloudWatch, Datadog, ELK stack)
- Alert on `CRITICAL` and `ERROR` log levels
- Dashboard for:
- Queue depth (pending submissions)
- Processing time (submission to video completion)
- Success/failure rates
- Sonauto credits remaining
**Health Check Integration:**
- Use `/api/health` endpoint 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` (see `FORM_SUBMIT_RETRY` constant)
- **IP-based rate limiting (secondary):**
- `/api/submissions` endpoint: 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_id` exists 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_id` and `LLM_task_id` for 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 `SessionManager` module in `assets/js/home.js`
- Stores session data in localStorage under key `submission_data`:
```javascript
{
"cookie_id": "cookie_abc123xyz",
"entries": [
{
"session_id": "clxyz123abc456def789",
"timestamp": "2026-02-14T10:30:00Z"
}
]
}
```
- `cookie_id` persists across submissions for rate limiting
- `entries` array tracks all user submissions
- Allows users to revisit their creations
- Persists across browser sessions
**Session Tracking (Rate Limiting):**
- **Storage Key:** `submission_data` in localStorage
- **Purpose:** Core rate limiting mechanism to prevent abuse
- **Data Structure:**
```javascript
{
"cookie_id": "cookie_abc123xyz", // Persisted identifier for rate limiting
"entries": [
{ "session_id": "clxyz123...", "timestamp": "2026-02-14T10:30:00Z" }
]
}
```
- **Generation:**
- `cookie_id` generated 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_id` is limited to `FORM_SUBMIT_RETRY` (10) submissions
- Backend counts existing submissions per cookie before accepting new ones
- If limit reached, user sees "Sold Out" modal
- **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 `petSongForm` with reactive form state
- ✅ `SessionManager` module for localStorage handling
- ✅ Read `cookie_id` from localStorage on page load
- ✅ Form submission via fetch API to `POST /api/submissions`
- ✅ Build JSON payload from form fields + cropped image base64
- ✅ Include `cookie_id` in 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.html` to `waiting.php?session_id={id}`
**2. waiting.php - NEEDS IMPLEMENTATION**
Current state: Static page showing loading GIF animation
**Required JavaScript additions:**
- Extract `session_id` from URL query parameter
- Implement polling loop:
- Call `GET /api/submissions/{session_id}/status` every 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
**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_id` from 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):**
```html
<video id="video-player" controls playsinline>
<source src="" type="video/mp4">
Your browser does not support the video tag.
</video>
```
**Copy URL Implementation:**
```javascript
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):**
```apache
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:**
```php
// 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:
```apache
# /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:
```bash
sudo a2enmod proxy proxy_http rewrite
sudo systemctl restart apache2
```
#### Docker Services
```yaml
# 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
```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:**
```bash
# Inside the backend directory
alembic init alembic
# Edit alembic.ini to set sqlalchemy.url or use env var
```
**alembic/env.py configuration:**
```python
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:**
```bash
# Create migration
alembic revision --autogenerate -m "Create submissions table"
# Apply migration
alembic upgrade head
```
**SQLAlchemy Model (app/models.py):**
```python
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:
```bash
# 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:
```python
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:
```python
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
```bash
# 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)
```bash
# 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
```bash
# 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`)