Files
zeekay 92654c3ad1 Add complete MPS/Apple Silicon support for StreamDiffusionV2 pipeline
This commit adds full macOS Apple Silicon (M1/M2/M3) support by fixing all
MPS (Metal Performance Shaders) compatibility issues in the WAN model.

## Changes:

### MPS Float64 Compatibility Fixes (5 locations):
- model.py: Fix sinusoidal_embedding_1d to use float32 instead of float64
- model.py: Fix rope_params to use float32 for theta calculations
- model.py: Fix rope_apply to use float32 for complex number operations
- causal_model.py: Fix causal_rope_apply to use float32
- wan_wrapper.py: Change .double() to .float() in _convert_flow_pred_to_x0

### Flash Attention Auto-Detection:
- attention.py: Add CUDA availability detection
- attention.py: Auto-disable flash attention on non-CUDA devices (MPS/CPU)
- attention.py: Use PyTorch's scaled_dot_product_attention as fallback on MPS
- model.py: Replace direct flash_attention() calls with attention() wrapper
- clip.py: Replace direct flash_attention() calls with attention() wrapper

### UI Improvements:
- Update Hanzo logo to use filled white SVG for better visibility
- Add proper favicon support with multiple sizes

### Configuration:
- Change default resolution to 128x128 for better performance on Apple Silicon

## Testing:
Successfully tested real-time video generation on Apple Silicon using MPS backend.
Pipeline loads, processes frames, and generates output without errors.

Fixes compatibility with macOS Metal/MPS acceleration while maintaining
full CUDA support for NVIDIA GPUs.
2025-10-21 11:26:53 -07:00

437 lines
13 KiB
Python

import argparse
import asyncio
import logging
import os
import platform
import subprocess
import sys
import threading
import time
import webbrowser
from contextlib import asynccontextmanager
from datetime import datetime
from importlib.metadata import version
from pathlib import Path
import torch
import uvicorn
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from download_models import download_required_models
from lib.pipeline_manager import PipelineManager
from lib.schema import (
HealthResponse,
PipelineLoadRequest,
PipelineStatusResponse,
WebRTCOfferRequest,
WebRTCOfferResponse,
)
from lib.webrtc import WebRTCManager
class STUNErrorFilter(logging.Filter):
"""Filter to suppress STUN/TURN connection errors that are not critical."""
def filter(self, record):
# Suppress STUN exeception that occurrs always during the stream restart
if "Task exception was never retrieved" in record.getMessage():
return False
return True
logging.basicConfig(
level=logging.WARNING, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
# Add the filter to suppress STUN/TURN errors
stun_filter = STUNErrorFilter()
logging.getLogger("asyncio").addFilter(stun_filter)
# Set INFO level for your app modules
logging.getLogger("app").setLevel(logging.INFO)
logging.getLogger("lib").setLevel(logging.INFO)
logging.getLogger("pipelines").setLevel(logging.INFO)
# Enable verbose logging for specific libraries when needed
if os.getenv("VERBOSE_LOGGING"):
logging.getLogger("uvicorn").setLevel(logging.INFO)
logging.getLogger("fastapi").setLevel(logging.INFO)
logging.getLogger("aiortc").setLevel(logging.INFO)
# Select pipeline depending on the "PIPELINE" environment variable
PIPELINE = os.getenv("PIPELINE", None)
logger = logging.getLogger(__name__)
def get_git_commit_hash() -> str:
"""
Get the current git commit hash.
Returns:
Git commit hash if available, otherwise a fallback message.
"""
try:
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
timeout=5, # 5 second timeout
cwd=Path(__file__).parent, # Run in the project directory
)
if result.returncode == 0:
return result.stdout.strip()
else:
return "unknown (not a git repository)"
except (subprocess.TimeoutExpired, subprocess.CalledProcessError):
return "unknown (git error)"
except FileNotFoundError:
return "unknown (git not installed)"
except Exception:
return "unknown"
def print_version_info():
"""Print version information and exit."""
try:
pkg_version = version("hanzo-live")
except Exception:
pkg_version = "unknown"
git_hash = get_git_commit_hash()
print(f"hanzo-live: {pkg_version}")
print(f"git commit: {git_hash}")
def configure_static_files():
"""Configure static file serving for production."""
frontend_dist = Path(__file__).parent / "frontend" / "dist"
if frontend_dist.exists():
app.mount(
"/assets", StaticFiles(directory=frontend_dist / "assets"), name="assets"
)
logger.info(f"Serving static assets from {frontend_dist / 'assets'}")
else:
logger.info("Frontend dist directory not found - running in development mode")
# Global WebRTC manager instance
webrtc_manager = None
# Global pipeline manager instance
pipeline_manager = None
async def prewarm_pipeline(pipeline_id: str):
"""Background task to pre-warm the pipeline without blocking startup."""
try:
await asyncio.wait_for(
pipeline_manager.load_pipeline(pipeline_id),
timeout=300, # 5 minute timeout for pipeline loading
)
except Exception as e:
logger.error(f"Error pre-warming pipeline {pipeline_id} in background: {e}")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifespan handler for startup and shutdown events."""
# Startup
global webrtc_manager, pipeline_manager
# Check hardware availability and auto-detect appropriate backend
cpu_mode = os.getenv("SCOPE_CPU_MODE") == "1"
# Check if we have any GPU support (CUDA or MPS)
has_cuda = torch.cuda.is_available()
has_mps = platform.system() == "Darwin" and hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
if not has_cuda and not has_mps and not cpu_mode:
error_msg = (
"No GPU detected. This application requires either:\n"
" - NVIDIA GPU with CUDA (Linux/Windows)\n"
" - Apple Silicon with Metal (macOS)\n"
"Use --cpu flag to run in CPU-only mode for testing."
)
logger.error(error_msg)
sys.exit(1)
if cpu_mode:
logger.warning("Running in CPU-only mode - performance will be limited")
elif has_cuda:
logger.info(f"Auto-detected CUDA: {torch.cuda.get_device_name(0)}")
elif has_mps:
try:
import mlx.core as mx
logger.info(f"Auto-detected Apple Silicon with MLX {mx.__version__}")
except ImportError:
logger.info("Auto-detected Apple Silicon with Metal backend")
else:
logger.info("Using CPU backend")
# Download models if needed
try:
download_required_models()
except Exception as e:
logger.error(f"Failed to download models: {e}")
sys.exit(1)
# Initialize pipeline manager (but don't load pipeline yet)
pipeline_manager = PipelineManager()
logger.info("Pipeline manager initialized")
# Pre-warm the default pipeline
if PIPELINE is not None:
asyncio.create_task(prewarm_pipeline(PIPELINE))
webrtc_manager = WebRTCManager()
logger.info("WebRTC manager initialized")
yield
# Shutdown
if webrtc_manager:
logger.info("Shutting down WebRTC manager...")
await webrtc_manager.stop()
logger.info("WebRTC manager shutdown complete")
if pipeline_manager:
logger.info("Shutting down pipeline manager...")
pipeline_manager.unload_pipeline()
logger.info("Pipeline manager shutdown complete")
def get_webrtc_manager() -> WebRTCManager:
"""Dependency to get WebRTC manager instance."""
return webrtc_manager
def get_pipeline_manager() -> PipelineManager:
"""Dependency to get pipeline manager instance."""
return pipeline_manager
app = FastAPI(
lifespan=lifespan,
title="Hanzo Live",
description="A tool for running and customizing real-time, interactive generative AI pipelines and models",
version=version("hanzo-live"),
)
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health", response_model=HealthResponse)
async def health_check():
"""Health check endpoint."""
return HealthResponse(status="healthy", timestamp=datetime.now().isoformat())
@app.get("/")
async def root():
"""Serve the frontend at the root URL."""
frontend_dist = Path(__file__).parent / "frontend" / "dist"
# Only serve SPA if frontend dist exists (production mode)
if not frontend_dist.exists():
return {"message": "Scope API - Frontend not built"}
# Serve the frontend index.html
index_file = frontend_dist / "index.html"
if index_file.exists():
return FileResponse(index_file)
return {"message": "Scope API - Frontend index.html not found"}
@app.post("/api/v1/pipeline/load")
async def load_pipeline(
request: PipelineLoadRequest,
pipeline_manager: PipelineManager = Depends(get_pipeline_manager),
):
"""Load a pipeline."""
try:
# Convert pydantic model to dict for pipeline manager
load_params_dict = None
if request.load_params:
load_params_dict = request.load_params.model_dump()
success = await pipeline_manager.load_pipeline(
request.pipeline_id, load_params_dict
)
if success:
return {"message": "Pipeline loading initiated successfully"}
else:
raise HTTPException(
status_code=500,
detail=f"Failed to load pipeline: {pipeline_manager.error_message}",
)
except Exception as e:
logger.error(f"Error loading pipeline: {e}")
raise HTTPException(status_code=500, detail=str(e)) from e
@app.get("/api/v1/pipeline/status", response_model=PipelineStatusResponse)
async def get_pipeline_status(
pipeline_manager: PipelineManager = Depends(get_pipeline_manager),
):
"""Get current pipeline status."""
try:
status_info = pipeline_manager.get_status_info()
return PipelineStatusResponse(**status_info)
except Exception as e:
logger.error(f"Error getting pipeline status: {e}")
raise HTTPException(status_code=500, detail=str(e)) from e
@app.post("/api/v1/webrtc/offer", response_model=WebRTCOfferResponse)
async def handle_webrtc_offer(
request: WebRTCOfferRequest,
webrtc_manager: WebRTCManager = Depends(get_webrtc_manager),
pipeline_manager: PipelineManager = Depends(get_pipeline_manager),
):
"""Handle WebRTC offer and return answer."""
try:
# Ensure pipeline is loaded before proceeding
if not pipeline_manager.is_loaded():
raise HTTPException(
status_code=400,
detail="Pipeline not loaded. Please load pipeline first.",
)
return await webrtc_manager.handle_offer(request, pipeline_manager)
except Exception as e:
logger.error(f"Error handling WebRTC offer: {e}")
raise HTTPException(status_code=500, detail=str(e)) from e
@app.get("/{path:path}")
async def serve_frontend(request: Request, path: str):
"""Serve the frontend for all non-API routes (fallback for client-side routing)."""
frontend_dist = Path(__file__).parent / "frontend" / "dist"
# Only serve SPA if frontend dist exists (production mode)
if not frontend_dist.exists():
raise HTTPException(status_code=404, detail="Frontend not built")
# Check if requesting a specific file that exists
file_path = frontend_dist / path
if file_path.exists() and file_path.is_file():
return FileResponse(file_path)
# Fallback to index.html for SPA routing
index_file = frontend_dist / "index.html"
if index_file.exists():
return FileResponse(index_file)
raise HTTPException(status_code=404, detail="Frontend index.html not found")
def open_browser_when_ready(host: str, port: int, server):
"""Open browser when server is ready, with fallback to URL logging."""
# Wait for server to be ready
while not getattr(server, "started", False):
time.sleep(0.1)
# Determine the URL to open
url = (
f"http://localhost:{port}"
if host in ["0.0.0.0", "127.0.0.1"]
else f"http://{host}:{port}"
)
try:
success = webbrowser.open(url)
if success:
logger.info(f"🌐 Opened browser at {url}")
except Exception:
success = False
if not success:
logger.info(f"🌐 UI is available at: {url}")
def main():
"""Main entry point for the hanzo-live command."""
parser = argparse.ArgumentParser(
description="Hanzo Live - Real-time AI video generation and streaming"
)
parser.add_argument(
"--version",
action="store_true",
help="Show version information and exit",
)
parser.add_argument(
"--reload",
action="store_true",
help="Enable auto-reload for development (default: False)",
)
parser.add_argument(
"--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)"
)
parser.add_argument(
"--port", type=int, default=8000, help="Port to bind to (default: 8000)"
)
parser.add_argument(
"--cpu",
action="store_true",
help="Force CPU-only mode (disable GPU acceleration)",
)
args = parser.parse_args()
# Store CPU mode in environment for lifespan handler
if args.cpu:
os.environ["SCOPE_CPU_MODE"] = "1"
# Handle version flag
if args.version:
print_version_info()
sys.exit(0)
# Configure static file serving
configure_static_files()
# Check if we're in production mode (frontend dist exists)
frontend_dist = Path(__file__).parent / "frontend" / "dist"
is_production = frontend_dist.exists()
if is_production:
# Create server instance for production mode
config = uvicorn.Config(
"app:app", host=args.host, port=args.port, reload=args.reload
)
server = uvicorn.Server(config)
# Start browser opening thread
browser_thread = threading.Thread(
target=open_browser_when_ready,
args=(args.host, args.port, server),
daemon=True,
)
browser_thread.start()
# Run the server
try:
server.run()
except KeyboardInterrupt:
pass # Clean shutdown on Ctrl+C
else:
# Development mode - just run normally
uvicorn.run("app:app", host=args.host, port=args.port, reload=args.reload)
if __name__ == "__main__":
main()