- 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>
2556 lines
80 KiB
Markdown
2556 lines
80 KiB
Markdown
# 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`)
|