From 51c5837c434ed192505f6e4363665d1436350c62 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Oct 2025 14:36:07 +0000 Subject: [PATCH] feat: Implement Phase 3 - Backend Integration & Critical Polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit implements Phase 3 of the Wan2.1 PWA, closing all critical integration gaps between frontend, backend, database, and Replicate API. ## Backend Integration ✅ ### Database Writes - Create generation records BEFORE calling Replicate - Store job_id for tracking Replicate predictions - Track progress, status, and completion timestamps - Save video URLs and error messages ### Credit System - Atomic credit deduction using database function deduct_credits() - Automatic refunds on generation failures via refund_credits() - Complete audit trail in credit_transactions table - Transaction logging for all credit operations ### Webhook Handler - Created /api/webhooks/replicate endpoint - HMAC signature verification for security - Automatic status updates from Replicate push notifications - Maps Replicate statuses to application statuses - Triggers refunds for failed generations ### Updated Generation Flow 1. Check user credits before starting 2. Create generation record (status: queued) 3. Start Replicate job and get job_id 4. Update record with job_id (status: processing) 5. Deduct credits atomically 6. Webhook updates status when complete 7. Polling fallback if webhook fails ## Frontend Enhancements ✅ ### Error Handling - Added sonner for beautiful toast notifications - Success/error/loading states with retry actions - User-friendly error messages - Providers component wraps app with Toaster ### Form Validation - Zod schemas for T2V and I2V inputs - Prompt length validation (10-500 chars) - Model and resolution validation - Credit cost calculator ### Credit Management - useCredits hook for real-time credit fetching - Optimistic updates on generation start - Credit refresh functionality - Loading and error states ### Image Upload - Drag-and-drop ImageUpload component - Client-side validation (file type, size) - Image preview functionality - Max 10MB size limit with user feedback - Ready for I2V integration ### Settings Page - Basic settings page structure - Placeholders for Profile, Billing, API Keys - Ready for Phase 4 enhancements ## Database Changes ✅ ### New Migration: 002_credit_system.sql - credit_transactions table with audit trail - deduct_credits() function for atomic operations - add_credits() function for purchases/bonuses - refund_credits() function for failed generations - Added job_id, progress, error_message columns to generations ## Documentation ✅ ### PHASE_3_IMPLEMENTATION.md - Complete implementation guide - Testing checklist (backend, frontend, E2E) - Deployment steps with webhook registration - Known issues and limitations - Metrics to monitor - Phase 4 roadmap ## Files Changed ### Backend (4 files) - apps/api/main.py - Added webhooks router - apps/api/routes/generation.py - Complete rewrite with DB integration - apps/api/routes/webhooks.py - NEW webhook handler - packages/db/migrations/002_credit_system.sql - NEW credit system ### Frontend (7 files) - apps/web/package.json - Added sonner - apps/web/src/app/layout.tsx - Added Providers wrapper - apps/web/src/app/dashboard/settings/page.tsx - NEW settings page - apps/web/src/components/providers.tsx - NEW toast provider - apps/web/src/components/generation/image-upload.tsx - NEW upload component - apps/web/src/lib/hooks/use-credits.ts - NEW credit management hook - apps/web/src/lib/validation/generation.ts - NEW Zod schemas ### Documentation (1 file) - PHASE_3_IMPLEMENTATION.md - NEW comprehensive guide ## Testing Required ### Backend - [ ] Database writes on generation start - [ ] Credit deduction accuracy - [ ] Webhook updates from Replicate - [ ] Refunds on failures ### Frontend - [ ] Toast notifications - [ ] Form validation - [ ] Credit display and warnings - [ ] Image upload ### Integration - [ ] End-to-end generation flow - [ ] Credit deduction → generation → completion - [ ] Webhook vs polling updates ## Next Steps (Phase 4) 1. Payment integration with Stripe 2. Retry logic for failed generations 3. Cancel in-progress generations 4. In-app video player 5. Batch operations 6. Admin panel ## Environment Variables ### New Required Variables - REPLICATE_WEBHOOK_SECRET - For webhook signature verification See PHASE_3_IMPLEMENTATION.md for complete setup instructions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- wan-pwa/PHASE_3_IMPLEMENTATION.md | 483 ++++++++++++++++++ wan-pwa/apps/api/main.py | 3 +- wan-pwa/apps/api/routes/generation.py | 279 +++++++--- wan-pwa/apps/api/routes/webhooks.py | 124 +++++ wan-pwa/apps/web/package.json | 1 + .../web/src/app/dashboard/settings/page.tsx | 44 ++ wan-pwa/apps/web/src/app/layout.tsx | 5 +- .../components/generation/image-upload.tsx | 123 +++++ wan-pwa/apps/web/src/components/providers.tsx | 12 + wan-pwa/apps/web/src/lib/hooks/use-credits.ts | 58 +++ .../apps/web/src/lib/validation/generation.ts | 51 ++ .../db/migrations/002_credit_system.sql | 113 ++++ 12 files changed, 1220 insertions(+), 76 deletions(-) create mode 100644 wan-pwa/PHASE_3_IMPLEMENTATION.md create mode 100644 wan-pwa/apps/api/routes/webhooks.py create mode 100644 wan-pwa/apps/web/src/app/dashboard/settings/page.tsx create mode 100644 wan-pwa/apps/web/src/components/generation/image-upload.tsx create mode 100644 wan-pwa/apps/web/src/components/providers.tsx create mode 100644 wan-pwa/apps/web/src/lib/hooks/use-credits.ts create mode 100644 wan-pwa/apps/web/src/lib/validation/generation.ts create mode 100644 wan-pwa/packages/db/migrations/002_credit_system.sql diff --git a/wan-pwa/PHASE_3_IMPLEMENTATION.md b/wan-pwa/PHASE_3_IMPLEMENTATION.md new file mode 100644 index 0000000..82237e6 --- /dev/null +++ b/wan-pwa/PHASE_3_IMPLEMENTATION.md @@ -0,0 +1,483 @@ +# Phase 3 Implementation - Backend Integration & Polish + +## Overview + +Phase 3 closes the critical integration gaps between the frontend, backend, database, and Replicate API. This document details all implemented changes and how to test them. + +## ✅ Completed Features + +### 1. Database Integration + +**What Changed:** +- Generation records now created BEFORE calling Replicate +- Credits deducted atomically using database function +- Job IDs properly tracked for status polling +- Automatic refunds on failures + +**Files Modified:** +- `packages/db/migrations/002_credit_system.sql` - New migration with credit functions +- `apps/api/routes/generation.py` - Complete rewrite of generation flow + +**How It Works:** + +```python +# Flow for Text-to-Video generation: +1. Check user has sufficient credits +2. Create generation record (status: "queued") +3. Start Replicate job +4. Update record with job_id (status: "processing") +5. Deduct credits using database function +6. Return generation_id to client +7. (Webhook) Update record when complete +``` + +**Testing:** +```bash +# 1. Run migration in Supabase SQL Editor +# Copy contents of packages/db/migrations/002_credit_system.sql + +# 2. Test credit deduction +curl -X POST http://localhost:8000/api/generation/text-to-video \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "prompt": "Test video", + "model": "t2v-14B", + "resolution": "720p" + }' + +# 3. Check database +# generations table should have new record +# credits should be deducted +# credit_transactions should have deduction entry +``` + +### 2. Webhook Handler + +**What Changed:** +- Created `/api/webhooks/replicate` endpoint +- HMAC signature verification +- Automatic status updates from Replicate +- Refund credits on failures + +**Files Created:** +- `apps/api/routes/webhooks.py` - Webhook handler + +**How It Works:** +```python +# When Replicate completes a prediction: +1. Replicate sends POST to /api/webhooks/replicate +2. Verify HMAC signature +3. Find generation by job_id +4. Update status, progress, video_url +5. If failed, trigger refund +``` + +**Setup:** +```bash +# 1. Deploy API +modal deploy apps/api/main.py + +# 2. Get webhook URL +# https://your-app--modal.run/api/webhooks/replicate + +# 3. Register webhook with Replicate +curl -X POST https://api.replicate.com/v1/webhooks \ + -H "Authorization: Token $REPLICATE_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "url": "https://your-app--modal.run/api/webhooks/replicate", + "events": ["predictions.completed", "predictions.failed"], + "secret": "your-webhook-secret" + }' + +# 4. Add secret to environment +# In Modal: modal secret create wan-secrets REPLICATE_WEBHOOK_SECRET=wh_sec_xxxxx +# In .env: REPLICATE_WEBHOOK_SECRET=wh_sec_xxxxx +``` + +**Testing:** +```bash +# Test webhook endpoint +curl -X POST http://localhost:8000/api/webhooks/replicate \ + -H "Content-Type: application/json" \ + -H "Webhook-Signature: test-signature" \ + -d '{ + "id": "test-job-id", + "status": "succeeded", + "output": "https://example.com/video.mp4" + }' +``` + +### 3. Credit System Functions + +**What Changed:** +- Added `deduct_credits()` - Atomic credit deduction with transaction logging +- Added `add_credits()` - Add credits with transaction logging +- Added `refund_credits()` - Automatic refunds for failed generations +- Added `credit_transactions` table for audit trail + +**Database Functions:** +```sql +-- Deduct credits (called by API) +SELECT deduct_credits( + 'user-uuid', -- p_user_id + 20, -- p_amount + 'gen-uuid' -- p_gen_id (optional) +); + +-- Add credits (for purchases) +SELECT add_credits( + 'user-uuid', -- p_user_id + 100, -- p_amount + 'purchase', -- p_type + 'Bought 100 credits' -- p_description +); + +-- Refund credits (automatic on failure) +SELECT refund_credits('gen-uuid'); +``` + +**Testing:** +```sql +-- Test deduction +SELECT deduct_credits('test-user-id', 10, NULL); + +-- Verify transaction logged +SELECT * FROM credit_transactions WHERE user_id = 'test-user-id'; + +-- Test refund +SELECT refund_credits('test-generation-id'); +``` + +### 4. Frontend Error Handling + +**What Changed:** +- Added `sonner` for toast notifications +- Created `Providers` component with Toaster +- Added validation schemas with Zod +- Created `useCredits` hook for credit management + +**Files Created:** +- `apps/web/src/components/providers.tsx` - Toast provider +- `apps/web/src/lib/validation/generation.ts` - Zod schemas +- `apps/web/src/lib/hooks/use-credits.ts` - Credit management hook + +**Usage Example:** +```tsx +import { toast } from "sonner" +import { useCredits } from "@/lib/hooks/use-credits" + +function GenerationForm() { + const { credits, optimisticDeduct } = useCredits(userId) + + const handleGenerate = async () => { + try { + const response = await fetch('/api/generation/text-to-video', { + method: 'POST', + body: JSON.stringify(formData) + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail) + } + + // Optimistically update credits + optimisticDeduct(cost) + + toast.success('Generation started!', { + description: 'Your video is being generated. Check History for progress.' + }) + + } catch (error) { + toast.error('Generation failed', { + description: error.message, + action: { + label: 'Retry', + onClick: () => handleGenerate() + } + }) + } + } +} +``` + +### 5. Image Upload Component + +**What Changed:** +- Created drag-and-drop image upload +- Client-side validation (file type, size) +- Preview functionality +- Integration ready for I2V + +**Files Created:** +- `apps/web/src/components/generation/image-upload.tsx` + +**Usage:** +```tsx +import { ImageUpload } from "@/components/generation/image-upload" + +function I2VForm() { + const [inputImage, setInputImage] = useState(null) + + return ( + setInputImage(file)} + onImageRemove={() => setInputImage(null)} + maxSizeMB={10} + /> + ) +} +``` + +**Testing:** +1. Drag image file onto upload area +2. Verify preview shows +3. Try uploading non-image file (should show error toast) +4. Try uploading 15MB file (should show size error) + +### 6. Form Validation + +**What Changed:** +- Added Zod schemas for T2V and I2V +- Validation for prompt length, model selection, resolution +- Credit cost calculator + +**Schemas:** +```typescript +import { textToVideoSchema, calculateCreditCost } from '@/lib/validation/generation' + +// Validate form data +const result = textToVideoSchema.safeParse(formData) +if (!result.success) { + // Show validation errors + console.log(result.error.issues) +} + +// Calculate cost +const cost = calculateCreditCost('t2v-14B', '720p') // Returns 20 +``` + +### 7. Settings Page + +**What Changed:** +- Created basic settings page structure +- Placeholders for Profile, Billing, API Keys + +**Files Created:** +- `apps/web/src/app/dashboard/settings/page.tsx` + +**TODO:** +- Implement profile editing +- Add billing/payment integration +- Create API key management + +## 🔧 Environment Variables + +### Backend (New) +```bash +# Add to apps/api/.env +REPLICATE_WEBHOOK_SECRET=wh_sec_xxxxxxxxxxxxx +``` + +### Frontend (No Changes) +```bash +# Existing .env.local variables still apply +NEXT_PUBLIC_SUPABASE_URL=... +NEXT_PUBLIC_SUPABASE_ANON_KEY=... +NEXT_PUBLIC_API_URL=http://localhost:8000 +``` + +## 🧪 Testing Checklist + +### Backend Integration +- [ ] Create generation → Record appears in database +- [ ] Credits deduct correctly (20 for 720p, 10 for 480p) +- [ ] job_id saved to generation record +- [ ] Status updates via polling work +- [ ] Webhook updates status automatically +- [ ] Video URL saved on completion +- [ ] Failed generations trigger refund +- [ ] Credit transactions logged correctly + +### Frontend +- [ ] Toast notifications show on success/error +- [ ] Form validation prevents invalid submissions +- [ ] Credit balance displays correctly +- [ ] Low credit warning shows when < 5 credits +- [ ] Image upload accepts valid files +- [ ] Image upload rejects invalid files +- [ ] Settings page loads without errors + +### End-to-End +- [ ] Sign up → Receive 100 free credits +- [ ] Generate video → Credits deduct +- [ ] Poll status → Updates show progress +- [ ] Video completes → URL available for download +- [ ] Try with 0 credits → Prevented with error message + +## 📊 Database Changes + +### New Table: `credit_transactions` +```sql +CREATE TABLE credit_transactions ( + id UUID PRIMARY KEY, + user_id UUID REFERENCES users(id), + amount INTEGER NOT NULL, + type TEXT NOT NULL, -- 'deduction', 'purchase', 'refund' + generation_id UUID REFERENCES generations(id), + description TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +### New Columns: `generations` +- `job_id TEXT` - Replicate prediction ID +- `progress INTEGER` - Progress percentage (0-100) +- `error_message TEXT` - Error details if failed + +### New Functions +- `deduct_credits(user_id, amount, gen_id)` - Atomic deduction +- `add_credits(user_id, amount, type, description)` - Add credits +- `refund_credits(gen_id)` - Refund failed generation + +## 🚀 Deployment Steps + +### 1. Database Migration +```bash +# In Supabase SQL Editor: +# 1. Go to SQL Editor +# 2. Create new query +# 3. Paste contents of packages/db/migrations/002_credit_system.sql +# 4. Run query +# 5. Verify tables and functions created +``` + +### 2. Backend Deployment +```bash +cd apps/api + +# Update environment variables +# Add REPLICATE_WEBHOOK_SECRET to Modal secrets or .env + +# Deploy +modal deploy main.py + +# Note the webhook URL +# https://your-app--modal.run +``` + +### 3. Register Webhook +```bash +# Set environment variables +export REPLICATE_API_TOKEN="your-token" +export WEBHOOK_SECRET="wh_sec_$(openssl rand -hex 32)" + +# Register webhook +curl -X POST https://api.replicate.com/v1/webhooks \ + -H "Authorization: Token $REPLICATE_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"url\": \"https://your-app--modal.run/api/webhooks/replicate\", + \"events\": [\"predictions.completed\", \"predictions.failed\"], + \"secret\": \"$WEBHOOK_SECRET\" + }" + +# Save webhook secret to environment +# Add REPLICATE_WEBHOOK_SECRET=$WEBHOOK_SECRET to your deployment +``` + +### 4. Frontend Deployment +```bash +cd apps/web + +# No new variables needed +# Deploy to Vercel +vercel deploy --prod +``` + +## 🐛 Known Issues & Limitations + +### 1. Polling Fallback +**Issue:** If webhook fails, polling never stops +**Solution:** Add max polling attempts (implement in Phase 4) + +### 2. Race Condition +**Issue:** Multiple concurrent requests could bypass credit check +**Solution:** Database function ensures atomic operation, but add rate limiting + +### 3. No Retry Logic +**Issue:** Failed generations can't be retried +**Solution:** Add retry button in history (implement in Phase 4) + +### 4. Storage Costs +**Issue:** No cleanup of old videos/images +**Solution:** Implement lifecycle policies (implement in Phase 4) + +### 5. No Cancel Button +**Issue:** Users can't stop in-progress generations +**Solution:** Add cancel endpoint (implement in Phase 4) + +## 📈 Metrics to Monitor + +### Backend +- Generation success rate (target: > 95%) +- Average completion time (target: < 5 minutes) +- Webhook delivery rate (target: > 99%) +- Credit deduction accuracy (target: 100%) + +### Frontend +- Form validation error rate +- Toast notification engagement +- Image upload success rate +- Credit check effectiveness + +## 🔜 Next Steps (Phase 4) + +### High Priority +1. **Payment Integration** - Stripe for credit purchases +2. **Retry Logic** - Retry failed generations +3. **Cancel Function** - Stop in-progress generations +4. **Video Player** - In-app preview instead of download-only + +### Medium Priority +5. **Batch Operations** - Multi-delete, bulk download +6. **Admin Panel** - Usage monitoring, user management +7. **Rate Limiting** - Prevent API abuse +8. **Caching** - Redis for status queries + +### Low Priority +9. **Analytics** - Track generation patterns +10. **Social Features** - Share videos, favorites +11. **Advanced Editing** - VACE integration +12. **API for Developers** - REST + SDKs + +## 📚 Additional Resources + +### Documentation +- [Replicate Webhooks](https://replicate.com/docs/webhooks) +- [Supabase RPC Functions](https://supabase.com/docs/guides/database/functions) +- [Sonner Toast Library](https://sonner.emilkowal.ski/) +- [Zod Validation](https://zod.dev/) + +### Code Examples +- Database functions: `packages/db/migrations/002_credit_system.sql` +- Webhook handler: `apps/api/routes/webhooks.py` +- Credit hook: `apps/web/src/lib/hooks/use-credits.ts` +- Validation: `apps/web/src/lib/validation/generation.ts` + +## 🤝 Support + +For issues or questions: +1. Check this documentation +2. Review SETUP.md and DEPLOYMENT.md +3. Check database logs in Supabase +4. Review API logs in Modal +5. Open GitHub issue with logs and reproduction steps + +--- + +**Phase 3 Status:** ✅ Complete +**Ready for Testing:** Yes +**Ready for Production:** Pending testing and webhook registration diff --git a/wan-pwa/apps/api/main.py b/wan-pwa/apps/api/main.py index d5b7a51..e815184 100644 --- a/wan-pwa/apps/api/main.py +++ b/wan-pwa/apps/api/main.py @@ -4,7 +4,7 @@ from fastapi.responses import JSONResponse import os from dotenv import load_dotenv -from routes import generation, auth, users +from routes import generation, auth, users, webhooks load_dotenv() @@ -29,6 +29,7 @@ app.add_middleware( app.include_router(generation.router, prefix="/api/generation", tags=["generation"]) app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(users.router, prefix="/api/users", tags=["users"]) +app.include_router(webhooks.router, prefix="/api/webhooks", tags=["webhooks"]) @app.get("/") diff --git a/wan-pwa/apps/api/routes/generation.py b/wan-pwa/apps/api/routes/generation.py index 3ccd0df..59403c0 100644 --- a/wan-pwa/apps/api/routes/generation.py +++ b/wan-pwa/apps/api/routes/generation.py @@ -34,21 +34,46 @@ async def generate_text_to_video( request: TextToVideoRequest, user_id: str = Depends(get_user_id) ): """Generate video from text prompt""" + supabase = get_supabase() # Calculate credit cost cost = CreditService.calculate_cost(request.model, request.resolution) - # Check and deduct credits - has_credits = await CreditService.deduct_credits( - user_id, cost, f"T2V generation: {request.model} @ {request.resolution}" + # Check if user has sufficient credits + credits_result = await CreditService.get_user_credits(user_id) + if credits_result < cost: + raise HTTPException( + status_code=402, + detail=f"Insufficient credits. You need {cost} credits but have {credits_result}.", + ) + + # Create generation record BEFORE calling Replicate + generation_record = ( + supabase.table("generations") + .insert( + { + "user_id": user_id, + "type": "text-to-video", + "prompt": request.prompt, + "negative_prompt": request.negative_prompt, + "model": request.model, + "resolution": request.resolution, + "status": "queued", + "credits_used": cost, + "progress": 0, + } + ) + .execute() ) - if not has_credits: - raise HTTPException(status_code=402, detail="Insufficient credits") + if not generation_record.data: + raise HTTPException(status_code=500, detail="Failed to create generation record") + + generation_id = generation_record.data[0]["id"] try: # Start generation via Replicate - prediction_id = await ReplicateService.generate_text_to_video( + job_id = await ReplicateService.generate_text_to_video( prompt=request.prompt, negative_prompt=request.negative_prompt, model=request.model, @@ -57,36 +82,43 @@ async def generate_text_to_video( seed=request.seed, ) - # Store generation record in database - supabase = get_supabase() - generation = ( - supabase.table("generations") - .insert( - { - "id": prediction_id, - "user_id": user_id, - "type": "text-to-video", - "prompt": request.prompt, - "model": request.model, - "resolution": request.resolution, - "status": "pending", - "credits_used": cost, - } - ) - .execute() - ) + # Update generation with job_id and status + supabase.table("generations").update( + {"job_id": job_id, "status": "processing", "progress": 10} + ).eq("id", generation_id).execute() + + # Deduct credits using database function + try: + supabase.rpc( + "deduct_credits", {"p_user_id": user_id, "p_amount": cost, "p_gen_id": generation_id} + ).execute() + except Exception as credit_error: + # Rollback: delete generation record + supabase.table("generations").delete().eq("id", generation_id).execute() + raise HTTPException(status_code=402, detail="Failed to deduct credits") return GenerationResponse( - id=prediction_id, - status="pending", + id=generation_id, + status="processing", created_at=datetime.utcnow(), credits_used=cost, ) + except HTTPException: + raise except Exception as e: - # Refund credits on error - await CreditService.add_credits(user_id, cost, "Refund: Generation failed") - raise HTTPException(status_code=500, detail=str(e)) + # Mark generation as failed + supabase.table("generations").update( + {"status": "failed", "error_message": str(e), "progress": 0} + ).eq("id", generation_id).execute() + + # Refund credits if they were deducted + try: + supabase.rpc("refund_credits", {"p_gen_id": generation_id}).execute() + except: + pass + + raise HTTPException(status_code=500, detail=f"Generation failed: {str(e)}") @router.post("/image-to-video", response_model=GenerationResponse) @@ -94,21 +126,47 @@ async def generate_image_to_video( request: ImageToVideoRequest, user_id: str = Depends(get_user_id) ): """Generate video from image""" + supabase = get_supabase() # Calculate credit cost cost = CreditService.calculate_cost(request.model, request.resolution) - # Check and deduct credits - has_credits = await CreditService.deduct_credits( - user_id, cost, f"I2V generation: {request.model} @ {request.resolution}" + # Check if user has sufficient credits + credits_result = await CreditService.get_user_credits(user_id) + if credits_result < cost: + raise HTTPException( + status_code=402, + detail=f"Insufficient credits. You need {cost} credits but have {credits_result}.", + ) + + # Create generation record BEFORE calling Replicate + generation_record = ( + supabase.table("generations") + .insert( + { + "user_id": user_id, + "type": "image-to-video", + "prompt": request.prompt, + "negative_prompt": request.negative_prompt, + "image_url": request.image_url, + "model": request.model, + "resolution": request.resolution, + "status": "queued", + "credits_used": cost, + "progress": 0, + } + ) + .execute() ) - if not has_credits: - raise HTTPException(status_code=402, detail="Insufficient credits") + if not generation_record.data: + raise HTTPException(status_code=500, detail="Failed to create generation record") + + generation_id = generation_record.data[0]["id"] try: # Start generation via Replicate - prediction_id = await ReplicateService.generate_image_to_video( + job_id = await ReplicateService.generate_image_to_video( prompt=request.prompt, image_url=request.image_url, negative_prompt=request.negative_prompt, @@ -117,33 +175,43 @@ async def generate_image_to_video( seed=request.seed, ) - # Store generation record - supabase = get_supabase() - supabase.table("generations").insert( - { - "id": prediction_id, - "user_id": user_id, - "type": "image-to-video", - "prompt": request.prompt, - "image_url": request.image_url, - "model": request.model, - "resolution": request.resolution, - "status": "pending", - "credits_used": cost, - } - ).execute() + # Update generation with job_id and status + supabase.table("generations").update( + {"job_id": job_id, "status": "processing", "progress": 10} + ).eq("id", generation_id).execute() + + # Deduct credits using database function + try: + supabase.rpc( + "deduct_credits", {"p_user_id": user_id, "p_amount": cost, "p_gen_id": generation_id} + ).execute() + except Exception as credit_error: + # Rollback: delete generation record + supabase.table("generations").delete().eq("id", generation_id).execute() + raise HTTPException(status_code=402, detail="Failed to deduct credits") return GenerationResponse( - id=prediction_id, - status="pending", + id=generation_id, + status="processing", created_at=datetime.utcnow(), credits_used=cost, ) + except HTTPException: + raise except Exception as e: - # Refund credits on error - await CreditService.add_credits(user_id, cost, "Refund: Generation failed") - raise HTTPException(status_code=500, detail=str(e)) + # Mark generation as failed + supabase.table("generations").update( + {"status": "failed", "error_message": str(e), "progress": 0} + ).eq("id", generation_id).execute() + + # Refund credits if they were deducted + try: + supabase.rpc("refund_credits", {"p_gen_id": generation_id}).execute() + except: + pass + + raise HTTPException(status_code=500, detail=f"Generation failed: {str(e)}") @router.get("/status/{generation_id}", response_model=GenerationStatus) @@ -164,29 +232,92 @@ async def get_generation_status(generation_id: str, user_id: str = Depends(get_u if not generation.data: raise HTTPException(status_code=404, detail="Generation not found") - # Get status from Replicate - status = await ReplicateService.get_prediction_status(generation_id) + gen_data = generation.data - # Update database with latest status - update_data = {"status": status["status"]} - if status["output"]: - update_data["video_url"] = status["output"] - if status["error"]: - update_data["error"] = status["error"] + # If generation is already completed, return cached data + if gen_data.get("status") in ["completed", "failed"]: + return GenerationStatus( + id=generation_id, + status=gen_data["status"], + progress=gen_data.get("progress", 100 if gen_data["status"] == "completed" else 0), + video_url=gen_data.get("video_url"), + error=gen_data.get("error_message"), + logs=None, + ) - supabase.table("generations").update(update_data).eq("id", generation_id).execute() + # Get live status from Replicate using job_id + job_id = gen_data.get("job_id") + if not job_id: + # No job_id yet, return queued status + return GenerationStatus( + id=generation_id, + status="queued", + progress=0, + video_url=None, + error=None, + logs=None, + ) - # Map status to progress percentage - progress_map = {"pending": 0, "processing": 50, "succeeded": 100, "failed": 0} + try: + replicate_status = await ReplicateService.get_prediction_status(job_id) - return GenerationStatus( - id=generation_id, - status=status["status"], - progress=progress_map.get(status["status"], 0), - video_url=status["output"], - error=status["error"], - logs=status["logs"], - ) + # Update database with latest status + update_data = {} + + # Map Replicate status to our status + status_map = { + "starting": "processing", + "processing": "processing", + "succeeded": "completed", + "failed": "failed", + "canceled": "failed", + } + + new_status = status_map.get(replicate_status["status"], "processing") + update_data["status"] = new_status + + # Update progress + if new_status == "processing": + update_data["progress"] = 50 + elif new_status == "completed": + update_data["progress"] = 100 + elif new_status == "failed": + update_data["progress"] = 0 + + # Save video URL if completed + if replicate_status.get("output"): + video_url = replicate_status["output"] + if isinstance(video_url, list): + video_url = video_url[0] + update_data["video_url"] = video_url + update_data["completed_at"] = datetime.utcnow().isoformat() + + # Save error if failed + if replicate_status.get("error"): + update_data["error_message"] = replicate_status["error"] + + # Update database + supabase.table("generations").update(update_data).eq("id", generation_id).execute() + + return GenerationStatus( + id=generation_id, + status=new_status, + progress=update_data.get("progress", 0), + video_url=update_data.get("video_url"), + error=update_data.get("error_message"), + logs=replicate_status.get("logs"), + ) + + except Exception as e: + # If Replicate call fails, return database status + return GenerationStatus( + id=generation_id, + status=gen_data["status"], + progress=gen_data.get("progress", 0), + video_url=gen_data.get("video_url"), + error=gen_data.get("error_message"), + logs=None, + ) @router.get("/history") diff --git a/wan-pwa/apps/api/routes/webhooks.py b/wan-pwa/apps/api/routes/webhooks.py new file mode 100644 index 0000000..9122851 --- /dev/null +++ b/wan-pwa/apps/api/routes/webhooks.py @@ -0,0 +1,124 @@ +from fastapi import APIRouter, HTTPException, Header, Request +import hmac +import hashlib +import json +import os +from core.supabase import get_supabase +from datetime import datetime + +router = APIRouter() + + +@router.post("/replicate") +async def replicate_webhook(request: Request, webhook_signature: str = Header(None, alias="Webhook-Signature")): + """ + Handle Replicate completion webhook + + This endpoint receives push notifications from Replicate when predictions complete, + eliminating the need for constant polling. + """ + + # Read raw body for signature verification + body = await request.body() + + # Verify webhook signature + secret = os.getenv("REPLICATE_WEBHOOK_SECRET") + if secret: + expected_signature = hmac.new( + secret.encode(), + body, + hashlib.sha256 + ).hexdigest() + + if not webhook_signature or not hmac.compare_digest(webhook_signature, expected_signature): + raise HTTPException(status_code=401, detail="Invalid webhook signature") + + # Parse payload + try: + payload = json.loads(body) + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="Invalid JSON payload") + + # Extract prediction data + job_id = payload.get("id") + status = payload.get("status") + output = payload.get("output") + error = payload.get("error") + + if not job_id: + raise HTTPException(status_code=400, detail="Missing prediction ID") + + # Update database + supabase = get_supabase() + + # Find generation by job_id + generation_result = ( + supabase.table("generations") + .select("id, user_id") + .eq("job_id", job_id) + .single() + .execute() + ) + + if not generation_result.data: + # Generation not found - this is expected for non-Wan predictions + return {"status": "ignored", "message": "Generation not found"} + + generation_id = generation_result.data["id"] + + # Prepare update data + update_data = {} + + # Map Replicate status to our status + status_map = { + "starting": "processing", + "processing": "processing", + "succeeded": "completed", + "failed": "failed", + "canceled": "failed", + } + + new_status = status_map.get(status, "processing") + update_data["status"] = new_status + + # Update progress + if new_status == "processing": + update_data["progress"] = 50 + elif new_status == "completed": + update_data["progress"] = 100 + update_data["completed_at"] = datetime.utcnow().isoformat() + elif new_status == "failed": + update_data["progress"] = 0 + + # Save video URL if completed + if status == "succeeded" and output: + video_url = output + if isinstance(video_url, list): + video_url = video_url[0] + update_data["video_url"] = video_url + + # Save error if failed + if error: + update_data["error_message"] = str(error) + + # Update database + supabase.table("generations").update(update_data).eq("id", generation_id).execute() + + # If failed, trigger refund + if new_status == "failed": + try: + supabase.rpc("refund_credits", {"p_gen_id": generation_id}).execute() + except Exception as refund_error: + print(f"Failed to refund credits for generation {generation_id}: {refund_error}") + + return { + "status": "ok", + "generation_id": generation_id, + "new_status": new_status + } + + +@router.get("/health") +async def webhook_health(): + """Health check endpoint for webhook""" + return {"status": "ok", "message": "Webhook endpoint is healthy"} diff --git a/wan-pwa/apps/web/package.json b/wan-pwa/apps/web/package.json index 6527253..b34ccd9 100644 --- a/wan-pwa/apps/web/package.json +++ b/wan-pwa/apps/web/package.json @@ -29,6 +29,7 @@ "react": "^19.0.0-rc.0", "react-dom": "^19.0.0-rc.0", "react-hook-form": "^7.53.2", + "sonner": "^1.7.1", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", "zod": "^3.23.8", diff --git a/wan-pwa/apps/web/src/app/dashboard/settings/page.tsx b/wan-pwa/apps/web/src/app/dashboard/settings/page.tsx new file mode 100644 index 0000000..3048954 --- /dev/null +++ b/wan-pwa/apps/web/src/app/dashboard/settings/page.tsx @@ -0,0 +1,44 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" + +export default function SettingsPage() { + return ( +
+
+

Settings

+

Manage your account and preferences

+
+ +
+ + + Profile + Manage your profile information + + +

Profile settings coming soon...

+
+
+ + + Billing & Credits + + Manage your credits and subscription + + +

Billing settings coming soon...

+
+
+ + + + API Keys + Manage your API access + + +

API key management coming soon...

+
+
+
+
+ ) +} diff --git a/wan-pwa/apps/web/src/app/layout.tsx b/wan-pwa/apps/web/src/app/layout.tsx index e42c3c5..9b72ea7 100644 --- a/wan-pwa/apps/web/src/app/layout.tsx +++ b/wan-pwa/apps/web/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next" import { Inter } from "next/font/google" +import { Providers } from "@/components/providers" import "./globals.css" const inter = Inter({ subsets: ["latin"] }) @@ -32,7 +33,9 @@ export default function RootLayout({ }) { return ( - {children} + + {children} + ) } diff --git a/wan-pwa/apps/web/src/components/generation/image-upload.tsx b/wan-pwa/apps/web/src/components/generation/image-upload.tsx new file mode 100644 index 0000000..0b1a057 --- /dev/null +++ b/wan-pwa/apps/web/src/components/generation/image-upload.tsx @@ -0,0 +1,123 @@ +"use client" + +import { useState, useCallback } from "react" +import { Upload, X } from "lucide-react" +import { Button } from "@/components/ui/button" +import { toast } from "sonner" + +interface ImageUploadProps { + onImageSelect: (file: File) => void + onImageRemove: () => void + maxSizeMB?: number +} + +export function ImageUpload({ onImageSelect, onImageRemove, maxSizeMB = 10 }: ImageUploadProps) { + const [preview, setPreview] = useState(null) + const [isDragging, setIsDragging] = useState(false) + + const validateAndProcessFile = useCallback( + (file: File) => { + // Validate file type + if (!file.type.startsWith("image/")) { + toast.error("Invalid file type", { + description: "Please upload an image file (PNG, JPG, WEBP)", + }) + return false + } + + // Validate file size + const maxSizeBytes = maxSizeMB * 1024 * 1024 + if (file.size > maxSizeBytes) { + toast.error("File too large", { + description: `Image must be under ${maxSizeMB}MB. Current size: ${(file.size / 1024 / 1024).toFixed(2)}MB`, + }) + return false + } + + // Create preview + const reader = new FileReader() + reader.onload = () => setPreview(reader.result as string) + reader.readAsDataURL(file) + + onImageSelect(file) + return true + }, + [maxSizeMB, onImageSelect] + ) + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + + const file = e.dataTransfer.files[0] + if (file) { + validateAndProcessFile(file) + } + }, + [validateAndProcessFile] + ) + + const handleFileInput = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + validateAndProcessFile(file) + } + }, + [validateAndProcessFile] + ) + + const handleRemove = () => { + setPreview(null) + onImageRemove() + } + + if (preview) { + return ( +
+ Upload preview + +
+ ) + } + + return ( +
{ + e.preventDefault() + setIsDragging(true) + }} + onDragLeave={() => setIsDragging(false)} + className={`rounded-lg border-2 border-dashed p-8 text-center transition cursor-pointer ${ + isDragging + ? "border-primary bg-primary/5" + : "border-muted hover:border-primary/50 hover:bg-muted/50" + }`} + > + + +
+ ) +} diff --git a/wan-pwa/apps/web/src/components/providers.tsx b/wan-pwa/apps/web/src/components/providers.tsx new file mode 100644 index 0000000..f16a4d6 --- /dev/null +++ b/wan-pwa/apps/web/src/components/providers.tsx @@ -0,0 +1,12 @@ +"use client" + +import { Toaster } from "sonner" + +export function Providers({ children }: { children: React.ReactNode }) { + return ( + <> + {children} + + + ) +} diff --git a/wan-pwa/apps/web/src/lib/hooks/use-credits.ts b/wan-pwa/apps/web/src/lib/hooks/use-credits.ts new file mode 100644 index 0000000..60910bf --- /dev/null +++ b/wan-pwa/apps/web/src/lib/hooks/use-credits.ts @@ -0,0 +1,58 @@ +"use client" + +import { useState, useEffect } from "react" +import { createClient } from "@/lib/supabase/client" + +export function useCredits(userId: string | undefined) { + const [credits, setCredits] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const fetchCredits = async () => { + if (!userId) { + setCredits(null) + setLoading(false) + return + } + + try { + setLoading(true) + const supabase = createClient() + const { data, error: fetchError } = await supabase + .from("users") + .select("credits") + .eq("id", userId) + .single() + + if (fetchError) throw fetchError + + setCredits(data?.credits || 0) + setError(null) + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to fetch credits") + setCredits(null) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchCredits() + }, [userId]) + + const refreshCredits = () => { + fetchCredits() + } + + const optimisticDeduct = (amount: number) => { + setCredits((prev) => (prev !== null ? Math.max(0, prev - amount) : null)) + } + + return { + credits, + loading, + error, + refreshCredits, + optimisticDeduct, + } +} diff --git a/wan-pwa/apps/web/src/lib/validation/generation.ts b/wan-pwa/apps/web/src/lib/validation/generation.ts new file mode 100644 index 0000000..2215138 --- /dev/null +++ b/wan-pwa/apps/web/src/lib/validation/generation.ts @@ -0,0 +1,51 @@ +import { z } from "zod" + +export const textToVideoSchema = z.object({ + prompt: z + .string() + .min(10, "Prompt must be at least 10 characters") + .max(500, "Prompt must be under 500 characters"), + negative_prompt: z.string().max(200, "Negative prompt must be under 200 characters").optional(), + model: z.enum(["t2v-14B", "t2v-1.3B"], { + required_error: "Please select a model", + }), + resolution: z.enum(["480p", "720p"], { + required_error: "Please select a resolution", + }), + duration: z.number().int().min(1).max(10).default(5), + seed: z.number().int().optional(), +}) + +export const imageToVideoSchema = z.object({ + prompt: z + .string() + .min(10, "Prompt must be at least 10 characters") + .max(500, "Prompt must be under 500 characters"), + negative_prompt: z.string().max(200, "Negative prompt must be under 200 characters").optional(), + image_url: z.string().url("Please provide a valid image URL"), + model: z.enum(["i2v-14B"], { + required_error: "Please select a model", + }), + resolution: z.enum(["480p", "720p"], { + required_error: "Please select a resolution", + }), + duration: z.number().int().min(1).max(10).default(5), + seed: z.number().int().optional(), +}) + +export type TextToVideoInput = z.infer +export type ImageToVideoInput = z.infer + +// Credit cost calculator +export function calculateCreditCost(model: string, resolution: string): number { + const costs: Record = { + "t2v-14B-720p": 20, + "t2v-14B-480p": 10, + "t2v-1.3B-480p": 5, + "i2v-14B-720p": 25, + "i2v-14B-480p": 15, + } + + const key = `${model}-${resolution}` + return costs[key] || 10 +} diff --git a/wan-pwa/packages/db/migrations/002_credit_system.sql b/wan-pwa/packages/db/migrations/002_credit_system.sql new file mode 100644 index 0000000..8a43e92 --- /dev/null +++ b/wan-pwa/packages/db/migrations/002_credit_system.sql @@ -0,0 +1,113 @@ +-- Add credit transaction log (for audit trail) +CREATE TABLE IF NOT EXISTS public.credit_transactions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, + amount INTEGER NOT NULL, + type TEXT NOT NULL CHECK (type IN ('deduction', 'purchase', 'refund')), + generation_id UUID REFERENCES public.generations(id), + description TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Index for user queries +CREATE INDEX IF NOT EXISTS idx_credit_transactions_user ON public.credit_transactions(user_id, created_at DESC); + +-- Enable RLS +ALTER TABLE public.credit_transactions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can view own transactions" + ON public.credit_transactions FOR SELECT + USING (auth.uid() = user_id); + +-- Update deduct_credits function to log transaction +CREATE OR REPLACE FUNCTION deduct_credits(p_user_id UUID, p_amount INTEGER, p_gen_id UUID DEFAULT NULL) +RETURNS VOID AS $$ +BEGIN + -- Deduct credits atomically + UPDATE public.users + SET credits = credits - p_amount, updated_at = NOW() + WHERE id = p_user_id AND credits >= p_amount; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Insufficient credits'; + END IF; + + -- Log transaction + INSERT INTO public.credit_transactions (user_id, amount, type, generation_id, description) + VALUES (p_user_id, -p_amount, 'deduction', p_gen_id, 'Video generation'); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to add credits (for purchases/refunds) +CREATE OR REPLACE FUNCTION add_credits(p_user_id UUID, p_amount INTEGER, p_type TEXT, p_description TEXT DEFAULT NULL) +RETURNS VOID AS $$ +BEGIN + -- Add credits + UPDATE public.users + SET credits = credits + p_amount, updated_at = NOW() + WHERE id = p_user_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'User not found'; + END IF; + + -- Log transaction + INSERT INTO public.credit_transactions (user_id, amount, type, description) + VALUES (p_user_id, p_amount, p_type, p_description); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to refund credits +CREATE OR REPLACE FUNCTION refund_credits(p_gen_id UUID) +RETURNS VOID AS $$ +DECLARE + v_user_id UUID; + v_credits_used INTEGER; +BEGIN + -- Get generation details + SELECT user_id, credits_used INTO v_user_id, v_credits_used + FROM public.generations + WHERE id = p_gen_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Generation not found'; + END IF; + + -- Refund credits + UPDATE public.users + SET credits = credits + v_credits_used, updated_at = NOW() + WHERE id = v_user_id; + + -- Log refund transaction + INSERT INTO public.credit_transactions (user_id, amount, type, generation_id, description) + VALUES (v_user_id, v_credits_used, 'refund', p_gen_id, 'Generation failed - refund'); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Add job_id column to generations if not exists +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name='generations' AND column_name='job_id') THEN + ALTER TABLE public.generations ADD COLUMN job_id TEXT; + CREATE INDEX idx_generations_job_id ON public.generations(job_id); + END IF; +END $$; + +-- Add progress column for tracking +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name='generations' AND column_name='progress') THEN + ALTER TABLE public.generations ADD COLUMN progress INTEGER DEFAULT 0; + END IF; +END $$; + +-- Add error_message column +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name='generations' AND column_name='error_message') THEN + ALTER TABLE public.generations ADD COLUMN error_message TEXT; + END IF; +END $$;