From 800edc08ea7ea4176bab653ba1f5d1aea19d7927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Mon, 16 Feb 2026 12:58:33 +0100 Subject: [PATCH] Initial commit: FaceFusion REST API FastAPI wrapper around FaceFusion v3.5.3 submodule with: - Sync and async (job-based) processing endpoints - FaceFusion bridge with manual key registration and Lock-serialized processing - Multi-target Dockerfile (CPU + CUDA GPU) - Docker Compose configs for dev, prod-cpu, and prod-gpu - Gitea CI/CD workflow with dual image builds - All 11 FaceFusion processors supported via options API Co-Authored-By: Claude Opus 4.6 --- .dockerignore | 16 + .gitea/workflows/docker-build-push.yml | 141 ++++++++ .gitignore | 32 ++ .gitmodules | 3 + CLAUDE.md | 95 +++++ Dockerfile | 55 +++ README.md | 115 +++++++ app/__init__.py | 0 app/config.py | 38 ++ app/main.py | 50 +++ app/routers/__init__.py | 0 app/routers/jobs.py | 105 ++++++ app/routers/process.py | 70 ++++ app/routers/processors.py | 47 +++ app/routers/system.py | 56 +++ app/schemas/__init__.py | 0 app/schemas/jobs.py | 31 ++ app/schemas/process.py | 114 ++++++ app/schemas/system.py | 33 ++ app/services/__init__.py | 0 app/services/facefusion_bridge.py | 459 +++++++++++++++++++++++++ app/services/file_manager.py | 75 ++++ app/services/worker.py | 115 +++++++ docker-compose.gpu.yml | 31 ++ docker-compose.prod.yml | 24 ++ docker-compose.yml | 20 ++ facefusion | 1 + facefusion.ini | 42 +++ requirements-cpu.txt | 2 + requirements-gpu.txt | 2 + requirements.txt | 12 + 31 files changed, 1784 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/docker-build-push.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/main.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/jobs.py create mode 100644 app/routers/process.py create mode 100644 app/routers/processors.py create mode 100644 app/routers/system.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/jobs.py create mode 100644 app/schemas/process.py create mode 100644 app/schemas/system.py create mode 100644 app/services/__init__.py create mode 100644 app/services/facefusion_bridge.py create mode 100644 app/services/file_manager.py create mode 100644 app/services/worker.py create mode 100644 docker-compose.gpu.yml create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 160000 facefusion create mode 100644 facefusion.ini create mode 100644 requirements-cpu.txt create mode 100644 requirements-gpu.txt create mode 100644 requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..051a669 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.gitmodules +facefusion/.git +data/ +*.md +!requirements*.txt +.env +.env.* +.venv/ +venv/ +__pycache__/ +*.py[cod] +.idea/ +.vscode/ +.gitea/ +docker-compose*.yml diff --git a/.gitea/workflows/docker-build-push.yml b/.gitea/workflows/docker-build-push.yml new file mode 100644 index 0000000..cb2b0f7 --- /dev/null +++ b/.gitea/workflows/docker-build-push.yml @@ -0,0 +1,141 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - main + - develop + tags: + - 'v*.*.*' + pull_request: + branches: + - main + workflow_dispatch: + inputs: + tag: + description: 'Custom tag for the image' + required: false + default: 'manual' + +env: + REGISTRY: dev.pivoine.art + IMAGE_NAME: valknar/facefusion-api + +jobs: + build-gpu: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + platforms: linux/amd64 + + - name: Log in to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ gitea.actor }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Extract metadata (GPU) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha,prefix={{branch}}- + type=raw,value=${{ gitea.event.inputs.tag }},enable=${{ gitea.event_name == 'workflow_dispatch' }} + labels: | + org.opencontainers.image.title=facefusion-api + org.opencontainers.image.description=REST API for FaceFusion face processing (GPU) + org.opencontainers.image.vendor=valknar + + - name: Build and push GPU image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64 + push: ${{ gitea.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: VARIANT=gpu + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-gpu + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-gpu,mode=max + + - name: Summary + if: gitea.event_name != 'pull_request' + run: | + echo "### GPU Image Published" >> $GITEA_STEP_SUMMARY + echo "**Tags:**" >> $GITEA_STEP_SUMMARY + echo "\`\`\`" >> $GITEA_STEP_SUMMARY + echo "${{ steps.meta.outputs.tags }}" >> $GITEA_STEP_SUMMARY + echo "\`\`\`" >> $GITEA_STEP_SUMMARY + + build-cpu: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + platforms: linux/amd64 + + - name: Log in to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ gitea.actor }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Extract metadata (CPU) + id: meta-cpu + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + flavor: suffix=-cpu + tags: | + type=raw,value=latest-cpu,enable={{is_default_branch}} + type=ref,event=branch,suffix=-cpu + type=ref,event=pr,suffix=-cpu + type=semver,pattern={{version}},suffix=-cpu + type=sha,prefix={{branch}}-,suffix=-cpu + labels: | + org.opencontainers.image.title=facefusion-api + org.opencontainers.image.description=REST API for FaceFusion face processing (CPU) + org.opencontainers.image.vendor=valknar + + - name: Build and push CPU image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64 + push: ${{ gitea.event_name != 'pull_request' }} + tags: ${{ steps.meta-cpu.outputs.tags }} + labels: ${{ steps.meta-cpu.outputs.labels }} + build-args: VARIANT=cpu + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-cpu + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-cpu,mode=max + + - name: Summary + if: gitea.event_name != 'pull_request' + run: | + echo "### CPU Image Published" >> $GITEA_STEP_SUMMARY + echo "**Tags:**" >> $GITEA_STEP_SUMMARY + echo "\`\`\`" >> $GITEA_STEP_SUMMARY + echo "${{ steps.meta-cpu.outputs.tags }}" >> $GITEA_STEP_SUMMARY + echo "\`\`\`" >> $GITEA_STEP_SUMMARY diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b8c60ce --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +*.egg +dist/ +build/ +.eggs/ + +.env +.env.* +!.env.example + +*.onnx +*.pth + +data/ +*.log + +.venv/ +venv/ + +.claude/ + +.idea/ +.vscode/ +*.swp +*.swo +*~ + +.DS_Store +Thumbs.db diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..43e2421 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "facefusion"] + path = facefusion + url = https://github.com/facefusion/facefusion.git diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1edf5d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,95 @@ +# CLAUDE.md + +## Overview + +FaceFusion API - A Python REST API wrapping FaceFusion v3.5.3 for face swapping, enhancement, lip sync, and other face/frame processing. Containerized with CUDA GPU support, deployed via Gitea CI/CD at `dev.pivoine.art`. + +## Architecture + +- **FastAPI** async web framework with single uvicorn worker (mandatory due to FaceFusion's global state) +- **FaceFusion** included as git submodule at `facefusion/` pinned to tag 3.5.3 +- All FaceFusion processing serialized through `threading.Lock` in `facefusion_bridge.py` +- Background job queue in `worker.py` for async processing + +### Key Integration Points + +FaceFusion uses global mutable state (`state_manager`) and module-level ONNX inference pools. The bridge (`app/services/facefusion_bridge.py`) replicates the initialization side-effects of `program.py`: +1. Registers all job_keys and step_keys in `job_store` +2. Initializes state defaults via `state_manager.init_item()` +3. Initializes job filesystem via `job_manager.init_jobs()` +4. Symlinks `facefusion/.assets/models/` -> `/data/models/` for model persistence + +### Project Structure + +``` +app/ + main.py # FastAPI app, lifespan, sys.path setup + config.py # Pydantic BaseSettings (FF_ env prefix) + routers/ # API endpoint handlers + schemas/ # Pydantic request/response models + services/ + facefusion_bridge.py # Core FaceFusion integration + worker.py # Background job queue + file_manager.py # File upload/output handling +``` + +## Common Commands + +```bash +# Development +docker compose build +docker compose up + +# Production (CPU VPS) +docker compose -f docker-compose.prod.yml up -d + +# Production (GPU server) +docker compose -f docker-compose.gpu.yml up -d + +# Test endpoints +curl http://localhost:8000/api/v1/health +curl http://localhost:8000/api/v1/system +curl -X POST http://localhost:8000/api/v1/models/download -H 'Content-Type: application/json' -d '["face_swapper"]' + +# Sync face swap +curl -X POST http://localhost:8000/api/v1/process \ + -F source=@source.jpg \ + -F target=@target.jpg \ + -o result.jpg +``` + +## API Endpoints + +- `POST /api/v1/process` - Synchronous processing (returns file) +- `POST /api/v1/jobs` - Create async job +- `GET /api/v1/jobs/{id}` - Job status +- `GET /api/v1/jobs/{id}/result` - Download result +- `DELETE /api/v1/jobs/{id}` - Cancel/delete job +- `GET /api/v1/processors` - List processors +- `GET /api/v1/models` - List downloaded models +- `POST /api/v1/models/download` - Download models +- `GET /api/v1/health` - Health check +- `GET /api/v1/system` - System info (GPU, memory) + +## Docker + +- `VARIANT=cpu` (default): `python:3.12-slim` + `onnxruntime` +- `VARIANT=gpu`: `nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04` + `onnxruntime-gpu` +- Models persisted in `/data/models` Docker volume (not baked into image) +- Single worker mandatory (`--workers 1`) + +## Environment Variables + +All prefixed with `FF_`: +- `FF_EXECUTION_PROVIDERS` - JSON array, e.g. `["cuda","cpu"]` +- `FF_EXECUTION_THREAD_COUNT` - Default 4 +- `FF_VIDEO_MEMORY_STRATEGY` - strict/moderate/tolerant +- `FF_MODELS_DIR` - Model storage path +- `FF_MAX_UPLOAD_SIZE_MB` - Upload limit (default 500) + +## Important Notes + +- Never run with multiple uvicorn workers - FaceFusion global state will corrupt +- Models are 100MB-1GB each; pre-download via `/api/v1/models/download` before processing +- The `facefusion/` submodule must not be modified - use symlinks for model paths +- Git operations: always push with the valknarthing ssh key diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cf90a91 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,55 @@ +ARG VARIANT=cpu + +# ---- CPU base ---- +FROM python:3.12-slim AS base-cpu + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg curl libgl1-mesa-glx libglib2.0-0 \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements-cpu.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt && rm /tmp/requirements.txt + +# ---- GPU base ---- +FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04 AS base-gpu + +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 python3-pip python3-venv \ + ffmpeg curl libgl1-mesa-glx libglib2.0-0 \ + && ln -sf /usr/bin/python3 /usr/bin/python \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements-gpu.txt /tmp/requirements.txt +RUN pip install --no-cache-dir --break-system-packages -r /tmp/requirements.txt && rm /tmp/requirements.txt + +# ---- Final stage ---- +FROM base-${VARIANT} AS final + +# CWD must be the FaceFusion submodule root so resolve_file_paths('facefusion/...') +# resolves correctly (FaceFusion uses relative paths from CWD internally) +WORKDIR /app/facefusion-src + +# Copy the FaceFusion submodule (preserving its directory structure) +COPY facefusion/ /app/facefusion-src/ + +# Copy API application code and config +COPY app/ /app/app/ +COPY facefusion.ini /app/facefusion-src/facefusion.ini + +RUN mkdir -p /data/uploads /data/outputs /data/models /data/temp /data/jobs + +# Symlink models directory so FaceFusion's resolve_relative_path('../.assets/models/...') +# resolves to the persistent volume (relative to facefusion/filesystem.py) +RUN mkdir -p /app/facefusion-src/.assets && \ + ln -sf /data/models /app/facefusion-src/.assets/models + +VOLUME ["/data/uploads", "/data/outputs", "/data/models", "/data/temp", "/data/jobs"] + +ENV PYTHONPATH=/app + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:8000/api/v1/health || exit 1 + +CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2695ff6 --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# FaceFusion API + +REST API wrapping [FaceFusion](https://github.com/facefusion/facefusion) v3.5.3 for face swapping, enhancement, lip sync, and other face/frame processing. Containerized with CPU and CUDA GPU support. + +## Features + +- Synchronous and asynchronous (job-based) processing +- Face swapping, enhancement, editing, lip sync, age modification, expression restoration, frame enhancement/colorization, background removal +- Multi-target Docker builds (CPU + CUDA GPU) +- Model persistence via Docker volumes +- Gitea CI/CD with dual image publishing + +## Quick Start + +```bash +# Build and run (CPU) +docker compose build +docker compose up -d + +# Download models +curl -X POST http://localhost:8000/api/v1/models/download \ + -H 'Content-Type: application/json' \ + -d '["face_swapper"]' + +# Face swap (synchronous) +curl -X POST http://localhost:8000/api/v1/process \ + -F source=@source.jpg \ + -F target=@target.jpg \ + -o result.jpg + +# Face swap (async job) +curl -X POST http://localhost:8000/api/v1/jobs \ + -F source=@source.jpg \ + -F target=@target.jpg + +# Check job status +curl http://localhost:8000/api/v1/jobs/{job_id} + +# Download result +curl http://localhost:8000/api/v1/jobs/{job_id}/result -o result.jpg +``` + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/v1/process` | Synchronous processing (returns file) | +| `POST` | `/api/v1/jobs` | Create async job | +| `GET` | `/api/v1/jobs/{id}` | Job status | +| `GET` | `/api/v1/jobs/{id}/result` | Download result | +| `DELETE` | `/api/v1/jobs/{id}` | Cancel/delete job | +| `GET` | `/api/v1/processors` | List available processors | +| `GET` | `/api/v1/models` | List downloaded models | +| `POST` | `/api/v1/models/download` | Trigger model download | +| `GET` | `/api/v1/health` | Health check | +| `GET` | `/api/v1/system` | System info (GPU, memory) | + +## Processing Options + +Pass options as a JSON string in the `options` form field: + +```json +{ + "processors": ["face_swapper", "face_enhancer"], + "face_swapper": { + "model": "hyperswap_1a_256", + "weight": 0.5 + }, + "face_enhancer": { + "model": "gfpgan_1.4", + "blend": 80 + }, + "output": { + "image_quality": 90 + } +} +``` + +Available processors: `face_swapper`, `face_enhancer`, `face_editor`, `lip_syncer`, `age_modifier`, `expression_restorer`, `frame_enhancer`, `frame_colorizer`, `background_remover`, `deep_swapper`, `face_debugger`. + +## Deployment + +### CPU (VPS) + +```bash +docker compose -f docker-compose.prod.yml up -d +``` + +### GPU (NVIDIA) + +```bash +docker compose -f docker-compose.gpu.yml up -d +``` + +## Configuration + +Environment variables (prefix `FF_`): + +| Variable | Default | Description | +|----------|---------|-------------| +| `FF_EXECUTION_PROVIDERS` | `["cpu"]` | ONNX execution providers | +| `FF_EXECUTION_THREAD_COUNT` | `4` | Processing threads | +| `FF_VIDEO_MEMORY_STRATEGY` | `moderate` | `strict` / `moderate` / `tolerant` | +| `FF_MODELS_DIR` | `/data/models` | Model storage path | +| `FF_MAX_UPLOAD_SIZE_MB` | `500` | Upload size limit | +| `FF_SYNC_TIMEOUT_SECONDS` | `120` | Sync processing timeout | +| `FF_LOG_LEVEL` | `info` | Log level | + +## Architecture + +- **FastAPI** with single uvicorn worker (required - FaceFusion uses global mutable state) +- **FaceFusion** included as git submodule, pinned to v3.5.3 +- All processing serialized through `threading.Lock` +- Background job queue with daemon worker thread +- Models persisted in Docker volume, not baked into image diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..1168604 --- /dev/null +++ b/app/config.py @@ -0,0 +1,38 @@ +import json +from typing import List + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + model_config = {'env_prefix': 'FF_'} + + # Paths + upload_dir: str = '/data/uploads' + output_dir: str = '/data/outputs' + models_dir: str = '/data/models' + temp_dir: str = '/data/temp' + jobs_dir: str = '/data/jobs' + + # FaceFusion defaults + execution_providers: str = '["cpu"]' + execution_thread_count: int = 4 + video_memory_strategy: str = 'moderate' + face_detector_model: str = 'yolo_face' + download_providers: str = '["github", "huggingface"]' + download_scope: str = 'lite' + log_level: str = 'info' + + # Limits + max_upload_size_mb: int = 500 + sync_timeout_seconds: int = 120 + auto_cleanup_hours: int = 24 + + def get_execution_providers(self) -> List[str]: + return json.loads(self.execution_providers) + + def get_download_providers(self) -> List[str]: + return json.loads(self.download_providers) + + +settings = Settings() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..3ff1c97 --- /dev/null +++ b/app/main.py @@ -0,0 +1,50 @@ +import logging +import os +import sys +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +# Ensure FaceFusion submodule is importable (must happen before any facefusion imports) +_project_root = os.path.dirname(os.path.dirname(__file__)) +for _candidate in (os.path.join(_project_root, 'facefusion'), '/app/facefusion-src'): + if os.path.isdir(os.path.join(_candidate, 'facefusion')) and _candidate not in sys.path: + sys.path.insert(0, _candidate) + break + +from app.routers import jobs, process, processors, system +from app.services import facefusion_bridge, file_manager +from app.services.worker import worker_queue + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s %(levelname)s %(name)s: %(message)s', +) +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + logger.info('Starting FaceFusion API...') + file_manager.ensure_directories() + facefusion_bridge.initialize() + worker_queue.start(facefusion_bridge.process_sync) + logger.info('FaceFusion API ready') + yield + # Shutdown + logger.info('Shutting down...') + worker_queue.stop() + + +app = FastAPI( + title='FaceFusion API', + version='1.0.0', + description='REST API for FaceFusion face processing', + lifespan=lifespan, +) + +app.include_router(process.router) +app.include_router(jobs.router) +app.include_router(processors.router) +app.include_router(system.router) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/jobs.py b/app/routers/jobs.py new file mode 100644 index 0000000..3637f4e --- /dev/null +++ b/app/routers/jobs.py @@ -0,0 +1,105 @@ +import json +import logging +import uuid +from typing import List, Optional + +from fastapi import APIRouter, File, Form, HTTPException, UploadFile +from fastapi.responses import FileResponse + +from app.schemas.jobs import JobCreateResponse, JobDeleteResponse, JobStatus, JobStatusResponse +from app.schemas.process import ProcessingOptions +from app.services import facefusion_bridge, file_manager +from app.services.worker import worker_queue + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix='/api/v1/jobs', tags=['jobs']) + + +@router.post('', response_model=JobCreateResponse) +async def create_job( + target: UploadFile = File(...), + source: Optional[List[UploadFile]] = File(None), + options: Optional[str] = Form(None), +): + """Create an async processing job.""" + job_id = str(uuid.uuid4()) + request_dir = file_manager.create_request_dir() + + try: + parsed_options = None + if options: + try: + parsed_options = json.loads(options) + ProcessingOptions(**parsed_options) + except (json.JSONDecodeError, Exception) as e: + raise HTTPException(status_code=422, detail=f'Invalid options: {e}') + + target_path = await file_manager.save_upload(target, request_dir) + source_paths = [] + if source: + source_paths = await file_manager.save_uploads(source, request_dir) + + output_path = file_manager.generate_output_path(target_path) + + args = facefusion_bridge.build_args_from_options( + source_paths=source_paths, + target_path=target_path, + output_path=output_path, + options=parsed_options, + ) + + worker_queue.submit(job_id, args) + + return JobCreateResponse(job_id=job_id, status=JobStatus.pending) + except HTTPException: + raise + except Exception as e: + file_manager.cleanup_directory(request_dir) + logger.error(f'Job creation failed: {e}') + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get('/{job_id}', response_model=JobStatusResponse) +async def get_job_status(job_id: str): + """Get job status.""" + job = worker_queue.get_job(job_id) + if not job: + raise HTTPException(status_code=404, detail='Job not found') + + return JobStatusResponse( + job_id=job.job_id, + status=JobStatus(job.status.value), + created_at=job.created_at, + updated_at=job.updated_at, + error=job.error, + ) + + +@router.get('/{job_id}/result') +async def get_job_result(job_id: str): + """Download job result. Only available when job is completed.""" + job = worker_queue.get_job(job_id) + if not job: + raise HTTPException(status_code=404, detail='Job not found') + + if job.status != 'completed': + raise HTTPException(status_code=409, detail=f'Job status is {job.status}, not completed') + + if not job.output_path: + raise HTTPException(status_code=500, detail='No output file') + + return FileResponse( + path=job.output_path, + media_type='application/octet-stream', + ) + + +@router.delete('/{job_id}', response_model=JobDeleteResponse) +async def delete_job(job_id: str): + """Cancel/delete a job.""" + deleted = worker_queue.delete_job(job_id) + if not deleted: + raise HTTPException(status_code=404, detail='Job not found') + + return JobDeleteResponse(job_id=job_id, deleted=True) diff --git a/app/routers/process.py b/app/routers/process.py new file mode 100644 index 0000000..5212065 --- /dev/null +++ b/app/routers/process.py @@ -0,0 +1,70 @@ +import json +import logging +from time import time +from typing import List, Optional + +from fastapi import APIRouter, File, Form, HTTPException, UploadFile +from fastapi.responses import FileResponse + +from app.schemas.process import ProcessingOptions +from app.services import facefusion_bridge, file_manager + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix='/api/v1', tags=['processing']) + + +@router.post('/process') +async def process_sync( + target: UploadFile = File(...), + source: Optional[List[UploadFile]] = File(None), + options: Optional[str] = Form(None), +): + """Synchronous face processing. Returns the result file directly.""" + request_dir = file_manager.create_request_dir() + + try: + # Parse options + parsed_options = None + if options: + try: + parsed_options = json.loads(options) + ProcessingOptions(**parsed_options) # validate + except (json.JSONDecodeError, Exception) as e: + raise HTTPException(status_code=422, detail=f'Invalid options: {e}') + + # Save uploads + target_path = await file_manager.save_upload(target, request_dir) + source_paths = [] + if source: + source_paths = await file_manager.save_uploads(source, request_dir) + + output_path = file_manager.generate_output_path(target_path) + + # Build args and process + args = facefusion_bridge.build_args_from_options( + source_paths=source_paths, + target_path=target_path, + output_path=output_path, + options=parsed_options, + ) + + start_time = time() + facefusion_bridge.process_sync(args) + processing_time = time() - start_time + + logger.info(f'Sync processing completed in {processing_time:.2f}s') + + return FileResponse( + path=output_path, + media_type='application/octet-stream', + filename=target.filename, + headers={'X-Processing-Time': f'{processing_time:.2f}'}, + ) + except HTTPException: + raise + except Exception as e: + logger.error(f'Processing failed: {e}') + raise HTTPException(status_code=500, detail=str(e)) + finally: + file_manager.cleanup_directory(request_dir) diff --git a/app/routers/processors.py b/app/routers/processors.py new file mode 100644 index 0000000..3447ca2 --- /dev/null +++ b/app/routers/processors.py @@ -0,0 +1,47 @@ +import logging +from typing import List + +from fastapi import APIRouter, HTTPException + +from app.schemas.system import ModelInfo, ProcessorInfo +from app.services import facefusion_bridge, file_manager + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix='/api/v1', tags=['processors']) + + +@router.get('/processors', response_model=List[ProcessorInfo]) +async def list_processors(): + """List available processors and their models.""" + try: + processor_names = facefusion_bridge.get_available_processors() + result = [] + for name in processor_names: + result.append(ProcessorInfo(name=name, models=[])) + return result + except Exception as e: + logger.error(f'Failed to list processors: {e}') + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get('/models', response_model=List[ModelInfo]) +async def list_models(): + """List downloaded model files.""" + models = file_manager.list_model_files() + return [ModelInfo(name=name, path=path, size_bytes=size) for name, path, size in models] + + +@router.post('/models/download') +async def download_models(processors: List[str] = None): + """Trigger model download for specified processors.""" + try: + success = facefusion_bridge.force_download_models(processors) + if success: + return {'status': 'ok', 'message': 'Models downloaded successfully'} + raise HTTPException(status_code=500, detail='Some models failed to download') + except HTTPException: + raise + except Exception as e: + logger.error(f'Model download failed: {e}') + raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/routers/system.py b/app/routers/system.py new file mode 100644 index 0000000..071fed9 --- /dev/null +++ b/app/routers/system.py @@ -0,0 +1,56 @@ +import logging +import os + +import psutil + +from fastapi import APIRouter + +from app.schemas.system import GpuDevice, HealthResponse, SystemInfoResponse +from app.services import facefusion_bridge + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix='/api/v1', tags=['system']) + + +@router.get('/health', response_model=HealthResponse) +async def health_check(): + return HealthResponse() + + +@router.get('/system', response_model=SystemInfoResponse) +async def system_info(): + providers = facefusion_bridge.get_execution_providers() + gpu_devices = _detect_gpu_devices() + mem = psutil.virtual_memory() + + return SystemInfoResponse( + execution_providers=providers, + gpu_devices=gpu_devices, + cpu_count=os.cpu_count(), + memory_total=mem.total, + memory_available=mem.available, + ) + + +def _detect_gpu_devices(): + devices = [] + try: + import subprocess + result = subprocess.run( + ['nvidia-smi', '--query-gpu=index,name,memory.total,memory.used', '--format=csv,noheader,nounits'], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + for line in result.stdout.strip().split('\n'): + parts = [p.strip() for p in line.split(',')] + if len(parts) >= 4: + devices.append(GpuDevice( + id=int(parts[0]), + name=parts[1], + memory_total=int(float(parts[2])) * 1024 * 1024, + memory_used=int(float(parts[3])) * 1024 * 1024, + )) + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + return devices diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/jobs.py b/app/schemas/jobs.py new file mode 100644 index 0000000..fd7c90b --- /dev/null +++ b/app/schemas/jobs.py @@ -0,0 +1,31 @@ +from datetime import datetime +from enum import Enum +from typing import Optional + +from pydantic import BaseModel + + +class JobStatus(str, Enum): + pending = 'pending' + processing = 'processing' + completed = 'completed' + failed = 'failed' + cancelled = 'cancelled' + + +class JobCreateResponse(BaseModel): + job_id: str + status: JobStatus + + +class JobStatusResponse(BaseModel): + job_id: str + status: JobStatus + created_at: datetime + updated_at: Optional[datetime] = None + error: Optional[str] = None + + +class JobDeleteResponse(BaseModel): + job_id: str + deleted: bool diff --git a/app/schemas/process.py b/app/schemas/process.py new file mode 100644 index 0000000..7968f7a --- /dev/null +++ b/app/schemas/process.py @@ -0,0 +1,114 @@ +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class FaceSwapperOptions(BaseModel): + model: str = 'hyperswap_1a_256' + pixel_boost: Optional[str] = None + weight: float = 0.5 + + +class FaceEnhancerOptions(BaseModel): + model: str = 'gfpgan_1.4' + blend: int = 80 + weight: float = 0.5 + + +class FaceEditorOptions(BaseModel): + model: str = 'live_portrait' + eyebrow_direction: Optional[float] = None + eye_gaze_horizontal: Optional[float] = None + eye_gaze_vertical: Optional[float] = None + eye_open_ratio: Optional[float] = None + lip_open_ratio: Optional[float] = None + mouth_grim: Optional[float] = None + mouth_pout: Optional[float] = None + mouth_purse: Optional[float] = None + mouth_smile: Optional[float] = None + mouth_position_horizontal: Optional[float] = None + mouth_position_vertical: Optional[float] = None + head_pitch: Optional[float] = None + head_yaw: Optional[float] = None + head_roll: Optional[float] = None + + +class LipSyncerOptions(BaseModel): + model: str = 'wav2lip_96' + + +class AgeModifierOptions(BaseModel): + model: str = 'styleganex_age' + direction: int = 0 + + +class ExpressionRestorerOptions(BaseModel): + model: str = 'live_portrait' + factor: int = 80 + + +class FrameEnhancerOptions(BaseModel): + model: str = 'span_kendata_1x' + blend: int = 80 + + +class FrameColorizerOptions(BaseModel): + model: str = 'ddcolor' + blend: int = 80 + size: str = '256x256' + + +class BackgroundRemoverOptions(BaseModel): + model: str = 'isnet_general_use' + + +class FaceDetectorOptions(BaseModel): + model: str = 'yolo_face' + size: str = '640x640' + score: float = 0.5 + + +class FaceSelectorOptions(BaseModel): + mode: str = 'reference' + order: str = 'large-small' + gender: Optional[str] = None + race: Optional[str] = None + age_start: Optional[int] = None + age_end: Optional[int] = None + + +class OutputOptions(BaseModel): + image_quality: int = 80 + image_scale: float = 1.0 + video_encoder: Optional[str] = None + video_preset: str = 'veryfast' + video_quality: int = 80 + video_scale: float = 1.0 + video_fps: Optional[float] = None + audio_encoder: Optional[str] = None + audio_quality: int = 80 + audio_volume: int = 100 + + +class ProcessingOptions(BaseModel): + processors: List[str] = Field(default_factory=lambda: ['face_swapper']) + face_swapper: Optional[FaceSwapperOptions] = None + face_enhancer: Optional[FaceEnhancerOptions] = None + face_editor: Optional[FaceEditorOptions] = None + lip_syncer: Optional[LipSyncerOptions] = None + age_modifier: Optional[AgeModifierOptions] = None + expression_restorer: Optional[ExpressionRestorerOptions] = None + frame_enhancer: Optional[FrameEnhancerOptions] = None + frame_colorizer: Optional[FrameColorizerOptions] = None + background_remover: Optional[BackgroundRemoverOptions] = None + face_detector: Optional[FaceDetectorOptions] = None + face_selector: Optional[FaceSelectorOptions] = None + output: Optional[OutputOptions] = None + execution_providers: Optional[List[str]] = None + execution_thread_count: Optional[int] = None + video_memory_strategy: Optional[str] = None + + +class ProcessingResponse(BaseModel): + output_path: str + processing_time: float diff --git a/app/schemas/system.py b/app/schemas/system.py new file mode 100644 index 0000000..cd6e6de --- /dev/null +++ b/app/schemas/system.py @@ -0,0 +1,33 @@ +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel + + +class HealthResponse(BaseModel): + status: str = 'ok' + + +class GpuDevice(BaseModel): + id: int + name: str + memory_total: Optional[int] = None + memory_used: Optional[int] = None + + +class SystemInfoResponse(BaseModel): + execution_providers: List[str] + gpu_devices: List[GpuDevice] + cpu_count: Optional[int] = None + memory_total: Optional[int] = None + memory_available: Optional[int] = None + + +class ProcessorInfo(BaseModel): + name: str + models: List[str] + + +class ModelInfo(BaseModel): + name: str + path: str + size_bytes: int diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/facefusion_bridge.py b/app/services/facefusion_bridge.py new file mode 100644 index 0000000..a3ae0db --- /dev/null +++ b/app/services/facefusion_bridge.py @@ -0,0 +1,459 @@ +import logging +import os +import sys +import threading +from typing import Any, Dict, List, Optional + +from app.config import settings + +logger = logging.getLogger(__name__) + +_initialized = False +_lock = threading.Lock() + +# All job_keys registered by program.py +JOB_KEYS = [ + 'config_path', 'temp_path', 'jobs_path', + 'source_pattern', 'target_pattern', 'output_pattern', + 'download_providers', 'download_scope', + 'execution_device_ids', 'execution_providers', 'execution_thread_count', + 'video_memory_strategy', 'system_memory_limit', + 'log_level', 'halt_on_error', +] + +# All step_keys registered by program.py + processor modules +STEP_KEYS = [ + # paths + 'source_paths', 'target_path', 'output_path', + # face detector + 'face_detector_model', 'face_detector_size', 'face_detector_margin', + 'face_detector_angles', 'face_detector_score', + # face landmarker + 'face_landmarker_model', 'face_landmarker_score', + # face selector + 'face_selector_mode', 'face_selector_order', 'face_selector_gender', + 'face_selector_race', 'face_selector_age_start', 'face_selector_age_end', + 'reference_face_position', 'reference_face_distance', 'reference_frame_number', + # face masker + 'face_occluder_model', 'face_parser_model', 'face_mask_types', + 'face_mask_areas', 'face_mask_regions', 'face_mask_blur', 'face_mask_padding', + # voice extractor + 'voice_extractor_model', + # frame extraction + 'trim_frame_start', 'trim_frame_end', 'temp_frame_format', 'keep_temp', + # output creation + 'output_image_quality', 'output_image_scale', 'output_audio_encoder', + 'output_audio_quality', 'output_audio_volume', 'output_video_encoder', + 'output_video_preset', 'output_video_quality', 'output_video_scale', + 'output_video_fps', + # processors + 'processors', + # processor-specific: face_swapper + 'face_swapper_model', 'face_swapper_pixel_boost', 'face_swapper_weight', + # processor-specific: face_enhancer + 'face_enhancer_model', 'face_enhancer_blend', 'face_enhancer_weight', + # processor-specific: face_editor + 'face_editor_model', 'face_editor_eyebrow_direction', + 'face_editor_eye_gaze_horizontal', 'face_editor_eye_gaze_vertical', + 'face_editor_eye_open_ratio', 'face_editor_lip_open_ratio', + 'face_editor_mouth_grim', 'face_editor_mouth_pout', 'face_editor_mouth_purse', + 'face_editor_mouth_smile', 'face_editor_mouth_position_horizontal', + 'face_editor_mouth_position_vertical', 'face_editor_head_pitch', + 'face_editor_head_yaw', 'face_editor_head_roll', + # processor-specific: lip_syncer + 'lip_syncer_model', 'lip_syncer_weight', + # processor-specific: age_modifier + 'age_modifier_model', 'age_modifier_direction', + # processor-specific: expression_restorer + 'expression_restorer_model', 'expression_restorer_factor', 'expression_restorer_areas', + # processor-specific: frame_enhancer + 'frame_enhancer_model', 'frame_enhancer_blend', + # processor-specific: frame_colorizer + 'frame_colorizer_model', 'frame_colorizer_blend', 'frame_colorizer_size', + # processor-specific: background_remover + 'background_remover_model', 'background_remover_color', + # processor-specific: deep_swapper + 'deep_swapper_model', 'deep_swapper_morph', + # processor-specific: face_debugger + 'face_debugger_items', +] + + +def _find_submodule_root() -> str: + """Find the FaceFusion submodule root directory. + + In development: {project_root}/facefusion/ + In Docker: /app/facefusion-src/ + """ + project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) + candidates = [ + os.path.join(project_root, 'facefusion'), # dev: project_root/facefusion/ + '/app/facefusion-src', # Docker + ] + for candidate in candidates: + if os.path.isdir(os.path.join(candidate, 'facefusion')): + return candidate + raise RuntimeError('FaceFusion submodule not found') + + +def _setup_sys_path() -> None: + submodule_root = _find_submodule_root() + if submodule_root not in sys.path: + sys.path.insert(0, submodule_root) + + +def _find_config_path() -> str: + """Find facefusion.ini - check submodule root first, then project root.""" + submodule_root = _find_submodule_root() + project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) + for base in (submodule_root, project_root): + path = os.path.join(base, 'facefusion.ini') + if os.path.isfile(path): + return path + return os.path.join(project_root, 'facefusion.ini') + + +def _setup_models_symlink() -> None: + submodule_root = _find_submodule_root() + assets_dir = os.path.join(submodule_root, '.assets') + os.makedirs(assets_dir, exist_ok=True) + + models_link = os.path.join(assets_dir, 'models') + models_target = settings.models_dir + os.makedirs(models_target, exist_ok=True) + + if os.path.islink(models_link): + if os.readlink(models_link) == models_target: + return + os.unlink(models_link) + elif os.path.isdir(models_link): + # Real dir exists - leave it alone in dev mode + return + + os.symlink(models_target, models_link) + logger.info(f'Symlinked {models_link} -> {models_target}') + + +def initialize() -> None: + global _initialized + if _initialized: + return + + _setup_sys_path() + _setup_models_symlink() + + from facefusion import state_manager + from facefusion.jobs import job_manager, job_store + + # Register all keys (replicating program.py side-effects) + job_store.register_job_keys(JOB_KEYS) + job_store.register_step_keys(STEP_KEYS) + + # Initialize state with API defaults + defaults = _build_defaults() + for key, value in defaults.items(): + state_manager.init_item(key, value) + + # Initialize job system + os.makedirs(settings.jobs_dir, exist_ok=True) + if not job_manager.init_jobs(settings.jobs_dir): + raise RuntimeError('Failed to initialize FaceFusion job system') + + # Initialize logger + from facefusion import logger as ff_logger + ff_logger.init(settings.log_level) + + _initialized = True + logger.info('FaceFusion bridge initialized') + + +def _build_defaults() -> Dict[str, Any]: + return { + 'command': 'headless-run', + 'config_path': _find_config_path(), + 'temp_path': settings.temp_dir, + 'jobs_path': settings.jobs_dir, + 'source_paths': None, + 'target_path': None, + 'output_path': None, + 'source_pattern': None, + 'target_pattern': None, + 'output_pattern': None, + # face detector + 'face_detector_model': settings.face_detector_model, + 'face_detector_size': '640x640', + 'face_detector_margin': [0, 0, 0, 0], + 'face_detector_angles': [0], + 'face_detector_score': 0.5, + # face landmarker + 'face_landmarker_model': '2dfan4', + 'face_landmarker_score': 0.5, + # face selector + 'face_selector_mode': 'reference', + 'face_selector_order': 'large-small', + 'face_selector_age_start': None, + 'face_selector_age_end': None, + 'face_selector_gender': None, + 'face_selector_race': None, + 'reference_face_position': 0, + 'reference_face_distance': 0.3, + 'reference_frame_number': 0, + # face masker + 'face_occluder_model': 'xseg_1', + 'face_parser_model': 'bisenet_resnet_34', + 'face_mask_types': ['box'], + 'face_mask_areas': ['upper-face', 'lower-face', 'mouth'], + 'face_mask_regions': ['skin', 'left-eyebrow', 'right-eyebrow', 'left-eye', 'right-eye', + 'glasses', 'nose', 'mouth', 'upper-lip', 'lower-lip'], + 'face_mask_blur': 0.3, + 'face_mask_padding': [0, 0, 0, 0], + # voice extractor + 'voice_extractor_model': 'kim_vocal_2', + # frame extraction + 'trim_frame_start': None, + 'trim_frame_end': None, + 'temp_frame_format': 'png', + 'keep_temp': False, + # output creation + 'output_image_quality': 80, + 'output_image_scale': 1.0, + 'output_audio_encoder': None, + 'output_audio_quality': 80, + 'output_audio_volume': 100, + 'output_video_encoder': None, + 'output_video_preset': 'veryfast', + 'output_video_quality': 80, + 'output_video_scale': 1.0, + 'output_video_fps': None, + # processors + 'processors': ['face_swapper'], + # uis (not used in API but needed by apply_args) + 'open_browser': False, + 'ui_layouts': None, + 'ui_workflow': 'instant_runner', + # execution + 'execution_device_ids': [0], + 'execution_providers': settings.get_execution_providers(), + 'execution_thread_count': settings.execution_thread_count, + # download + 'download_providers': settings.get_download_providers(), + 'download_scope': settings.download_scope, + # memory + 'video_memory_strategy': settings.video_memory_strategy, + 'system_memory_limit': 0, + # misc + 'log_level': settings.log_level, + 'halt_on_error': False, + # benchmark (not used but apply_args references them) + 'benchmark_mode': None, + 'benchmark_resolutions': None, + 'benchmark_cycle_count': None, + # jobs + 'job_id': None, + 'job_status': None, + 'step_index': None, + # processor-specific defaults + 'face_swapper_model': 'hyperswap_1a_256', + 'face_swapper_pixel_boost': None, + 'face_swapper_weight': 0.5, + 'face_enhancer_model': 'gfpgan_1.4', + 'face_enhancer_blend': 80, + 'face_enhancer_weight': 0.5, + 'face_editor_model': 'live_portrait', + 'face_editor_eyebrow_direction': None, + 'face_editor_eye_gaze_horizontal': None, + 'face_editor_eye_gaze_vertical': None, + 'face_editor_eye_open_ratio': None, + 'face_editor_lip_open_ratio': None, + 'face_editor_mouth_grim': None, + 'face_editor_mouth_pout': None, + 'face_editor_mouth_purse': None, + 'face_editor_mouth_smile': None, + 'face_editor_mouth_position_horizontal': None, + 'face_editor_mouth_position_vertical': None, + 'face_editor_head_pitch': None, + 'face_editor_head_yaw': None, + 'face_editor_head_roll': None, + 'lip_syncer_model': 'wav2lip_96', + 'lip_syncer_weight': None, + 'age_modifier_model': 'styleganex_age', + 'age_modifier_direction': 0, + 'expression_restorer_model': 'live_portrait', + 'expression_restorer_factor': 80, + 'expression_restorer_areas': None, + 'frame_enhancer_model': 'span_kendata_1x', + 'frame_enhancer_blend': 80, + 'frame_colorizer_model': 'ddcolor', + 'frame_colorizer_blend': 80, + 'frame_colorizer_size': '256x256', + 'background_remover_model': 'isnet_general_use', + 'background_remover_color': None, + 'deep_swapper_model': None, + 'deep_swapper_morph': None, + 'face_debugger_items': None, + } + + +def build_args_from_options( + source_paths: List[str], + target_path: str, + output_path: str, + options: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Build a FaceFusion args dict from API request options.""" + args = dict(_build_defaults()) + args['source_paths'] = source_paths + args['target_path'] = target_path + args['output_path'] = output_path + + if not options: + return args + + if 'processors' in options: + args['processors'] = options['processors'] + + # Face detector options + fd = options.get('face_detector') + if fd: + if 'model' in fd: + args['face_detector_model'] = fd['model'] + if 'size' in fd: + args['face_detector_size'] = fd['size'] + if 'score' in fd: + args['face_detector_score'] = fd['score'] + + # Face selector options + fs = options.get('face_selector') + if fs: + for key in ('mode', 'order', 'gender', 'race', 'age_start', 'age_end'): + if key in fs: + args[f'face_selector_{key}'] = fs[key] + + # Output options + out = options.get('output') + if out: + for key in ('image_quality', 'image_scale', 'video_encoder', 'video_preset', + 'video_quality', 'video_scale', 'video_fps', 'audio_encoder', + 'audio_quality', 'audio_volume'): + if key in out: + args[f'output_{key}'] = out[key] + + # Execution overrides + if 'execution_providers' in options: + args['execution_providers'] = options['execution_providers'] + if 'execution_thread_count' in options: + args['execution_thread_count'] = options['execution_thread_count'] + if 'video_memory_strategy' in options: + args['video_memory_strategy'] = options['video_memory_strategy'] + + # Per-processor options + _processor_option_map = { + 'face_swapper': ['model', 'pixel_boost', 'weight'], + 'face_enhancer': ['model', 'blend', 'weight'], + 'face_editor': [ + 'model', 'eyebrow_direction', 'eye_gaze_horizontal', 'eye_gaze_vertical', + 'eye_open_ratio', 'lip_open_ratio', 'mouth_grim', 'mouth_pout', + 'mouth_purse', 'mouth_smile', 'mouth_position_horizontal', + 'mouth_position_vertical', 'head_pitch', 'head_yaw', 'head_roll', + ], + 'lip_syncer': ['model', 'weight'], + 'age_modifier': ['model', 'direction'], + 'expression_restorer': ['model', 'factor', 'areas'], + 'frame_enhancer': ['model', 'blend'], + 'frame_colorizer': ['model', 'blend', 'size'], + 'background_remover': ['model', 'color'], + 'deep_swapper': ['model', 'morph'], + 'face_debugger': ['items'], + } + + for processor_name, fields in _processor_option_map.items(): + proc_opts = options.get(processor_name) + if proc_opts: + for field in fields: + if field in proc_opts: + args[f'{processor_name}_{field}'] = proc_opts[field] + + return args + + +def process_sync(args: Dict[str, Any]) -> bool: + """Run FaceFusion processing synchronously. Must be called with _lock held.""" + with _lock: + from facefusion import state_manager + from facefusion.args import apply_args, reduce_step_args + from facefusion.core import conditional_process, common_pre_check, processors_pre_check + from facefusion.jobs import job_helper, job_manager, job_runner + + # Apply args to state + apply_args(args, state_manager.set_item) + + # Create and run job + job_id = job_helper.suggest_job_id('api') + step_args = reduce_step_args(args) + + if not job_manager.create_job(job_id): + raise RuntimeError(f'Failed to create job {job_id}') + if not job_manager.add_step(job_id, step_args): + raise RuntimeError(f'Failed to add step to job {job_id}') + if not job_manager.submit_job(job_id): + raise RuntimeError(f'Failed to submit job {job_id}') + + from facefusion.core import process_step + success = job_runner.run_job(job_id, process_step) + + if not success: + raise RuntimeError(f'Job {job_id} failed') + + return success + + +def get_available_processors() -> List[str]: + """Return list of available processor names.""" + _setup_sys_path() + from facefusion.filesystem import get_file_name, resolve_file_paths + return [get_file_name(p) for p in resolve_file_paths('facefusion/processors/modules')] + + +def get_execution_providers() -> List[str]: + """Return list of available ONNX execution providers.""" + _setup_sys_path() + from facefusion.execution import get_available_execution_providers + return get_available_execution_providers() + + +def force_download_models(processors: Optional[List[str]] = None) -> bool: + """Download models for specified processors (or all if None).""" + with _lock: + from facefusion import content_analyser, face_classifier, face_detector, face_landmarker, face_masker, face_recognizer, state_manager, voice_extractor + from facefusion.download import conditional_download_hashes, conditional_download_sources + from facefusion.filesystem import get_file_name, resolve_file_paths + from facefusion.processors.core import get_processors_modules + + common_modules = [ + content_analyser, face_classifier, face_detector, + face_landmarker, face_masker, face_recognizer, voice_extractor, + ] + + if processors is None: + available = [get_file_name(p) for p in resolve_file_paths('facefusion/processors/modules')] + else: + available = processors + + processor_modules = get_processors_modules(available) + + for module in common_modules + processor_modules: + if hasattr(module, 'create_static_model_set'): + for model in module.create_static_model_set(state_manager.get_item('download_scope')).values(): + model_hash_set = model.get('hashes') + model_source_set = model.get('sources') + if model_hash_set and model_source_set: + if not conditional_download_hashes(model_hash_set) or not conditional_download_sources(model_source_set): + return False + return True + + +def stop_processing() -> None: + """Signal FaceFusion to stop current processing.""" + _setup_sys_path() + from facefusion import process_manager + process_manager.stop() diff --git a/app/services/file_manager.py b/app/services/file_manager.py new file mode 100644 index 0000000..4b1767b --- /dev/null +++ b/app/services/file_manager.py @@ -0,0 +1,75 @@ +import logging +import os +import shutil +import uuid +from typing import List, Tuple + +from fastapi import UploadFile + +from app.config import settings + +logger = logging.getLogger(__name__) + + +def ensure_directories() -> None: + for path in (settings.upload_dir, settings.output_dir, settings.models_dir, + settings.temp_dir, settings.jobs_dir): + os.makedirs(path, exist_ok=True) + + +def create_request_dir() -> str: + request_id = str(uuid.uuid4()) + request_dir = os.path.join(settings.upload_dir, request_id) + os.makedirs(request_dir, exist_ok=True) + return request_dir + + +async def save_upload(file: UploadFile, directory: str) -> str: + ext = os.path.splitext(file.filename or '')[1] or '' + filename = f'{uuid.uuid4()}{ext}' + filepath = os.path.join(directory, filename) + + with open(filepath, 'wb') as f: + while chunk := await file.read(1024 * 1024): + f.write(chunk) + + return filepath + + +async def save_uploads(files: List[UploadFile], directory: str) -> List[str]: + paths = [] + for file in files: + path = await save_upload(file, directory) + paths.append(path) + return paths + + +def generate_output_path(target_path: str) -> str: + ext = os.path.splitext(target_path)[1] or '.png' + filename = f'{uuid.uuid4()}{ext}' + return os.path.join(settings.output_dir, filename) + + +def cleanup_directory(directory: str) -> None: + if os.path.isdir(directory): + shutil.rmtree(directory, ignore_errors=True) + + +def cleanup_file(filepath: str) -> None: + if os.path.isfile(filepath): + os.remove(filepath) + + +def list_model_files() -> List[Tuple[str, str, int]]: + """Return list of (name, path, size_bytes) for all .onnx files in models dir.""" + models = [] + models_dir = settings.models_dir + if not os.path.isdir(models_dir): + return models + + for name in sorted(os.listdir(models_dir)): + if name.endswith('.onnx'): + path = os.path.join(models_dir, name) + size = os.path.getsize(path) + models.append((name, path, size)) + return models diff --git a/app/services/worker.py b/app/services/worker.py new file mode 100644 index 0000000..82a3316 --- /dev/null +++ b/app/services/worker.py @@ -0,0 +1,115 @@ +import logging +import queue +import threading +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Any, Callable, Dict, List, Optional + +logger = logging.getLogger(__name__) + + +class ApiJobStatus(str, Enum): + pending = 'pending' + processing = 'processing' + completed = 'completed' + failed = 'failed' + cancelled = 'cancelled' + + +@dataclass +class ApiJob: + job_id: str + status: ApiJobStatus = ApiJobStatus.pending + created_at: datetime = field(default_factory=datetime.now) + updated_at: Optional[datetime] = None + error: Optional[str] = None + output_path: Optional[str] = None + args: Dict[str, Any] = field(default_factory=dict) + + +class WorkerQueue: + def __init__(self) -> None: + self._queue: queue.Queue[str] = queue.Queue() + self._jobs: Dict[str, ApiJob] = {} + self._lock = threading.Lock() + self._thread: Optional[threading.Thread] = None + self._running = False + self._process_fn: Optional[Callable[[Dict[str, Any]], bool]] = None + + def start(self, process_fn: Callable[[Dict[str, Any]], bool]) -> None: + self._process_fn = process_fn + self._running = True + self._thread = threading.Thread(target=self._worker_loop, daemon=True, name='ff-worker') + self._thread.start() + logger.info('Worker thread started') + + def stop(self) -> None: + self._running = False + self._queue.put('') # sentinel to unblock + if self._thread: + self._thread.join(timeout=5) + logger.info('Worker thread stopped') + + def submit(self, job_id: str, args: Dict[str, Any]) -> ApiJob: + job = ApiJob(job_id=job_id, args=args) + with self._lock: + self._jobs[job_id] = job + self._queue.put(job_id) + return job + + def get_job(self, job_id: str) -> Optional[ApiJob]: + with self._lock: + return self._jobs.get(job_id) + + def delete_job(self, job_id: str) -> bool: + with self._lock: + job = self._jobs.get(job_id) + if not job: + return False + if job.status == ApiJobStatus.processing: + job.status = ApiJobStatus.cancelled + from app.services.facefusion_bridge import stop_processing + stop_processing() + elif job.status == ApiJobStatus.pending: + job.status = ApiJobStatus.cancelled + self._jobs.pop(job_id, None) + return True + + def list_jobs(self) -> List[ApiJob]: + with self._lock: + return list(self._jobs.values()) + + def _worker_loop(self) -> None: + while self._running: + try: + job_id = self._queue.get(timeout=1) + except queue.Empty: + continue + + if not job_id: # sentinel + break + + with self._lock: + job = self._jobs.get(job_id) + if not job or job.status == ApiJobStatus.cancelled: + continue + job.status = ApiJobStatus.processing + job.updated_at = datetime.now() + + try: + self._process_fn(job.args) + with self._lock: + job.status = ApiJobStatus.completed + job.updated_at = datetime.now() + job.output_path = job.args.get('output_path') + logger.info(f'Job {job_id} completed') + except Exception as e: + with self._lock: + job.status = ApiJobStatus.failed + job.updated_at = datetime.now() + job.error = str(e) + logger.error(f'Job {job_id} failed: {e}') + + +worker_queue = WorkerQueue() diff --git a/docker-compose.gpu.yml b/docker-compose.gpu.yml new file mode 100644 index 0000000..14d259a --- /dev/null +++ b/docker-compose.gpu.yml @@ -0,0 +1,31 @@ +services: + api: + image: dev.pivoine.art/valknar/facefusion-api:latest + ports: + - "8000:8000" + volumes: + - uploads:/data/uploads + - outputs:/data/outputs + - models:/data/models + - temp:/data/temp + - jobs:/data/jobs + environment: + - FF_EXECUTION_PROVIDERS=["cuda","cpu"] + - FF_EXECUTION_THREAD_COUNT=4 + - FF_VIDEO_MEMORY_STRATEGY=moderate + - FF_LOG_LEVEL=info + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + restart: unless-stopped + +volumes: + uploads: + outputs: + models: + temp: + jobs: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..61d3aec --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,24 @@ +services: + api: + image: dev.pivoine.art/valknar/facefusion-api:latest-cpu + ports: + - "8000:8000" + volumes: + - uploads:/data/uploads + - outputs:/data/outputs + - models:/data/models + - temp:/data/temp + - jobs:/data/jobs + environment: + - FF_EXECUTION_PROVIDERS=["cpu"] + - FF_EXECUTION_THREAD_COUNT=4 + - FF_VIDEO_MEMORY_STRATEGY=moderate + - FF_LOG_LEVEL=info + restart: unless-stopped + +volumes: + uploads: + outputs: + models: + temp: + jobs: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3dbcfc3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + api: + build: + context: . + args: + VARIANT: cpu + ports: + - "8000:8000" + volumes: + - ./data/uploads:/data/uploads + - ./data/outputs:/data/outputs + - ./data/models:/data/models + - ./data/temp:/data/temp + - ./data/jobs:/data/jobs + environment: + - FF_EXECUTION_PROVIDERS=["cpu"] + - FF_EXECUTION_THREAD_COUNT=4 + - FF_VIDEO_MEMORY_STRATEGY=moderate + - FF_LOG_LEVEL=info + restart: unless-stopped diff --git a/facefusion b/facefusion new file mode 160000 index 0000000..8801668 --- /dev/null +++ b/facefusion @@ -0,0 +1 @@ +Subproject commit 8801668562cda5fd396b11ae4be05af2abfca83d diff --git a/facefusion.ini b/facefusion.ini new file mode 100644 index 0000000..499e467 --- /dev/null +++ b/facefusion.ini @@ -0,0 +1,42 @@ +[paths] +temp_path = /data/temp +jobs_path = /data/jobs + +[face_detector] +face_detector_model = yolo_face +face_detector_size = 640x640 +face_detector_score = 0.5 + +[face_landmarker] +face_landmarker_model = 2dfan4 + +[face_selector] +face_selector_mode = reference +face_selector_order = large-small + +[face_masker] +face_occluder_model = xseg_1 +face_parser_model = bisenet_resnet_34 +face_mask_types = box + +[processors] +processors = face_swapper +face_swapper_model = hyperswap_1a_256 + +[output_creation] +output_image_quality = 80 +output_video_preset = veryfast +output_video_quality = 80 + +[execution] +execution_thread_count = 4 + +[memory] +video_memory_strategy = moderate + +[download] +download_providers = github huggingface +download_scope = lite + +[misc] +log_level = info diff --git a/requirements-cpu.txt b/requirements-cpu.txt new file mode 100644 index 0000000..7ccbc5d --- /dev/null +++ b/requirements-cpu.txt @@ -0,0 +1,2 @@ +-r requirements.txt +onnxruntime==1.20.1 diff --git a/requirements-gpu.txt b/requirements-gpu.txt new file mode 100644 index 0000000..5b36193 --- /dev/null +++ b/requirements-gpu.txt @@ -0,0 +1,2 @@ +-r requirements.txt +onnxruntime-gpu==1.20.1 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d071052 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +python-multipart==0.0.18 +pydantic-settings==2.7.1 +psutil==6.1.1 + +# FaceFusion dependencies (excluding gradio) +numpy==1.26.4 +opencv-python==4.10.0.84 +onnx==1.17.0 +scipy==1.14.1 +tqdm==4.67.1