mirror of
https://github.com/Wan-Video/Wan2.1.git
synced 2025-11-03 05:52:18 +00:00
Compare commits
4 Commits
cf5c95fd7a
...
576864de25
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
576864de25 | ||
|
|
e9fc673b3c | ||
|
|
9a8fcf76cb | ||
|
|
51c5837c43 |
9
wan-pwa/.npmrc
Normal file
9
wan-pwa/.npmrc
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Vercel build configuration
|
||||||
|
legacy-peer-deps=false
|
||||||
|
strict-peer-dependencies=false
|
||||||
|
auto-install-peers=true
|
||||||
|
|
||||||
|
# Performance
|
||||||
|
prefer-offline=true
|
||||||
|
progress=false
|
||||||
|
loglevel=error
|
||||||
519
wan-pwa/MCP_SERVER_SETUP.md
Normal file
519
wan-pwa/MCP_SERVER_SETUP.md
Normal file
@ -0,0 +1,519 @@
|
|||||||
|
# MCP Server Setup for Wan2.1 PWA
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This guide shows how to connect Claude Desktop to your local Wan2.1 PWA project directory for persistent file access and seamless development workflow.
|
||||||
|
|
||||||
|
## What is MCP?
|
||||||
|
|
||||||
|
**Model Context Protocol (MCP)** allows Claude Desktop to directly access and manipulate files in your local project directory, eliminating the need to copy files back and forth.
|
||||||
|
|
||||||
|
### Benefits
|
||||||
|
- 🔄 **Direct File Access** - Claude can read and write files directly in your project
|
||||||
|
- 💾 **Persistent Changes** - All edits are saved to your local filesystem
|
||||||
|
- 🚀 **Faster Workflow** - No manual copying of code between sessions
|
||||||
|
- 🔒 **Secure** - Runs locally on your machine, you control access
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Claude Desktop** installed ([download here](https://claude.ai/download))
|
||||||
|
- **Node.js 18+** installed
|
||||||
|
- **Your Wan2.1 PWA project** cloned locally
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation & Setup
|
||||||
|
|
||||||
|
### Step 1: Install MCP SDK
|
||||||
|
|
||||||
|
Choose one of these methods:
|
||||||
|
|
||||||
|
#### Global Installation (Recommended)
|
||||||
|
```bash
|
||||||
|
npm install -g @modelcontextprotocol/sdk
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Or Use npx (No global install needed)
|
||||||
|
```bash
|
||||||
|
npx @modelcontextprotocol/sdk --version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2: Get Your Project Path
|
||||||
|
|
||||||
|
You need the **absolute path** to your wan-pwa directory.
|
||||||
|
|
||||||
|
#### macOS/Linux
|
||||||
|
```bash
|
||||||
|
cd /path/to/Wan2.1/wan-pwa
|
||||||
|
pwd
|
||||||
|
# Example output: /Users/yourname/projects/Wan2.1/wan-pwa
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Windows
|
||||||
|
```powershell
|
||||||
|
cd C:\path\to\Wan2.1\wan-pwa
|
||||||
|
cd
|
||||||
|
# Example output: C:\Users\yourname\projects\Wan2.1\wan-pwa
|
||||||
|
```
|
||||||
|
|
||||||
|
**Copy this path** - you'll need it in the next step.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3: Configure Claude Desktop
|
||||||
|
|
||||||
|
#### macOS
|
||||||
|
|
||||||
|
1. **Open Claude Desktop Settings**
|
||||||
|
- Click **Claude** in menu bar → **Settings** (or `Cmd + ,`)
|
||||||
|
- Navigate to **Developer** tab
|
||||||
|
- Click **Edit Config**
|
||||||
|
|
||||||
|
2. **Add MCP Server Configuration**
|
||||||
|
|
||||||
|
Paste this JSON (replace `YOUR_ABSOLUTE_PATH`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"wan-pwa": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-filesystem",
|
||||||
|
"YOUR_ABSOLUTE_PATH"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example (macOS):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"wan-pwa": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-filesystem",
|
||||||
|
"/Users/johnsmith/projects/Wan2.1/wan-pwa"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Save and Restart**
|
||||||
|
- Save the file (`Cmd + S`)
|
||||||
|
- Quit Claude Desktop (`Cmd + Q`)
|
||||||
|
- Reopen Claude Desktop
|
||||||
|
|
||||||
|
#### Windows
|
||||||
|
|
||||||
|
1. **Open Claude Desktop Settings**
|
||||||
|
- Click **Settings** icon → **Developer** tab
|
||||||
|
- Click **Edit Config**
|
||||||
|
|
||||||
|
2. **Add MCP Server Configuration**
|
||||||
|
|
||||||
|
Paste this JSON (replace `YOUR_ABSOLUTE_PATH`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"wan-pwa": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-filesystem",
|
||||||
|
"YOUR_ABSOLUTE_PATH"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example (Windows):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"wan-pwa": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-filesystem",
|
||||||
|
"C:\\Users\\johnsmith\\projects\\Wan2.1\\wan-pwa"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Use double backslashes (`\\`) in Windows paths.
|
||||||
|
|
||||||
|
3. **Save and Restart**
|
||||||
|
- Save the file
|
||||||
|
- Close and reopen Claude Desktop
|
||||||
|
|
||||||
|
#### Linux
|
||||||
|
|
||||||
|
1. **Open Claude Desktop Settings**
|
||||||
|
- Open Settings → Developer tab
|
||||||
|
- Click **Edit Config**
|
||||||
|
|
||||||
|
2. **Add MCP Server Configuration**
|
||||||
|
|
||||||
|
Paste this JSON (replace `YOUR_ABSOLUTE_PATH`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"wan-pwa": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-filesystem",
|
||||||
|
"YOUR_ABSOLUTE_PATH"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example (Linux):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"wan-pwa": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-filesystem",
|
||||||
|
"/home/johnsmith/projects/Wan2.1/wan-pwa"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Save and Restart**
|
||||||
|
- Save the file
|
||||||
|
- Restart Claude Desktop
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4: Verify Connection
|
||||||
|
|
||||||
|
Open a new conversation in Claude Desktop and ask:
|
||||||
|
|
||||||
|
```
|
||||||
|
Can you list the files in my wan-pwa project?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Response:**
|
||||||
|
```
|
||||||
|
wan-pwa/
|
||||||
|
├── apps/
|
||||||
|
│ ├── web/
|
||||||
|
│ └── api/
|
||||||
|
├── packages/
|
||||||
|
│ ├── db/
|
||||||
|
│ └── types/
|
||||||
|
├── README.md
|
||||||
|
├── SETUP.md
|
||||||
|
├── package.json
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
If you see the file structure, **you're connected!** 🎉
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using MCP with Your Project
|
||||||
|
|
||||||
|
### Common Tasks
|
||||||
|
|
||||||
|
#### View a File
|
||||||
|
```
|
||||||
|
Show me the content of apps/web/src/app/page.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Edit a File
|
||||||
|
```
|
||||||
|
Update the Button component in apps/web/src/components/ui/button.tsx to add a loading state
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Create New Files
|
||||||
|
```
|
||||||
|
Create a new API endpoint for video analytics in apps/api/routes/analytics.py
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Run Commands
|
||||||
|
```
|
||||||
|
Run npm install in the web app directory
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Database Migrations
|
||||||
|
```
|
||||||
|
Create a new migration to add a 'favorites' table
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example Workflow
|
||||||
|
|
||||||
|
1. **Ask for Code Review**
|
||||||
|
```
|
||||||
|
Review the generation.py file and suggest improvements
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Request New Features**
|
||||||
|
```
|
||||||
|
Add a retry button to failed generations in the history page
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Debug Issues**
|
||||||
|
```
|
||||||
|
Why is the credit deduction not working? Check the database functions
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Refactor Code**
|
||||||
|
```
|
||||||
|
Extract the image upload logic into a reusable hook
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternative: Claude Code CLI
|
||||||
|
|
||||||
|
For terminal-based workflows:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install CLI
|
||||||
|
npm install -g claude-code
|
||||||
|
|
||||||
|
# Navigate to project
|
||||||
|
cd /path/to/Wan2.1/wan-pwa
|
||||||
|
|
||||||
|
# Start session
|
||||||
|
claude-code
|
||||||
|
|
||||||
|
# In the CLI
|
||||||
|
> connect /path/to/Wan2.1/wan-pwa
|
||||||
|
> list files
|
||||||
|
> edit apps/web/src/app/page.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Issue: "MCP server not found"
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Verify `@modelcontextprotocol/sdk` is installed:
|
||||||
|
```bash
|
||||||
|
npm list -g @modelcontextprotocol/sdk
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Check your config path is absolute (not relative):
|
||||||
|
```bash
|
||||||
|
# ✅ Correct
|
||||||
|
"/Users/john/projects/Wan2.1/wan-pwa"
|
||||||
|
|
||||||
|
# ❌ Wrong
|
||||||
|
"~/projects/Wan2.1/wan-pwa"
|
||||||
|
"./wan-pwa"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Restart Claude Desktop completely
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: "Permission denied"
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Check directory permissions:
|
||||||
|
```bash
|
||||||
|
ls -la /path/to/Wan2.1/wan-pwa
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Ensure you have read/write access:
|
||||||
|
```bash
|
||||||
|
chmod -R u+rw /path/to/Wan2.1/wan-pwa
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Don't run Claude Desktop with `sudo`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: "Files not updating"
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. MCP servers don't auto-reload on file changes
|
||||||
|
2. Ask Claude to "refresh" or "reload" the file
|
||||||
|
3. Restart the MCP server:
|
||||||
|
- Quit Claude Desktop
|
||||||
|
- Reopen and start new conversation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: "command not found: npx"
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Install Node.js 18+ from [nodejs.org](https://nodejs.org)
|
||||||
|
2. Verify installation:
|
||||||
|
```bash
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
npx --version
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Restart terminal and Claude Desktop
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: Windows Path with Spaces
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
Use double backslashes and quotes:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"wan-pwa": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-filesystem",
|
||||||
|
"C:\\Users\\John Smith\\projects\\Wan2.1\\wan-pwa"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
### ✅ DO:
|
||||||
|
- Only grant access to your project directory
|
||||||
|
- Use `.gitignore` for secrets and credentials
|
||||||
|
- Keep `.env` files out of version control
|
||||||
|
- Review MCP config before saving
|
||||||
|
|
||||||
|
### ❌ DON'T:
|
||||||
|
- Grant access to root directory (`/` or `C:\`)
|
||||||
|
- Share your Claude Desktop config publicly
|
||||||
|
- Commit API keys or secrets to git
|
||||||
|
- Run with elevated permissions unnecessarily
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Advanced Configuration
|
||||||
|
|
||||||
|
### Multiple Projects
|
||||||
|
|
||||||
|
You can configure multiple MCP servers:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"wan-pwa": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/wan-pwa"]
|
||||||
|
},
|
||||||
|
"other-project": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/other-project"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Read-Only Access
|
||||||
|
|
||||||
|
For code review without edit permissions:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"wan-pwa-readonly": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-filesystem",
|
||||||
|
"/path/to/wan-pwa",
|
||||||
|
"--readonly"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comparison: MCP vs Current Session
|
||||||
|
|
||||||
|
| Feature | Current Session | With MCP Server |
|
||||||
|
|---------|----------------|-----------------|
|
||||||
|
| File Access | Temporary | Persistent |
|
||||||
|
| Edits | Need manual copy | Direct to files |
|
||||||
|
| Git Integration | Manual commands | Direct access |
|
||||||
|
| Multiple Projects | One at a time | Multiple servers |
|
||||||
|
| Setup Time | None | 5 minutes |
|
||||||
|
| Best For | Quick tasks | Deep development |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. ✅ Complete MCP setup using steps above
|
||||||
|
2. 🧪 Test connection with simple file operations
|
||||||
|
3. 🚀 Start using Claude for development tasks
|
||||||
|
4. 📚 Explore [MCP Documentation](https://modelcontextprotocol.io/docs) for advanced features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- **MCP Protocol Documentation**: https://modelcontextprotocol.io/docs
|
||||||
|
- **Claude Desktop Download**: https://claude.ai/download
|
||||||
|
- **Filesystem Server**: https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem
|
||||||
|
- **Claude Code Guide**: https://docs.claude.com/en/docs/claude-code
|
||||||
|
- **Wan2.1 PWA Docs**: See README.md, SETUP.md, DEPLOYMENT.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
### Getting Help
|
||||||
|
|
||||||
|
1. Check this documentation
|
||||||
|
2. Review troubleshooting section
|
||||||
|
3. Check [MCP GitHub Issues](https://github.com/modelcontextprotocol/typescript-sdk/issues)
|
||||||
|
4. Ask in Claude Desktop (once connected!)
|
||||||
|
|
||||||
|
### Common Questions
|
||||||
|
|
||||||
|
**Q: Do I need MCP for development?**
|
||||||
|
A: No, but it significantly improves the workflow for active development.
|
||||||
|
|
||||||
|
**Q: Does MCP work with claude.ai (web)?**
|
||||||
|
A: No, MCP is currently desktop-only. Use file uploads for web.
|
||||||
|
|
||||||
|
**Q: Can I use MCP with VS Code?**
|
||||||
|
A: MCP is for Claude Desktop. For VS Code, use the Claude Code extension.
|
||||||
|
|
||||||
|
**Q: Is my code sent to Anthropic?**
|
||||||
|
A: Only the files you discuss in conversations. MCP runs locally.
|
||||||
|
|
||||||
|
**Q: Can multiple people share an MCP config?**
|
||||||
|
A: Yes, but each person needs their own absolute path configured.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Setup Complete?** Start building with Phase 4 features! 🎉
|
||||||
483
wan-pwa/PHASE_3_IMPLEMENTATION.md
Normal file
483
wan-pwa/PHASE_3_IMPLEMENTATION.md
Normal file
@ -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<File | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ImageUpload
|
||||||
|
onImageSelect={(file) => 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
|
||||||
@ -4,7 +4,7 @@ from fastapi.responses import JSONResponse
|
|||||||
import os
|
import os
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from routes import generation, auth, users
|
from routes import generation, auth, users, webhooks
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
@ -29,6 +29,7 @@ app.add_middleware(
|
|||||||
app.include_router(generation.router, prefix="/api/generation", tags=["generation"])
|
app.include_router(generation.router, prefix="/api/generation", tags=["generation"])
|
||||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||||
app.include_router(users.router, prefix="/api/users", tags=["users"])
|
app.include_router(users.router, prefix="/api/users", tags=["users"])
|
||||||
|
app.include_router(webhooks.router, prefix="/api/webhooks", tags=["webhooks"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@ -34,21 +34,46 @@ async def generate_text_to_video(
|
|||||||
request: TextToVideoRequest, user_id: str = Depends(get_user_id)
|
request: TextToVideoRequest, user_id: str = Depends(get_user_id)
|
||||||
):
|
):
|
||||||
"""Generate video from text prompt"""
|
"""Generate video from text prompt"""
|
||||||
|
supabase = get_supabase()
|
||||||
|
|
||||||
# Calculate credit cost
|
# Calculate credit cost
|
||||||
cost = CreditService.calculate_cost(request.model, request.resolution)
|
cost = CreditService.calculate_cost(request.model, request.resolution)
|
||||||
|
|
||||||
# Check and deduct credits
|
# Check if user has sufficient credits
|
||||||
has_credits = await CreditService.deduct_credits(
|
credits_result = await CreditService.get_user_credits(user_id)
|
||||||
user_id, cost, f"T2V generation: {request.model} @ {request.resolution}"
|
if credits_result < cost:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"Insufficient credits. You need {cost} credits but have {credits_result}.",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not has_credits:
|
# Create generation record BEFORE calling Replicate
|
||||||
raise HTTPException(status_code=402, detail="Insufficient credits")
|
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 generation_record.data:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create generation record")
|
||||||
|
|
||||||
|
generation_id = generation_record.data[0]["id"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Start generation via Replicate
|
# Start generation via Replicate
|
||||||
prediction_id = await ReplicateService.generate_text_to_video(
|
job_id = await ReplicateService.generate_text_to_video(
|
||||||
prompt=request.prompt,
|
prompt=request.prompt,
|
||||||
negative_prompt=request.negative_prompt,
|
negative_prompt=request.negative_prompt,
|
||||||
model=request.model,
|
model=request.model,
|
||||||
@ -57,36 +82,43 @@ async def generate_text_to_video(
|
|||||||
seed=request.seed,
|
seed=request.seed,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store generation record in database
|
# Update generation with job_id and status
|
||||||
supabase = get_supabase()
|
supabase.table("generations").update(
|
||||||
generation = (
|
{"job_id": job_id, "status": "processing", "progress": 10}
|
||||||
supabase.table("generations")
|
).eq("id", generation_id).execute()
|
||||||
.insert(
|
|
||||||
{
|
# Deduct credits using database function
|
||||||
"id": prediction_id,
|
try:
|
||||||
"user_id": user_id,
|
supabase.rpc(
|
||||||
"type": "text-to-video",
|
"deduct_credits", {"p_user_id": user_id, "p_amount": cost, "p_gen_id": generation_id}
|
||||||
"prompt": request.prompt,
|
).execute()
|
||||||
"model": request.model,
|
except Exception as credit_error:
|
||||||
"resolution": request.resolution,
|
# Rollback: delete generation record
|
||||||
"status": "pending",
|
supabase.table("generations").delete().eq("id", generation_id).execute()
|
||||||
"credits_used": cost,
|
raise HTTPException(status_code=402, detail="Failed to deduct credits")
|
||||||
}
|
|
||||||
)
|
|
||||||
.execute()
|
|
||||||
)
|
|
||||||
|
|
||||||
return GenerationResponse(
|
return GenerationResponse(
|
||||||
id=prediction_id,
|
id=generation_id,
|
||||||
status="pending",
|
status="processing",
|
||||||
created_at=datetime.utcnow(),
|
created_at=datetime.utcnow(),
|
||||||
credits_used=cost,
|
credits_used=cost,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Refund credits on error
|
# Mark generation as failed
|
||||||
await CreditService.add_credits(user_id, cost, "Refund: Generation failed")
|
supabase.table("generations").update(
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
{"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)
|
@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)
|
request: ImageToVideoRequest, user_id: str = Depends(get_user_id)
|
||||||
):
|
):
|
||||||
"""Generate video from image"""
|
"""Generate video from image"""
|
||||||
|
supabase = get_supabase()
|
||||||
|
|
||||||
# Calculate credit cost
|
# Calculate credit cost
|
||||||
cost = CreditService.calculate_cost(request.model, request.resolution)
|
cost = CreditService.calculate_cost(request.model, request.resolution)
|
||||||
|
|
||||||
# Check and deduct credits
|
# Check if user has sufficient credits
|
||||||
has_credits = await CreditService.deduct_credits(
|
credits_result = await CreditService.get_user_credits(user_id)
|
||||||
user_id, cost, f"I2V generation: {request.model} @ {request.resolution}"
|
if credits_result < cost:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"Insufficient credits. You need {cost} credits but have {credits_result}.",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not has_credits:
|
# Create generation record BEFORE calling Replicate
|
||||||
raise HTTPException(status_code=402, detail="Insufficient credits")
|
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 generation_record.data:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create generation record")
|
||||||
|
|
||||||
|
generation_id = generation_record.data[0]["id"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Start generation via Replicate
|
# Start generation via Replicate
|
||||||
prediction_id = await ReplicateService.generate_image_to_video(
|
job_id = await ReplicateService.generate_image_to_video(
|
||||||
prompt=request.prompt,
|
prompt=request.prompt,
|
||||||
image_url=request.image_url,
|
image_url=request.image_url,
|
||||||
negative_prompt=request.negative_prompt,
|
negative_prompt=request.negative_prompt,
|
||||||
@ -117,33 +175,43 @@ async def generate_image_to_video(
|
|||||||
seed=request.seed,
|
seed=request.seed,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store generation record
|
# Update generation with job_id and status
|
||||||
supabase = get_supabase()
|
supabase.table("generations").update(
|
||||||
supabase.table("generations").insert(
|
{"job_id": job_id, "status": "processing", "progress": 10}
|
||||||
{
|
).eq("id", generation_id).execute()
|
||||||
"id": prediction_id,
|
|
||||||
"user_id": user_id,
|
# Deduct credits using database function
|
||||||
"type": "image-to-video",
|
try:
|
||||||
"prompt": request.prompt,
|
supabase.rpc(
|
||||||
"image_url": request.image_url,
|
"deduct_credits", {"p_user_id": user_id, "p_amount": cost, "p_gen_id": generation_id}
|
||||||
"model": request.model,
|
|
||||||
"resolution": request.resolution,
|
|
||||||
"status": "pending",
|
|
||||||
"credits_used": cost,
|
|
||||||
}
|
|
||||||
).execute()
|
).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(
|
return GenerationResponse(
|
||||||
id=prediction_id,
|
id=generation_id,
|
||||||
status="pending",
|
status="processing",
|
||||||
created_at=datetime.utcnow(),
|
created_at=datetime.utcnow(),
|
||||||
credits_used=cost,
|
credits_used=cost,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Refund credits on error
|
# Mark generation as failed
|
||||||
await CreditService.add_credits(user_id, cost, "Refund: Generation failed")
|
supabase.table("generations").update(
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
{"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)
|
@router.get("/status/{generation_id}", response_model=GenerationStatus)
|
||||||
@ -164,28 +232,91 @@ async def get_generation_status(generation_id: str, user_id: str = Depends(get_u
|
|||||||
if not generation.data:
|
if not generation.data:
|
||||||
raise HTTPException(status_code=404, detail="Generation not found")
|
raise HTTPException(status_code=404, detail="Generation not found")
|
||||||
|
|
||||||
# Get status from Replicate
|
gen_data = generation.data
|
||||||
status = await ReplicateService.get_prediction_status(generation_id)
|
|
||||||
|
# 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
replicate_status = await ReplicateService.get_prediction_status(job_id)
|
||||||
|
|
||||||
# Update database with latest status
|
# Update database with latest status
|
||||||
update_data = {"status": status["status"]}
|
update_data = {}
|
||||||
if status["output"]:
|
|
||||||
update_data["video_url"] = status["output"]
|
|
||||||
if status["error"]:
|
|
||||||
update_data["error"] = status["error"]
|
|
||||||
|
|
||||||
|
# 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()
|
supabase.table("generations").update(update_data).eq("id", generation_id).execute()
|
||||||
|
|
||||||
# Map status to progress percentage
|
|
||||||
progress_map = {"pending": 0, "processing": 50, "succeeded": 100, "failed": 0}
|
|
||||||
|
|
||||||
return GenerationStatus(
|
return GenerationStatus(
|
||||||
id=generation_id,
|
id=generation_id,
|
||||||
status=status["status"],
|
status=new_status,
|
||||||
progress=progress_map.get(status["status"], 0),
|
progress=update_data.get("progress", 0),
|
||||||
video_url=status["output"],
|
video_url=update_data.get("video_url"),
|
||||||
error=status["error"],
|
error=update_data.get("error_message"),
|
||||||
logs=status["logs"],
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
124
wan-pwa/apps/api/routes/webhooks.py
Normal file
124
wan-pwa/apps/api/routes/webhooks.py
Normal file
@ -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"}
|
||||||
@ -26,9 +26,10 @@
|
|||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"next": "15.0.3",
|
"next": "15.0.3",
|
||||||
"next-pwa": "^5.6.0",
|
"next-pwa": "^5.6.0",
|
||||||
"react": "^19.0.0-rc.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^19.0.0-rc.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hook-form": "^7.53.2",
|
"react-hook-form": "^7.53.2",
|
||||||
|
"sonner": "^1.7.1",
|
||||||
"tailwind-merge": "^2.5.4",
|
"tailwind-merge": "^2.5.4",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
|
|||||||
44
wan-pwa/apps/web/src/app/dashboard/settings/page.tsx
Normal file
44
wan-pwa/apps/web/src/app/dashboard/settings/page.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Settings</h1>
|
||||||
|
<p className="text-muted-foreground">Manage your account and preferences</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Profile</CardTitle>
|
||||||
|
<CardDescription>Manage your profile information</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">Profile settings coming soon...</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardTitle className="p-6 pb-4">Billing & Credits</CardTitle>
|
||||||
|
<CardDescription className="px-6 pb-6">
|
||||||
|
Manage your credits and subscription
|
||||||
|
</CardDescription>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">Billing settings coming soon...</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>API Keys</CardTitle>
|
||||||
|
<CardDescription>Manage your API access</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">API key management coming soon...</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
import { Inter } from "next/font/google"
|
import { Inter } from "next/font/google"
|
||||||
|
import { Providers } from "@/components/providers"
|
||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] })
|
const inter = Inter({ subsets: ["latin"] })
|
||||||
@ -32,7 +33,9 @@ export default function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={inter.className}>{children}</body>
|
<body className={inter.className}>
|
||||||
|
<Providers>{children}</Providers>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
123
wan-pwa/apps/web/src/components/generation/image-upload.tsx
Normal file
123
wan-pwa/apps/web/src/components/generation/image-upload.tsx
Normal file
@ -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<string | null>(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<HTMLDivElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDragging(false)
|
||||||
|
|
||||||
|
const file = e.dataTransfer.files[0]
|
||||||
|
if (file) {
|
||||||
|
validateAndProcessFile(file)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[validateAndProcessFile]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleFileInput = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
validateAndProcessFile(file)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[validateAndProcessFile]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleRemove = () => {
|
||||||
|
setPreview(null)
|
||||||
|
onImageRemove()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview) {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<img src={preview} alt="Upload preview" className="w-full rounded-lg object-cover" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-2 top-2"
|
||||||
|
onClick={handleRemove}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileInput}
|
||||||
|
className="hidden"
|
||||||
|
id="image-upload"
|
||||||
|
/>
|
||||||
|
<label htmlFor="image-upload" className="cursor-pointer">
|
||||||
|
<Upload className="mx-auto mb-4 h-8 w-8 text-muted-foreground" />
|
||||||
|
<p className="font-medium">Drop an image here</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
or click to browse (PNG, JPG, WEBP • Max {maxSizeMB}MB)
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
wan-pwa/apps/web/src/components/providers.tsx
Normal file
12
wan-pwa/apps/web/src/components/providers.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Toaster } from "sonner"
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
<Toaster richColors position="top-right" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
wan-pwa/apps/web/src/lib/hooks/use-credits.ts
Normal file
58
wan-pwa/apps/web/src/lib/hooks/use-credits.ts
Normal file
@ -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<number | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(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,
|
||||||
|
}
|
||||||
|
}
|
||||||
51
wan-pwa/apps/web/src/lib/validation/generation.ts
Normal file
51
wan-pwa/apps/web/src/lib/validation/generation.ts
Normal file
@ -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<typeof textToVideoSchema>
|
||||||
|
export type ImageToVideoInput = z.infer<typeof imageToVideoSchema>
|
||||||
|
|
||||||
|
// Credit cost calculator
|
||||||
|
export function calculateCreditCost(model: string, resolution: string): number {
|
||||||
|
const costs: Record<string, number> = {
|
||||||
|
"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
|
||||||
|
}
|
||||||
13
wan-pwa/apps/web/vercel.json
Normal file
13
wan-pwa/apps/web/vercel.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||||
|
"buildCommand": "cd ../.. && npm install && npm run build --filter=@wan-pwa/web",
|
||||||
|
"framework": "nextjs",
|
||||||
|
"installCommand": "npm install",
|
||||||
|
"regions": ["iad1"],
|
||||||
|
"env": {
|
||||||
|
"NEXT_PUBLIC_SUPABASE_URL": "@supabase-url",
|
||||||
|
"NEXT_PUBLIC_SUPABASE_ANON_KEY": "@supabase-anon-key",
|
||||||
|
"NEXT_PUBLIC_API_URL": "@api-url",
|
||||||
|
"NEXT_PUBLIC_APP_URL": "@app-url"
|
||||||
|
}
|
||||||
|
}
|
||||||
113
wan-pwa/packages/db/migrations/002_credit_system.sql
Normal file
113
wan-pwa/packages/db/migrations/002_credit_system.sql
Normal file
@ -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 $$;
|
||||||
Loading…
Reference in New Issue
Block a user