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

80 KiB
Raw Blame History

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:

{
  "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:

    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):

{
  "success": true,
  "session_id": "clxyz123abc456def789",
  "cookie_id": "cookie_abc123xyz",  // Only returned if new cookie was generated
  "message": "Submission received successfully"
}

Note: If user already had a cookie, the response does NOT include cookie_id field (frontend keeps using existing cookie).

Error Response - Rate Limit Exceeded (429):

{
  "success": false,
  "error": "rate_limit_exceeded",
  "message": "You have reached the maximum number of submissions. Please check back later.",
  "attempts_used": 10,
  "max_attempts": 10
}

Error Response - Validation Failed (400):

{
  "success": false,
  "error": "Invalid image format",
  "message": "Please upload JPG or PNG image under 5MB"
}

Error Response - Server Error (500):

{
  "success": false,
  "error": "server_error",
  "message": "Something went wrong. Please try again."
}

Python Implementation (FastAPI):

from fastapi import APIRouter, HTTPException, Response
from pydantic import BaseModel, Field, field_validator
import base64
import re
from cuid2 import cuid_wrapper
from pathlib import Path

router = APIRouter()
cuid_generator = cuid_wrapper()

# Pydantic Schemas
class SubmissionRequest(BaseModel):
    pet_name: str = Field(..., min_length=2, max_length=100)
    pet_type: str
    photo: str  # base64 encoded JPEG
    music_vibe: str
    owner_name: str = Field(..., min_length=2, max_length=100)
    cookie_id: str | None = None

    @field_validator('pet_name', 'owner_name')
    @classmethod
    def validate_name(cls, v):
        if not re.match(r'^[A-Za-z ]+$', v):
            raise ValueError('Only letters and spaces allowed')
        return v

    @field_validator('pet_type')
    @classmethod
    def validate_pet_type(cls, v):
        allowed = ['Dog', 'Cat', 'Bird', 'Fish', 'Other']
        if v not in allowed:
            raise ValueError(f'Must be one of: {", ".join(allowed)}')
        return v

    @field_validator('music_vibe')
    @classmethod
    def validate_music_vibe(cls, v):
        allowed = ['Chill', 'Energetic', 'Romantic', 'Party', 'Relaxing', 'Other']
        if v not in allowed:
            raise ValueError(f'Must be one of: {", ".join(allowed)}')
        return v


class SubmissionResponse(BaseModel):
    success: bool
    session_id: str
    cookie_id: str | None = None
    message: str


@router.post("/api/submissions", response_model=SubmissionResponse)
async def create_submission(payload: SubmissionRequest, db: Session = Depends(get_db)):
    # Check rate limit if cookie_id provided
    if payload.cookie_id:
        count = db.query(Submission).filter_by(cookie_id=payload.cookie_id).count()
        if count >= FORM_SUBMIT_RETRY:
            raise HTTPException(
                status_code=429,
                detail={
                    "success": False,
                    "error": "rate_limit_exceeded",
                    "message": "You have reached the maximum number of submissions.",
                    "attempts_used": count,
                    "max_attempts": FORM_SUBMIT_RETRY
                }
            )
        cookie_id = payload.cookie_id
        is_new_cookie = False
    else:
        # Generate new cookie for first-time user
        cookie_id = f"cookie_{cuid_generator()}"
        is_new_cookie = True

    # Generate session_id
    session_id = cuid_generator()

    # Decode and save base64 image
    try:
        # Remove data URL prefix if present
        photo_data = payload.photo
        if photo_data.startswith('data:'):
            photo_data = photo_data.split(',', 1)[1]

        image_bytes = base64.b64decode(photo_data)
        photo_path = Path(IMG_STORAGE) / f"{session_id}.jpg"
        photo_path.write_bytes(image_bytes)
    except Exception as e:
        raise HTTPException(status_code=400, detail=f"Invalid image data: {str(e)}")

    # Create submission record
    submission = Submission(
        session_id=session_id,
        cookie_id=cookie_id,
        pet_name=payload.pet_name,
        pet_type=payload.pet_type,
        photo_path=str(photo_path),
        music_vibe=payload.music_vibe,
        owner_name=payload.owner_name,
        entry_status='pending'
    )
    db.add(submission)
    db.commit()

    return SubmissionResponse(
        success=True,
        session_id=session_id,
        cookie_id=cookie_id if is_new_cookie else None,
        message="Submission received successfully"
    )

2. GET /api/submissions/{session_id}/status

Purpose: Poll endpoint for frontend to check processing status

URL Parameters:

  • session_id: The unique session identifier

Process:

  1. Query database for record with matching session_id
  2. Return entry_status field value

Success Response (200):

{
  "session_id": "clxyz123abc456def789",
  "status": "pending",  // or "processing", "success", "fail"
  "created_at": "2026-02-14T10:30:00Z"
}

Status Values:

  • pending: Waiting in queue
  • processing: Being processed by Sonauto API
  • success: Video generation complete, ready to view
  • fail: Generation failed at any stage

Error Response (404):

{
  "success": false,
  "error": "Session not found"
}

Python Implementation (FastAPI):

class StatusResponse(BaseModel):
    session_id: str
    status: str
    created_at: str


@router.get("/api/submissions/{session_id}/status", response_model=StatusResponse)
async def get_submission_status(session_id: str, db: Session = Depends(get_db)):
    submission = db.get(Submission, session_id)
    if not submission:
        raise HTTPException(status_code=404, detail="Session not found")

    return StatusResponse(
        session_id=submission.session_id,
        status=submission.entry_status,
        created_at=submission.created_at.isoformat() + "Z"
    )

3. POST /api/webhook

Purpose: Receive callback from Sonauto API when song generation completes

Request Payload (from Sonauto):

{
  "task_id": "0b2a66c0-d9f2-43d2-9fa7-b9c95c29324d",
  "status": "SUCCESS",
  "song_paths": ["pubapi/generations2/audio_0b2a66c0...._0.mp3"]
}

Process:

  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_detailsdownload_audiocreate_video
  4. If status is "FAILURE":
    • Update LLM_status = "fail"
    • Update entry_status = "fail"
    • Update received_from_LLM timestamp

Python Implementation (FastAPI):

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel

router = APIRouter()

class WebhookPayload(BaseModel):
    task_id: str
    status: str
    song_paths: list[str] | None = None
    error_message: str | None = None

@router.post("/api/webhook")
async def handle_webhook(payload: WebhookPayload, db: Session = Depends(get_db)):
    submission = db.query(Submission).filter_by(LLM_task_id=payload.task_id).first()
    if not submission:
        logger.warning(f"Webhook received for unknown task_id: {payload.task_id}")
        raise HTTPException(status_code=404, detail="Unknown task_id")

    submission.received_from_LLM = datetime.utcnow()

    if payload.status == "SUCCESS":
        db.commit()
        # Trigger chained processing tasks
        trigger_post_webhook_processing(submission.session_id)
        logger.info(f"Webhook SUCCESS for {submission.session_id}, triggered processing chain")

    elif payload.status == "FAILURE":
        submission.LLM_status = 'fail'
        submission.entry_status = 'fail'
        db.commit()
        logger.warning(f"Webhook FAILURE for {submission.session_id}: {payload.error_message}")

    return {"success": True, "message": "Webhook processed"}

Success Response (200):

{
  "success": true,
  "message": "Webhook processed"
}

Security Considerations:

  • Validate that task_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):

{
  "success": true,
  "session_id": "clxyz123abc456def789",
  "status": "success",
  "video_url": "/storage/video/clxyz123abc456def789.mp4",
  "lyrics": "[Chorus]\nMatt and Toby, best friends...",
  "owner_name": "Matt",
  "pet_name": "Toby",
  "created_at": "2026-02-14T10:30:00Z"
}

Error Response (404/400):

{
  "success": false,
  "status": "fail",
  "message": "Video generation failed. Please try again."
}

Security:

  • No authentication required (public share links)
  • Session IDs are cryptographically random (Cuid2)
  • Consider rate limiting (e.g., 100 requests/hour per IP)

Python Implementation (FastAPI):

class ResultResponse(BaseModel):
    success: bool
    session_id: str
    status: str
    video_url: str | None = None
    lyrics: str | None = None
    owner_name: str
    pet_name: str
    created_at: str
    message: str | None = None


@router.get("/api/results/{session_id}")
async def get_results(session_id: str, db: Session = Depends(get_db)):
    submission = db.get(Submission, session_id)
    if not submission:
        raise HTTPException(status_code=404, detail="Session not found")

    if submission.entry_status == 'success':
        return ResultResponse(
            success=True,
            session_id=submission.session_id,
            status=submission.entry_status,
            video_url=f"/storage/video/{submission.session_id}.mp4",
            lyrics=submission.lyrics,
            owner_name=submission.owner_name,
            pet_name=submission.pet_name,
            created_at=submission.created_at.isoformat() + "Z"
        )
    else:
        return ResultResponse(
            success=False,
            session_id=submission.session_id,
            status=submission.entry_status,
            owner_name=submission.owner_name,
            pet_name=submission.pet_name,
            created_at=submission.created_at.isoformat() + "Z",
            message="Video generation failed. Please try again."
        )

5. GET /api/health

Purpose: Health check endpoint for load balancers and monitoring

Response (200):

{
  "status": "healthy",
  "timestamp": "2026-02-14T10:30:00Z",
  "redis": "connected",
  "database": "connected",
  "celery_workers": 4
}

Response (503 - Unhealthy):

{
  "status": "unhealthy",
  "timestamp": "2026-02-14T10:30:00Z",
  "redis": "disconnected",
  "database": "connected",
  "celery_workers": 0
}

Python Implementation (FastAPI):

from fastapi import APIRouter, Response
from fastapi.responses import JSONResponse

router = APIRouter()

@router.get("/api/health")
async def health_check(db: Session = Depends(get_db)):
    checks = {
        "timestamp": datetime.utcnow().isoformat() + "Z",
        "redis": "connected",
        "database": "connected",
        "celery_workers": 0
    }

    # Check Redis
    try:
        redis_client.ping()
    except Exception:
        checks["redis"] = "disconnected"

    # Check Database
    try:
        db.execute(text("SELECT 1"))
    except Exception:
        checks["database"] = "disconnected"

    # Check Celery workers
    try:
        inspect = celery_app.control.inspect()
        active = inspect.active()
        checks["celery_workers"] = len(active) if active else 0
    except Exception:
        checks["celery_workers"] = 0

    is_healthy = (
        checks["redis"] == "connected" and
        checks["database"] == "connected" and
        checks["celery_workers"] > 0
    )

    checks["status"] = "healthy" if is_healthy else "unhealthy"
    status_code = 200 if is_healthy else 503

    return JSONResponse(content=checks, status_code=status_code)

6. GET /api/admin/queue-status

Purpose: Admin endpoint to view queue status (optional, for debugging)

Note: Should be protected by basic auth or IP whitelist in production.

Response (200):

{
  "pending_submissions": 5,
  "processing_submissions": 3,
  "active_celery_tasks": 2,
  "sonauto_credits": 8500,
  "last_credits_check": "2026-02-14T10:25:00Z"
}

Python Implementation (FastAPI):

from fastapi import APIRouter, Depends

router = APIRouter()

@router.get("/api/admin/queue-status")
async def queue_status(db: Session = Depends(get_db)):
    # Add basic auth dependency here for production

    pending = db.query(Submission).filter_by(entry_status='pending').count()
    processing = db.query(Submission).filter_by(entry_status='processing').count()

    credits = redis_client.get("sonauto_credits")

    return {
        "pending_submissions": pending,
        "processing_submissions": processing,
        "sonauto_credits": int(credits) if credits else 0,
    }

Data Retention & Cleanup

Purpose: Prevent unbounded storage growth by cleaning up old files and records.

Celery Beat Task (runs daily at 3 AM):

app.conf.beat_schedule['cleanup-old-files'] = {
    'task': 'tasks.cleanup_old_files',
    'schedule': crontab(hour=3, minute=0),
}

@shared_task(bind=True)
def cleanup_old_files(self):
    """Delete files and optionally records older than FILE_RETENTION_DAYS."""
    from app.database import SessionLocal

    db = SessionLocal()
    try:
        cutoff_date = datetime.utcnow() - timedelta(days=FILE_RETENTION_DAYS)

        old_submissions = db.query(Submission).filter(
            Submission.created_at < cutoff_date
        ).all()

        deleted_count = 0
        for submission in old_submissions:
            # Delete associated files
            for path_attr in ['photo_path', 'generated_song_path', 'generated_video_path']:
                file_path = getattr(submission, path_attr)
                if file_path and Path(file_path).exists():
                    Path(file_path).unlink()
                    deleted_count += 1

            # Optionally delete the database record
            # db.delete(submission)

        db.commit()
        logger.info(f"Cleanup: deleted {deleted_count} files from {len(old_submissions)} old submissions")
        return {"files_deleted": deleted_count, "submissions_processed": len(old_submissions)}
    finally:
        db.close()

Storage Monitoring:

  • Log warning if storage directory exceeds 80% capacity
  • Consider adding disk space check to health endpoint

Background Workers (Celery with Redis)

The system uses Celery with Redis as the message broker for asynchronous task processing.

Why Celery Workers Are Required:

The following operations MUST run in Celery workers (not in the web process):

Task Reason
send_to_sonauto External API call with potential latency/retries
fetch_generation_details External API call
download_audio Large file download (MP3, ~3-5MB)
create_video CPU-intensive, 30-90 second operation - generates video frames, runs FFmpeg encoding

Video generation in particular would cause request timeouts and resource exhaustion if run synchronously in the web process.

Dependencies:

celery[redis]>=5.3.0
redis>=4.5.0
requests>=2.28.0

Celery Configuration (celery_app.py):

from celery import Celery

app = Celery('pah_workers')
app.config_from_object({
    'broker_url': 'redis://localhost:6379/0',
    'result_backend': 'redis://localhost:6379/0',
    'task_serializer': 'json',
    'result_serializer': 'json',
    'accept_content': ['json'],
    'timezone': 'UTC',
    'task_track_started': True,
    'task_acks_late': True,
    'worker_prefetch_multiplier': 1,  # Prevent grabbing too many tasks
})

Running Celery Workers:

# Start worker for all queues
celery -A celery_app worker --loglevel=info --concurrency=4

# Start beat scheduler for periodic tasks
celery -A celery_app beat --loglevel=info

Worker Concurrency Considerations:

Video generation is CPU and memory intensive. Recommended concurrency settings:

  • Small server (2 CPU, 4GB RAM): --concurrency=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:

# Dedicated worker for video generation (lower concurrency)
celery -A celery_app worker --loglevel=info --concurrency=2 -Q video

# Worker for lighter tasks (API calls, downloads)
celery -A celery_app worker --loglevel=info --concurrency=8 -Q default

Task 1: Process Pending Submission (send_to_sonauto)

Triggered by: Celery Beat scheduler (every 1 minute)

Purpose: Process pending submissions by sending them to Sonauto API while respecting rate limits

Configuration Constants:

MAX_CONCURRENT_REQUESTS = 10  # Sonauto's parallel request limit
MIN_AVAILABLE_CREDITS = 5000  # Threshold for warnings
MAX_RETRIES = 3               # Maximum retry attempts per submission

Celery Beat Schedule:

app.conf.beat_schedule = {
    'process-pending-submissions': {
        'task': 'tasks.process_pending_queue',
        'schedule': 60.0,  # Every 60 seconds
    },
    'check-sonauto-credits': {
        'task': 'tasks.check_credits',
        'schedule': 600.0,  # Every 10 minutes
    },
    'check-stale-submissions': {
        'task': 'tasks.check_timeouts',
        'schedule': 300.0,  # Every 5 minutes
    },
}

Task Implementation:

from celery import shared_task
from celery.utils.log import get_task_logger

logger = get_task_logger(__name__)

@shared_task(bind=True)
def process_pending_queue(self):
    """Process pending submissions and send to Sonauto API."""
    from app.database import SessionLocal

    # Check credits
    credits = get_cached_credits()
    if credits == 0:
        logger.warning("Credits exhausted - skipping queue processing")
        return {"processed": 0, "reason": "no_credits"}

    db = SessionLocal()
    try:
        # Count active requests
        active_count = db.query(Submission).filter(
            Submission.sent_to_LLM.isnot(None),
            Submission.received_from_LLM.is_(None)
        ).count()

        available_slots = MAX_CONCURRENT_REQUESTS - active_count
        if available_slots <= 0:
            logger.info(f"No available slots ({active_count}/{MAX_CONCURRENT_REQUESTS} active)")
            return {"processed": 0, "reason": "no_slots"}

        # Get pending submissions
        pending = db.query(Submission).filter(
            Submission.entry_status == 'pending',
            Submission.sent_to_LLM.is_(None),
            Submission.retry_count < MAX_RETRIES
        ).order_by(Submission.created_at.asc()).limit(available_slots).all()

        processed = 0
        for submission in pending:
            # Dispatch individual task for each submission
            send_to_sonauto.delay(submission.session_id)
            processed += 1

        return {"processed": processed}
    finally:
        db.close()


@shared_task(
    bind=True,
    autoretry_for=(requests.RequestException,),
    retry_backoff=True,
    retry_backoff_max=60,
    retry_kwargs={'max_retries': 3}
)
def send_to_sonauto(self, session_id: str):
    """Send a single submission to Sonauto API."""
    from app.database import SessionLocal

    db = SessionLocal()
    try:
        submission = db.get(Submission, session_id)
        if not submission:
            logger.error(f"Submission {session_id} not found")
            return

        payload = {
            "tags": [submission.music_vibe, "love song", "valentine"],
            "prompt": f"Create a heartfelt Valentine's Day love song celebrating the special bond between {submission.owner_name} and their beloved {submission.pet_type} {submission.pet_name}. Make it warm, genuine, and mention both names in the lyrics.",
            "instrumental": False,
            "output_format": "mp3",
            "webhook_url": f"{WEBHOOK_BASE_URL}/api/webhook"
        }

        try:
            response = requests.post(
                f"{EXTERNAL_API_URL}/generations/v3",
                json=payload,
                headers={"Authorization": f"Bearer {SONAUTO_API_KEY}"},
                timeout=API_REQUEST_TIMEOUT_SECONDS
            )
            response.raise_for_status()
            data = response.json()

            submission.LLM_task_id = data["task_id"]
            submission.sent_to_LLM = datetime.utcnow()
            submission.entry_status = 'processing'
            db.commit()

            logger.info(f"Sent {session_id} to Sonauto, task_id: {data['task_id']}")

        except requests.RequestException as e:
            submission.retry_count += 1
            if submission.retry_count >= MAX_RETRIES:
                submission.entry_status = 'fail'
                logger.error(f"Submission {session_id} failed after {MAX_RETRIES} retries")
            db.commit()
            raise  # Trigger Celery retry with exponential backoff
    finally:
        db.close()

Sonauto API Call Details:

Endpoint: POST {EXTERNAL_API_URL}/generations/v3

Documentation: https://sonauto.ai/developers#generations-v3-post

Request Headers:

{
    "Content-Type": "application/json",
    "Authorization": f"Bearer {SONAUTO_API_KEY}"  # From environment variable
}

Request Payload:

{
  "tags": ["<MUSIC_VIBE>", "love song", "valentine"],
  "prompt": "Create a heartfelt Valentine's Day love song celebrating the special bond between <OWNER_NAME> and their beloved <PET_TYPE> <PET_NAME>. Make it warm, genuine, and mention both names in the lyrics.",
  "instrumental": false,
  "output_format": "mp3",
  "webhook_url": "https://<YOUR_DOMAIN>/api/webhook"
}

Prompt Template:

Create a heartfelt Valentine's Day love song celebrating the special bond
between {owner_name} and their beloved {pet_type} {pet_name}.
Make it warm, genuine, and mention both names in the lyrics.

Expected Response:

{
  "task_id": "2f2fcb2f-f024-4cfa-a282-f5bd46d1d044"
}

Error Handling:

  • If API returns error, log it and retry on next worker run
  • If credits insufficient, log warning but continue (Credits Monitor will update)
  • If network timeout, mark for retry
  • Maximum 3 retry attempts per submission before marking as "fail"

Task 2: Credits Monitor (check_credits)

Triggered by: Celery Beat scheduler (every 10 minutes)

Purpose: Check available Sonauto credits and cache in Redis

Configuration Constants:

MIN_AVAILABLE_CREDITS = 5000  # Warning threshold

Task Implementation:

@shared_task(bind=True)
def check_credits(self):
    """Check Sonauto credits and cache in Redis."""
    try:
        response = requests.get(
            f"{EXTERNAL_API_URL}/credits/balance",
            headers={"Authorization": f"Bearer {SONAUTO_API_KEY}"},
            timeout=30
        )
        response.raise_for_status()
        data = response.json()

        num_credits = data.get("num_credits", 0)

        # Cache in Redis (expires in 15 minutes as safety)
        redis_client.setex("sonauto_credits", 900, num_credits)

        if num_credits == 0:
            logger.critical("Credits exhausted - submissions halted")
        elif num_credits < MIN_AVAILABLE_CREDITS:
            logger.warning(f"Credits below threshold: {num_credits}")
        else:
            logger.info(f"Credits available: {num_credits}")

        return {"credits": num_credits}

    except requests.RequestException as e:
        logger.error(f"Failed to check credits: {e}")
        return {"error": str(e)}


def get_cached_credits() -> int:
    """Get cached credits from Redis, default to 0 if not found."""
    credits = redis_client.get("sonauto_credits")
    return int(credits) if credits else 0

Sonauto Credits API Details:

Endpoint: GET {EXTERNAL_API_URL}/credits/balance

Documentation: https://sonauto.ai/developers#credits-balance-get

Request Headers:

{
    "Authorization": f"Bearer {SONAUTO_API_KEY}"
}

Expected Response:

{
  "num_credits": 12500,
  "num_credits_payg": 0
}

Task 3: Timeout Checker (check_timeouts)

Triggered by: Celery Beat scheduler (every 5 minutes)

Purpose: Mark stale submissions as failed if webhook never arrives

Configuration Constants:

WEBHOOK_TIMEOUT_MINUTES = 10  # Max time to wait for Sonauto webhook

Task Implementation:

@shared_task(bind=True)
def check_timeouts(self):
    """Mark submissions as failed if webhook times out."""
    from app.database import SessionLocal

    db = SessionLocal()
    try:
        timeout_threshold = datetime.utcnow() - timedelta(minutes=WEBHOOK_TIMEOUT_MINUTES)

        stale_submissions = db.query(Submission).filter(
            Submission.sent_to_LLM.isnot(None),
            Submission.received_from_LLM.is_(None),
            Submission.sent_to_LLM < timeout_threshold
        ).all()

        count = 0
        for submission in stale_submissions:
            submission.entry_status = 'fail'
            submission.LLM_status = 'timeout'
            count += 1
            logger.warning(f"Submission {submission.session_id} timed out")

        db.commit()
        return {"timed_out": count}
    finally:
        db.close()

Task 4: Post-Webhook Processing Chain

Triggered by: Webhook endpoint when Sonauto returns SUCCESS

Purpose: Fetch generation details, download audio, create video (chained tasks)

Task Chain:

from celery import chain

def trigger_post_webhook_processing(session_id: str):
    """Trigger the chained tasks after successful webhook."""
    workflow = chain(
        fetch_generation_details.s(session_id),
        download_audio.s(),
        create_video.s()
    )
    workflow.apply_async()

Task Implementations:

@shared_task(
    bind=True,
    autoretry_for=(requests.RequestException,),
    retry_backoff=True,
    retry_backoff_max=60,
    retry_kwargs={'max_retries': 3}
)
def fetch_generation_details(self, session_id: str) -> dict:
    """Fetch full generation details from Sonauto API."""
    from app.database import SessionLocal

    db = SessionLocal()
    try:
        submission = db.get(Submission, session_id)

        response = requests.get(
            f"{EXTERNAL_API_URL}/generations/{submission.LLM_task_id}",
            headers={"Authorization": f"Bearer {SONAUTO_API_KEY}"},
            timeout=30
        )
        response.raise_for_status()
        data = response.json()

        # Store full response and extract lyrics
        submission.LLM_full_response = json.dumps(data)
        submission.LLM_response = data["song_paths"][0]
        submission.lyrics = data.get("lyrics", "")
        submission.LLM_status = 'success'
        db.commit()

        logger.info(f"Fetched generation details for {session_id}")

        # Return data needed by next task in chain
        return {
            "session_id": session_id,
            "song_url": data["song_paths"][0],
            "lyrics": data.get("lyrics", "")
        }
    finally:
        db.close()


@shared_task(
    bind=True,
    autoretry_for=(requests.RequestException,),
    retry_backoff=True,
    retry_backoff_max=60,
    retry_kwargs={'max_retries': 3}
)
def download_audio(self, result: dict) -> dict:
    """Download MP3 from Sonauto CDN."""
    from app.database import SessionLocal

    session_id = result["session_id"]
    song_url = result["song_url"]

    file_path = Path(AUDIO_STORAGE) / f"{session_id}.mp3"

    response = requests.get(song_url, stream=True, timeout=120)
    response.raise_for_status()

    with open(file_path, 'wb') as f:
        for chunk in response.iter_content(chunk_size=8192):
            f.write(chunk)

    # Update database
    db = SessionLocal()
    try:
        submission = db.get(Submission, session_id)
        submission.generated_song_path = str(file_path)
        db.commit()
        logger.info(f"Downloaded audio for {session_id}")
    finally:
        db.close()

    # Return data needed by next task in chain
    return {
        "session_id": session_id,
        "audio_path": str(file_path),
        "lyrics": result.get("lyrics", "")
    }


@shared_task(
    bind=True,
    autoretry_for=(Exception,),
    retry_backoff=True,
    retry_backoff_max=60,
    retry_kwargs={'max_retries': 1}  # Only 1 retry for video generation
)
def create_video(self, result: dict) -> dict:
    """Create MP4 video combining pet photo with audio using the video generator script."""
    from app.database import SessionLocal
    from app.config import VIDEO_STORAGE
    from video_generator import create_video as video_gen

    session_id = result["session_id"]
    audio_path = result["audio_path"]

    db = SessionLocal()
    try:
        submission = db.get(Submission, session_id)

        # Mark video creation start
        submission.video_creation_start = datetime.utcnow()
        db.commit()

        try:
            # Set output path for the video
            output_path = Path(VIDEO_STORAGE) / f"{session_id}.mp4"

            # Call the video generator script's main function
            # The script handles all image compositing, rotation animation,
            # and FFmpeg encoding internally
            video_gen.main(
                pet_img_path=submission.photo_path,
                audio_track_path=audio_path,
                output_path=str(output_path)  # Pass custom output path
            )

            # Verify video was created
            if not output_path.exists():
                raise FileNotFoundError(f"Video file not created at {output_path}")

            # Mark success
            submission.generated_video_path = str(output_path)
            submission.video_creation_end = datetime.utcnow()
            submission.entry_status = 'success'
            db.commit()

            logger.info(f"Video created for {session_id}: {output_path}")
            return {"session_id": session_id, "video_path": str(output_path)}

        except Exception as e:
            submission.entry_status = 'fail'
            submission.video_creation_end = datetime.utcnow()
            db.commit()
            logger.error(f"Video creation failed for {session_id}: {e}")
            raise
    finally:
        db.close()

Error Handling in Chains:

  • Each task has exponential backoff retry (10s → 30s → 60s)
  • If any task in chain fails after retries, submission marked as 'fail'
  • Celery's link_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:

    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:
    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:
      {
        "tags": ["Romantic", "love song", "valentine"],
        "prompt": "Create a heartfelt Valentine's Day love song celebrating the special bond between Matt and their beloved Dog Toby. Make it warm, genuine, and mention both names in the lyrics.",
        "instrumental": false,
        "output_format": "mp3",
        "webhook_url": "https://mysite.com/api/webhook"
      }
      
    • Send POST to {EXTERNAL_API_URL}/generations/v3
    • Receive response: {"task_id": "0b2a66c0-d9f2..."}
    • Update database:
      UPDATE submissions SET
        LLM_task_id = '0b2a66c0-d9f2...',
        sent_to_LLM = NOW(),
        entry_status = 'processing'
      WHERE session_id = 'clxyz123...';
      

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):

{
  "task_id": "0b2a66c0-d9f2-43d2-9fa7-b9c95c29324d",
  "status": "SUCCESS",
  "song_paths": ["pubapi/generations2/audio_0b2a66c0...._0.mp3"]
}

Sonauto Sends (Failure):

{
  "task_id": "0b2a66c0-d9f2-43d2-9fa7-b9c95c29324d",
  "status": "FAILURE",
  "error_message": "Generation failed due to content policy"
}

Actions:

  1. Webhook endpoint /api/webhook receives POST request
  2. Extract task_id from payload
  3. Query database:
    SELECT * FROM submissions WHERE LLM_task_id = '0b2a66c0-d9f2...';
    
  4. Validate that record exists (security check)
  5. IF status = "SUCCESS":
    • Update database:
      UPDATE submissions SET
        received_from_LLM = NOW()
      WHERE LLM_task_id = '0b2a66c0-d9f2...';
      
    • Trigger Celery task chain: fetch_generation_detailsdownload_audiocreate_video
  6. IF status = "FAILURE":
    • Update database:
      UPDATE submissions SET
        LLM_status = 'fail',
        entry_status = 'fail',
        received_from_LLM = NOW()
      WHERE LLM_task_id = '0b2a66c0-d9f2...';
      
    • End journey (user will see failure message)

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:
    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:

{
    "Authorization": f"Bearer {SONAUTO_API_KEY}"
}

Example Success Response:

{
  "id": "4273ad54-943a-4202-beac-4011918f4f3d",
  "created_at": "2026-01-23T11:47:30.017936+00:00",
  "status": "SUCCESS",
  "alignment_status": null,
  "model_version": "v2.2",
  "song_paths": [
    "https://cdn.sonauto.ai/pubapi/generations2/audio_4273ad54-943a-4202-beac-4011918f4f3d_0.mp3"
  ],
  "error_message": null,
  "lyrics": "[Chorus]\nMatt and Toby, best friends down the line\nFetchin' forever, hearts in a canine bind\nOne wags a tail, one holds the leash just right\nIt's puppy love by moonlight tonight\n\n[Post-Chorus]\nWoo-oo, woof woof, hearts are true\nWoo-oo, biscuits for two\n\n[Verse 2]\nMatt once tried Tinder, but it just went south\nUntil Toby slobbered all over his mouth\nShared a steak dinner, you shoulda seen Matt's face\nDog got the fries and left him the plate\n\n[Pre-Chorus]\nLove is muddy pawprints and fur on his jeans\nEvery Valentine's, you know what this means…\n\n[Chorus]\nMatt and Toby, best friends down the line\nFetchin' forever, hearts in a canine bind\nOne wags a tail, one holds the leash just right\nIt's puppy love by moonlight tonight\n\n[Post-Chorus]\nWoo-oo, woof woof, hearts are true\nWoo-oo, biscuits for two",
  "prompt": "Create a funny Valentine's love ballad in a country and western style celebrating the love between Matt and a pet dog called Toby.",
  "prompt_strength": 2.0,
  "tags": ["rock", "energetic"],
  "v2_params": {
    "balance_strength": 0.7,
    "bpm": "auto",
    "seed": 3644529975
  },
  "extend_params": null,
  "inpaint_params": null
}

Database State After Step 3:

LLM_status: success
LLM_response: https://cdn.sonauto.ai/pubapi/generations2/audio_4273ad54...mp3
LLM_full_response: {entire JSON object}

Step 4: Download Audio File

Trigger: Celery task download_audio (second task in chain, receives data from Step 3)

Actions:

  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:
    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):

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:
    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:
    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):

# 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:

# Using uv (recommended)
uv run create-video.py

# Or with pip-installed dependencies
python create-video.py

Integration with Backend:

from video_generator_example.create_video import main as generate_video

# In the create_video Celery task:
generate_video(
    pet_img_path=submission.photo_path,
    audio_track_path=audio_path
)

Database State After Step 5 (Success):

entry_status: success
generated_video_path: /storage/video/clxyz123abc456def789.mp4
video_creation_start: 2026-02-14T10:36:00Z
video_creation_end: 2026-02-14T10:37:30Z

Error Handling:

  • If FFmpeg fails, retry once
  • If retry fails, mark entry_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:
    {
      "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:
    {
      "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):

<video id="video-player" controls playsinline>
  <source src="" type="video/mp4">
  Your browser does not support the video tag.
</video>

Copy URL Implementation:

document.querySelector('.copy-btn').addEventListener('click', () => {
    navigator.clipboard.writeText(window.location.href);
    // Show "Copied!" feedback
});

4. opengraph.php - COMPLETE

Implemented Open Graph and Twitter Card meta tags for social sharing:

  • Twitter Card (summary_large_image)
  • Open Graph base data (url, title, description, site_name, locale)
  • Open Graph image (1200×630px)

URL Routing

The frontend needs URL routing to support clean URLs:

  • / → index.php (form)
  • /waiting/{session_id} → waiting.php
  • /result/{session_id} → result.php

Apache .htaccess (place in document root):

RewriteEngine On

# Don't rewrite existing files or directories
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d

# Rewrite /waiting/{session_id} to waiting.php?session_id={session_id}
RewriteRule ^waiting/([a-zA-Z0-9]+)$ waiting.php?session_id=$1 [L,QSA]

# Rewrite /result/{session_id} to result.php?session_id={session_id}
RewriteRule ^result/([a-zA-Z0-9]+)$ result.php?session_id=$1 [L,QSA]

PHP to read session_id from URL:

// In waiting.php and result.php
$session_id = $_GET['session_id'] ?? null;
if (!$session_id) {
    header('Location: /');
    exit;
}

Deployment (Docker)

The backend runs in Docker containers with PostgreSQL database, Redis for Celery, and the FastAPI application. The frontend (PHP) is served separately via Apache on the host server.

Architecture Overview

┌─────────────────────────────────────────────────────────┐
│                    Apache (Host)                        │
│   - Serves PHP frontend (index.php, waiting.php, etc.) │
│   - Proxies /api/* requests to Docker backend          │
│   - Serves /storage/* files directly                   │
└─────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────┐
│                  Docker Compose Stack                   │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐    │
│  │  FastAPI    │  │   Celery    │  │   Celery    │    │
│  │   (web)     │  │   Worker    │  │    Beat     │    │
│  │  port 8000  │  │             │  │             │    │
│  └─────────────┘  └─────────────┘  └─────────────┘    │
│         │                │                │            │
│         ▼                ▼                ▼            │
│  ┌─────────────────────────────────────────────┐      │
│  │              PostgreSQL + Redis              │      │
│  └─────────────────────────────────────────────┘      │
└─────────────────────────────────────────────────────────┘

Apache Configuration

Add proxy rules to forward API requests to the Docker backend:

# /etc/apache2/sites-available/pah.conf

<VirtualHost *:80>
    ServerName your-domain.com
    DocumentRoot /var/www/pah

    # Serve PHP frontend
    <Directory /var/www/pah>
        Options -Indexes +FollowSymLinks
        AllowOverride All
        Require all granted
    </Directory>

    # Proxy API requests to Docker backend
    ProxyPreserveHost On
    ProxyPass /api/ http://localhost:8000/api/
    ProxyPassReverse /api/ http://localhost:8000/api/

    # Serve storage files directly (or proxy if preferred)
    Alias /storage /var/www/pah/storage
    <Directory /var/www/pah/storage>
        Require all granted
    </Directory>
</VirtualHost>

Enable required Apache modules:

sudo a2enmod proxy proxy_http rewrite
sudo systemctl restart apache2

Docker Services

# docker-compose.yml
version: '3.8'

services:
  web:
    build: .
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=postgresql://pah:pah_password@db:5432/pah
      - REDIS_URL=redis://redis:6379/0
      - CELERY_BROKER_URL=redis://redis:6379/0
      - SONAUTO_API_KEY=${SONAUTO_API_KEY}
      - WEBHOOK_BASE_URL=${WEBHOOK_BASE_URL}
    volumes:
      - ./storage:/app/storage
    depends_on:
      - db
      - redis

  celery_worker:
    build: .
    command: celery -A celery_app worker --loglevel=info --concurrency=4
    environment:
      - DATABASE_URL=postgresql://pah:pah_password@db:5432/pah
      - REDIS_URL=redis://redis:6379/0
      - CELERY_BROKER_URL=redis://redis:6379/0
      - SONAUTO_API_KEY=${SONAUTO_API_KEY}
      - WEBHOOK_BASE_URL=${WEBHOOK_BASE_URL}
    volumes:
      - ./storage:/app/storage
    depends_on:
      - db
      - redis

  celery_beat:
    build: .
    command: celery -A celery_app beat --loglevel=info
    environment:
      - DATABASE_URL=postgresql://pah:pah_password@db:5432/pah
      - REDIS_URL=redis://redis:6379/0
      - CELERY_BROKER_URL=redis://redis:6379/0
    depends_on:
      - db
      - redis

  db:
    image: postgres:15
    environment:
      - POSTGRES_USER=pah
      - POSTGRES_PASSWORD=pah_password
      - POSTGRES_DB=pah
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  postgres_data:

Dockerfile

FROM python:3.11-slim

# Install FFmpeg for video generation
RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

# Create storage directories
RUN mkdir -p storage/uploads storage/audio storage/video

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]

requirements.txt

fastapi>=0.100.0
uvicorn[standard]>=0.23.0
sqlalchemy>=2.0.0
alembic>=1.11.0
celery[redis]>=5.3.0
redis>=4.5.0
requests>=2.28.0
psycopg2-binary>=2.9.0
Pillow>=10.0.0
cuid2>=2.0.0
python-multipart>=0.0.6
pydantic>=2.0.0
pydantic-settings>=2.0.0
mutagen>=1.47.0          # For reading MP3 duration in video generation

Database Migrations (Alembic)

Initialize Alembic:

# Inside the backend directory
alembic init alembic

# Edit alembic.ini to set sqlalchemy.url or use env var

alembic/env.py configuration:

from app.models import Base
from app.config import settings

config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
target_metadata = Base.metadata

Create and run migrations:

# Create migration
alembic revision --autogenerate -m "Create submissions table"

# Apply migration
alembic upgrade head

SQLAlchemy Model (app/models.py):

from sqlalchemy import Column, String, Integer, Text, DateTime, Index
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime

Base = declarative_base()

class Submission(Base):
    __tablename__ = 'submissions'

    session_id = Column(String(255), primary_key=True)
    created_at = Column(DateTime, default=datetime.utcnow)
    cookie_id = Column(String(255), index=True)
    pet_name = Column(String(100), nullable=False)
    pet_type = Column(String(50), nullable=False)
    photo_path = Column(String(500), nullable=False)
    music_vibe = Column(String(50), nullable=False)
    owner_name = Column(String(100), nullable=False)
    retry_count = Column(Integer, default=0)
    sent_to_LLM = Column(DateTime)
    LLM_task_id = Column(String(255))
    received_from_LLM = Column(DateTime)
    LLM_response = Column(Text)
    LLM_full_response = Column(Text)
    generated_song_path = Column(Text)
    LLM_status = Column(String(50))
    lyrics = Column(Text)
    video_creation_start = Column(DateTime)
    video_creation_end = Column(DateTime)
    generated_video_path = Column(String(500))
    entry_status = Column(String(50), default='pending')

    __table_args__ = (
        Index('ix_submissions_llm_task_id', 'LLM_task_id'),
    )

FastAPI Application Structure:

backend/
├── app/
│   ├── __init__.py
│   ├── main.py           # FastAPI app, routes
│   ├── models.py         # SQLAlchemy models
│   ├── schemas.py        # Pydantic schemas
│   ├── config.py         # Settings
│   └── database.py       # DB session management
├── tasks/
│   ├── __init__.py
│   ├── celery_app.py     # Celery configuration
│   └── workers.py        # Celery tasks
├── video_generator/
│   ├── __init__.py
│   ├── create_video.py   # Video generation script (from video_generator_example/)
│   └── assets/           # Video assets (copied from video_generator_example/assets/)
│       ├── 1080x1080-bg.png
│       ├── 736-x-736-record.png
│       └── needle.png
├── alembic/
│   └── versions/
├── alembic.ini
├── requirements.txt
└── Dockerfile

Video Generator Setup:

Copy the video generator from the example folder to the backend:

# From project root
mkdir -p backend/video_generator
cp video_generator_example/create-video.py backend/video_generator/create_video.py
cp -r video_generator_example/assets backend/video_generator/

# Update ASSET_DIR in create_video.py to point to the new location
# ASSET_DIR = "./video_generator/assets"

The script's main() function should be modified to accept an optional output_path parameter for integration with the Celery task:

def main(pet_img_path, audio_track_path, output_path=None):
    """Main execution function.

    Args:
        pet_img_path: Path to the pet image
        audio_track_path: Path to the audio MP3 file
        output_path: Optional custom output path (uses default if not provided)
    """
    global OUTPUT_VIDEO
    if output_path:
        OUTPUT_VIDEO = output_path
    # ... rest of the function

CORS Configuration (app/main.py):

Since the PHP frontend makes JavaScript fetch calls to the API (via Apache proxy), CORS middleware should be configured:

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI(title="PAH Backend API")

# CORS configuration
# When using Apache proxy, requests appear same-origin, but configure anyway for flexibility
app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "http://localhost",
        "http://localhost:8000",
        "https://your-domain.com",  # Production domain
    ],
    allow_credentials=True,
    allow_methods=["GET", "POST"],
    allow_headers=["*"],
)

Note: When Apache proxies /api/* to the backend, the browser sees same-origin requests, so CORS may not be strictly necessary. However, including it provides flexibility for direct API access during development.

Running the Stack

# Start all services
docker-compose up -d

# Run database migrations
docker-compose exec web alembic upgrade head

# View logs
docker-compose logs -f

# View specific service logs
docker-compose logs -f web
docker-compose logs -f celery_worker

# Stop all services
docker-compose down

# Stop and remove volumes (WARNING: deletes database)
docker-compose down -v

Environment Variables (.env file)

# Required
SONAUTO_API_KEY=your_sonauto_api_key_here
WEBHOOK_BASE_URL=https://your-domain.com

# Optional (have defaults in docker-compose)
DATABASE_URL=postgresql://pah:pah_password@db:5432/pah
REDIS_URL=redis://redis:6379/0

Storage Volumes

The storage directory should be shared between Docker and Apache:

  • Mount the same directory in docker-compose (./storage:/app/storage)
  • Symlink or configure Apache to serve from the same location
# On host, ensure storage directory exists and has correct permissions
mkdir -p /var/www/pah/storage/{uploads,audio,video}
chmod -R 755 /var/www/pah/storage

# In docker-compose, mount to this location:
volumes:
  - /var/www/pah/storage:/app/storage

Storage contents:

  • storage/uploads/ - Uploaded pet images ({session_id}.jpg)
  • storage/audio/ - Downloaded MP3 files from Sonauto ({session_id}.mp3)
  • storage/video/ - Generated MP4 videos ({session_id}.mp4)