Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/wan22
REDIS_URL=redis://localhost:6379/0
S3_ENDPOINT=https://nyc3.digitaloceanspaces.com
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_BUCKET=wan22-videos
WAN2_2_PATH=/workspace/Wan2.2
WAN2_2_CKPT_DIR=cache/TI2V-5B
28 changes: 27 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,30 @@ __pycache__/
tmp_examples*
new_checkpoint*
batch_test*
nohup*
nohup*

# Backend
*.pyc
__pycache__/
.env
*.egg-info/
dist/
build/

# Frontend
node_modules/
.next/
*.tsbuildinfo
next-env.d.ts

# IDE
.idea/
*.swp
*.swo

# Runtime
*.db
*.mp4
*.db-journal
backend/outputs/
backend/.env
7 changes: 7 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends gcc libpq-dev && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Empty file added backend/app/__init__.py
Empty file.
60 changes: 60 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import os, json
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()


class Settings:
PROJECT_NAME: str = "Wan2.2 API"
VERSION: str = "1.0.0"

# Environment: "local" | "production"
ENV: str = os.getenv("ENV", "local")

# Database — SQLite for local, PostgreSQL for production
DATABASE_URL: str = os.getenv(
"DATABASE_URL",
"sqlite:///./wan22.db" if os.getenv("ENV", "local") == "local"
else "postgresql://postgres:postgres@localhost:5432/wan22"
)

# Redis / Celery (optional in local mode)
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0")
CELERY_BROKER_URL: str = REDIS_URL
CELERY_RESULT_BACKEND: str = REDIS_URL

# S3 / DO Spaces
S3_ENDPOINT: str = os.getenv("S3_ENDPOINT", "https://nyc3.digitaloceanspaces.com")
S3_ACCESS_KEY: str = os.getenv("S3_ACCESS_KEY", "")
S3_SECRET_KEY: str = os.getenv("S3_SECRET_KEY", "")
S3_BUCKET: str = os.getenv("S3_BUCKET", "wan22-videos")

# Wan2.2 paths
WAN2_2_PATH: str = os.getenv("WAN2_2_PATH", str(Path(__file__).parent.parent.parent / "wan2.2"))
WAN2_2_CKPT_DIR: str = os.getenv("WAN2_2_CKPT_DIR", "cache/TI2V-5B")
WAN2_2_DEFAULT_TASK: str = "ti2v-5B"
WAN2_2_DEFAULT_SIZE: str = "704*1280"

# Mock mode — simulate generation without GPU
MOCK_MODE: bool = os.getenv("MOCK_MODE", "true" if os.getenv("ENV", "local") == "local" else "false").lower() == "true"

# Server
HOST: str = os.getenv("HOST", "0.0.0.0")
PORT: int = int(os.getenv("PORT", "8000"))

# Output directory (local)
OUTPUT_DIR: str = os.getenv("OUTPUT_DIR", str(Path(__file__).parent.parent / "outputs"))

@property
def is_local(self) -> bool:
return self.ENV == "local"

@property
def is_mock(self) -> bool:
return self.MOCK_MODE or self.is_local


settings = Settings()

# Ensure output dir exists
Path(settings.OUTPUT_DIR).mkdir(parents=True, exist_ok=True)
22 changes: 22 additions & 0 deletions backend/app/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from app.config import settings

engine = create_engine(settings.DATABASE_URL, connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)


class Base(DeclarativeBase):
pass


def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()


def init_db():
Base.metadata.create_all(bind=engine)
29 changes: 29 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.routers import health, generate
from app.config import settings
from app.database import init_db

app = FastAPI(title=settings.PROJECT_NAME, version=settings.VERSION)

app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

app.include_router(health.router, tags=["health"])
app.include_router(generate.router, prefix="/api", tags=["generate"])


@app.on_event("startup")
async def startup():
# Initialize database tables
init_db()
print(f" Mode: {'MOCK' if settings.is_mock else 'LIVE'}")
print(f" DB: {settings.DATABASE_URL}")
print(f" Worker: {'MockWorker' if settings.is_mock else 'Wan2_2Worker'}")
print(f" Output: {settings.OUTPUT_DIR}")
print(f" API: http://localhost:{settings.PORT}")
Empty file added backend/app/models/__init__.py
Empty file.
22 changes: 22 additions & 0 deletions backend/app/models/job.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import uuid, datetime
from sqlalchemy import Column, String, Float, DateTime, Text, Integer
from app.database import Base


class Job(Base):
__tablename__ = "jobs"

id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
status = Column(String, default="queued") # queued | processing | completed | failed
model = Column(String, default="ti2v-5b")
prompt = Column(Text)
image_path = Column(String, nullable=True)
steps = Column(Integer, default=30)
guidance_scale = Column(Float, default=6.0)
seed = Column(Integer, nullable=True)
size = Column(String, default="704*1280")
output_path = Column(String, nullable=True)
progress = Column(Float, default=0.0)
error = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
completed_at = Column(DateTime, nullable=True)
Empty file added backend/app/routers/__init__.py
Empty file.
148 changes: 148 additions & 0 deletions backend/app/routers/generate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import uuid, threading, os
from fastapi import APIRouter, HTTPException, Depends
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.job import Job
from app.schemas.job import GenerateRequest, JobResponse, JobStatus
from app.config import settings
from app.services.wan_worker import Wan2_2Worker
from app.services.mock_worker import MockWorker
from pathlib import Path

router = APIRouter()

# Initialize worker based on environment
if settings.is_mock or not Path(settings.WAN2_2_PATH).exists():
worker = MockWorker()
else:
worker = Wan2_2Worker(wan_path=settings.WAN2_2_PATH, ckpt_dir=settings.WAN2_2_CKPT_DIR)


def run_in_background(job_id: str, params: dict, db_session_factory):
"""Run generation in a background thread"""
db = db_session_factory()
try:
job = db.query(Job).filter(Job.id == job_id).first()
if not job:
return

job.status = "processing"
db.commit()

def update_progress(progress: float):
job.progress = progress
db.commit()

image_path = None
if params.get("image"):
import base64
image_dir = Path(settings.OUTPUT_DIR) / "inputs"
image_dir.mkdir(parents=True, exist_ok=True)
image_path = str(image_dir / f"{job_id}_input.png")
try:
img_data = base64.b64decode(params["image"])
with open(image_path, "wb") as f:
f.write(img_data)
except Exception:
image_path = params["image"]

output_filename = f"{job_id}.mp4"
output_path = str(Path(settings.OUTPUT_DIR) / output_filename)

out = worker.generate(
prompt=params["prompt"],
image_path=image_path,
task=params.get("model", "ti2v-5B"),
size=params.get("size", "704*1280"),
steps=params.get("steps", 30),
guidance=params.get("guidance_scale", 6.0),
seed=params.get("seed"),
output_path=output_path,
progress_callback=update_progress if hasattr(worker, "generate") and isinstance(worker, MockWorker) else None,
)

job.status = "completed"
job.progress = 1.0
job.output_path = out
from datetime import datetime
job.completed_at = datetime.utcnow()
db.commit()

except Exception as e:
job.status = "failed"
job.error = str(e)
db.commit()
finally:
db.close()


@router.post("/generate", response_model=JobResponse)
async def create_job(req: GenerateRequest, db: Session = Depends(get_db)):
job = Job(
model=req.model,
prompt=req.prompt,
image_path=req.image,
steps=req.steps,
guidance_scale=req.guidance_scale,
seed=req.seed,
size=req.size,
status="queued",
)
db.add(job)
db.commit()
db.refresh(job)

# Run in background thread
from app.database import SessionLocal
thread = threading.Thread(
target=run_in_background,
args=(job.id, req.model_dump(), SessionLocal),
daemon=True,
)
thread.start()

return JobResponse(job_id=job.id, status="queued", estimated_seconds=540)


@router.get("/status/{job_id}", response_model=JobStatus)
async def get_job_status(job_id: str, db: Session = Depends(get_db)):
job = db.query(Job).filter(Job.id == job_id).first()
if not job:
raise HTTPException(404, "Job not found")
return JobStatus(
job_id=job.id,
status=job.status,
progress=job.progress,
output_url=f"/api/download/{job.id}" if job.status == "completed" else None,
error=job.error,
created_at=job.created_at,
completed_at=job.completed_at,
)


@router.get("/gallery")
async def list_jobs(db: Session = Depends(get_db)):
jobs = db.query(Job).order_by(Job.created_at.desc()).limit(50).all()
return [
{
"job_id": j.id,
"status": j.status,
"prompt": j.prompt[:100] if j.prompt else "",
"output_url": f"/api/download/{j.id}" if j.status == "completed" else None,
"created_at": j.created_at.isoformat() if j.created_at else None,
}
for j in jobs
]


@router.get("/download/{job_id}")
async def download_video(job_id: str, db: Session = Depends(get_db)):
job = db.query(Job).filter(Job.id == job_id).first()
if not job:
raise HTTPException(404, "Job not found")
if job.status != "completed":
raise HTTPException(400, "Job not completed yet")
if not job.output_path or not Path(job.output_path).exists():
raise HTTPException(404, "Output file not found")
return FileResponse(job.output_path, media_type="video/mp4", filename=f"{job_id}.mp4")
6 changes: 6 additions & 0 deletions backend/app/routers/health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from fastapi import APIRouter
router = APIRouter()

@router.get("/health")
async def health():
return {"status": "ok", "version": "1.0.0"}
Empty file added backend/app/schemas/__init__.py
Empty file.
29 changes: 29 additions & 0 deletions backend/app/schemas/job.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel


class GenerateRequest(BaseModel):
model: str = "ti2v-5b"
prompt: str
image: Optional[str] = None
steps: int = 30
guidance_scale: float = 6.0
seed: Optional[int] = None
size: str = "704*1280"


class JobResponse(BaseModel):
job_id: str
status: str
estimated_seconds: int = 540


class JobStatus(BaseModel):
job_id: str
status: str
progress: float = 0.0
output_url: Optional[str] = None
error: Optional[str] = None
created_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
Empty file.
Loading