支持fastapi服务
This commit is contained in:
15
api/__init__.py
Normal file
15
api/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
ReelForge API Layer
|
||||
|
||||
FastAPI-based REST API for video generation services.
|
||||
"""
|
||||
|
||||
# Lazy import to avoid loading dependencies until needed
|
||||
def get_app():
|
||||
"""Get FastAPI app instance (lazy loading)"""
|
||||
from api.app import app
|
||||
return app
|
||||
|
||||
|
||||
__all__ = ["get_app"]
|
||||
|
||||
133
api/app.py
Normal file
133
api/app.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
ReelForge FastAPI Application
|
||||
|
||||
Main FastAPI app with all routers and middleware.
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from loguru import logger
|
||||
|
||||
from api.config import api_config
|
||||
from api.tasks import task_manager
|
||||
from api.dependencies import shutdown_reelforge
|
||||
|
||||
# Import routers
|
||||
from api.routers import (
|
||||
health_router,
|
||||
llm_router,
|
||||
tts_router,
|
||||
image_router,
|
||||
content_router,
|
||||
video_router,
|
||||
tasks_router,
|
||||
files_router,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""
|
||||
Application lifespan manager
|
||||
|
||||
Handles startup and shutdown events.
|
||||
"""
|
||||
# Startup
|
||||
logger.info("🚀 Starting ReelForge API...")
|
||||
await task_manager.start()
|
||||
logger.info("✅ ReelForge API started successfully\n")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info("🛑 Shutting down ReelForge API...")
|
||||
await task_manager.stop()
|
||||
await shutdown_reelforge()
|
||||
logger.info("✅ ReelForge API shutdown complete")
|
||||
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="ReelForge API",
|
||||
description="""
|
||||
## ReelForge - AI Video Generation Platform API
|
||||
|
||||
### Features
|
||||
- 🤖 **LLM**: Large language model integration
|
||||
- 🔊 **TTS**: Text-to-speech synthesis
|
||||
- 🎨 **Image**: AI image generation
|
||||
- 📝 **Content**: Automated content generation
|
||||
- 🎬 **Video**: End-to-end video generation
|
||||
|
||||
### Video Generation Modes
|
||||
- **Sync**: `/api/video/generate/sync` - For small videos (< 30s)
|
||||
- **Async**: `/api/video/generate/async` - For large videos with task tracking
|
||||
|
||||
### Getting Started
|
||||
1. Check health: `GET /health`
|
||||
2. Generate narrations: `POST /api/content/narration`
|
||||
3. Generate video: `POST /api/video/generate/sync` or `/async`
|
||||
4. Track task progress: `GET /api/tasks/{task_id}`
|
||||
""",
|
||||
version="0.1.0",
|
||||
docs_url=api_config.docs_url,
|
||||
redoc_url=api_config.redoc_url,
|
||||
openapi_url=api_config.openapi_url,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Add CORS middleware
|
||||
if api_config.cors_enabled:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=api_config.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
logger.info(f"CORS enabled for origins: {api_config.cors_origins}")
|
||||
|
||||
# Include routers
|
||||
# Health check (no prefix)
|
||||
app.include_router(health_router)
|
||||
|
||||
# API routers (with /api prefix)
|
||||
app.include_router(llm_router, prefix=api_config.api_prefix)
|
||||
app.include_router(tts_router, prefix=api_config.api_prefix)
|
||||
app.include_router(image_router, prefix=api_config.api_prefix)
|
||||
app.include_router(content_router, prefix=api_config.api_prefix)
|
||||
app.include_router(video_router, prefix=api_config.api_prefix)
|
||||
app.include_router(tasks_router, prefix=api_config.api_prefix)
|
||||
app.include_router(files_router, prefix=api_config.api_prefix)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint with API information"""
|
||||
return {
|
||||
"service": "ReelForge API",
|
||||
"version": "0.1.0",
|
||||
"docs": api_config.docs_url,
|
||||
"health": "/health",
|
||||
"api": {
|
||||
"llm": f"{api_config.api_prefix}/llm",
|
||||
"tts": f"{api_config.api_prefix}/tts",
|
||||
"image": f"{api_config.api_prefix}/image",
|
||||
"content": f"{api_config.api_prefix}/content",
|
||||
"video": f"{api_config.api_prefix}/video",
|
||||
"tasks": f"{api_config.api_prefix}/tasks",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"api.app:app",
|
||||
host=api_config.host,
|
||||
port=api_config.port,
|
||||
reload=api_config.reload,
|
||||
)
|
||||
|
||||
38
api/config.py
Normal file
38
api/config.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
API Configuration
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class APIConfig(BaseModel):
|
||||
"""API configuration"""
|
||||
|
||||
# Server settings
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
reload: bool = False
|
||||
|
||||
# CORS settings
|
||||
cors_enabled: bool = True
|
||||
cors_origins: list[str] = ["*"]
|
||||
|
||||
# Task settings
|
||||
max_concurrent_tasks: int = 5
|
||||
task_cleanup_interval: int = 3600 # Clean completed tasks every hour
|
||||
task_retention_time: int = 86400 # Keep task results for 24 hours
|
||||
|
||||
# File upload settings
|
||||
max_upload_size: int = 100 * 1024 * 1024 # 100MB
|
||||
|
||||
# API settings
|
||||
api_prefix: str = "/api"
|
||||
docs_url: Optional[str] = "/docs"
|
||||
redoc_url: Optional[str] = "/redoc"
|
||||
openapi_url: Optional[str] = "/openapi.json"
|
||||
|
||||
|
||||
# Global config instance
|
||||
api_config = APIConfig()
|
||||
|
||||
45
api/dependencies.py
Normal file
45
api/dependencies.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
FastAPI Dependencies
|
||||
|
||||
Provides dependency injection for ReelForgeCore and other services.
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
from fastapi import Depends
|
||||
from loguru import logger
|
||||
|
||||
from reelforge.service import ReelForgeCore
|
||||
|
||||
|
||||
# Global ReelForge instance
|
||||
_reelforge_instance: ReelForgeCore = None
|
||||
|
||||
|
||||
async def get_reelforge() -> ReelForgeCore:
|
||||
"""
|
||||
Get ReelForge core instance (dependency injection)
|
||||
|
||||
Returns:
|
||||
ReelForgeCore instance
|
||||
"""
|
||||
global _reelforge_instance
|
||||
|
||||
if _reelforge_instance is None:
|
||||
_reelforge_instance = ReelForgeCore()
|
||||
await _reelforge_instance.initialize()
|
||||
logger.info("✅ ReelForge initialized for API")
|
||||
|
||||
return _reelforge_instance
|
||||
|
||||
|
||||
async def shutdown_reelforge():
|
||||
"""Shutdown ReelForge instance"""
|
||||
global _reelforge_instance
|
||||
if _reelforge_instance:
|
||||
logger.info("Shutting down ReelForge...")
|
||||
_reelforge_instance = None
|
||||
|
||||
|
||||
# Type alias for dependency injection
|
||||
ReelForgeDep = Annotated[ReelForgeCore, Depends(get_reelforge)]
|
||||
|
||||
24
api/routers/__init__.py
Normal file
24
api/routers/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
API Routers
|
||||
"""
|
||||
|
||||
from api.routers.health import router as health_router
|
||||
from api.routers.llm import router as llm_router
|
||||
from api.routers.tts import router as tts_router
|
||||
from api.routers.image import router as image_router
|
||||
from api.routers.content import router as content_router
|
||||
from api.routers.video import router as video_router
|
||||
from api.routers.tasks import router as tasks_router
|
||||
from api.routers.files import router as files_router
|
||||
|
||||
__all__ = [
|
||||
"health_router",
|
||||
"llm_router",
|
||||
"tts_router",
|
||||
"image_router",
|
||||
"content_router",
|
||||
"video_router",
|
||||
"tasks_router",
|
||||
"files_router",
|
||||
]
|
||||
|
||||
125
api/routers/content.py
Normal file
125
api/routers/content.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Content generation endpoints
|
||||
|
||||
Endpoints for generating narrations, image prompts, and titles.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from api.dependencies import ReelForgeDep
|
||||
from api.schemas.content import (
|
||||
NarrationGenerateRequest,
|
||||
NarrationGenerateResponse,
|
||||
ImagePromptGenerateRequest,
|
||||
ImagePromptGenerateResponse,
|
||||
TitleGenerateRequest,
|
||||
TitleGenerateResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/content", tags=["Content Generation"])
|
||||
|
||||
|
||||
@router.post("/narration", response_model=NarrationGenerateResponse)
|
||||
async def generate_narration(
|
||||
request: NarrationGenerateRequest,
|
||||
reelforge: ReelForgeDep
|
||||
):
|
||||
"""
|
||||
Generate narrations from text
|
||||
|
||||
Uses LLM to break down text into multiple narration segments.
|
||||
|
||||
- **text**: Source text
|
||||
- **n_scenes**: Number of narrations to generate
|
||||
- **min_words**: Minimum words per narration
|
||||
- **max_words**: Maximum words per narration
|
||||
|
||||
Returns list of narration strings.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Generating {request.n_scenes} narrations from text")
|
||||
|
||||
# Call narration generator service
|
||||
narrations = await reelforge.narration_generator(
|
||||
text=request.text,
|
||||
n_scenes=request.n_scenes,
|
||||
min_words=request.min_words,
|
||||
max_words=request.max_words
|
||||
)
|
||||
|
||||
return NarrationGenerateResponse(
|
||||
narrations=narrations
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Narration generation error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/image-prompt", response_model=ImagePromptGenerateResponse)
|
||||
async def generate_image_prompt(
|
||||
request: ImagePromptGenerateRequest,
|
||||
reelforge: ReelForgeDep
|
||||
):
|
||||
"""
|
||||
Generate image prompts from narrations
|
||||
|
||||
Uses LLM to create detailed image generation prompts.
|
||||
|
||||
- **narrations**: List of narration texts
|
||||
- **min_words**: Minimum words per prompt
|
||||
- **max_words**: Maximum words per prompt
|
||||
|
||||
Returns list of image prompts.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Generating image prompts for {len(request.narrations)} narrations")
|
||||
|
||||
# Call image prompt generator service
|
||||
image_prompts = await reelforge.image_prompt_generator(
|
||||
narrations=request.narrations,
|
||||
min_words=request.min_words,
|
||||
max_words=request.max_words
|
||||
)
|
||||
|
||||
return ImagePromptGenerateResponse(
|
||||
image_prompts=image_prompts
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Image prompt generation error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/title", response_model=TitleGenerateResponse)
|
||||
async def generate_title(
|
||||
request: TitleGenerateRequest,
|
||||
reelforge: ReelForgeDep
|
||||
):
|
||||
"""
|
||||
Generate video title from text
|
||||
|
||||
Uses LLM to create an engaging title.
|
||||
|
||||
- **text**: Source text
|
||||
- **style**: Optional title style hint
|
||||
|
||||
Returns generated title.
|
||||
"""
|
||||
try:
|
||||
logger.info("Generating title from text")
|
||||
|
||||
# Call title generator service
|
||||
title = await reelforge.title_generator(
|
||||
text=request.text
|
||||
)
|
||||
|
||||
return TitleGenerateResponse(
|
||||
title=title
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Title generation error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
72
api/routers/files.py
Normal file
72
api/routers/files.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
File service endpoints
|
||||
|
||||
Provides access to generated files (videos, images, audio).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from loguru import logger
|
||||
|
||||
router = APIRouter(prefix="/files", tags=["Files"])
|
||||
|
||||
|
||||
@router.get("/{file_path:path}")
|
||||
async def get_file(file_path: str):
|
||||
"""
|
||||
Get file by path
|
||||
|
||||
Serves files from the output directory only.
|
||||
|
||||
- **file_path**: File name or path (e.g., "abc123.mp4" or "subfolder/abc123.mp4")
|
||||
|
||||
Returns file for download or preview.
|
||||
"""
|
||||
try:
|
||||
# Automatically prepend "output/" to the path
|
||||
full_path = f"output/{file_path}"
|
||||
abs_path = Path.cwd() / full_path
|
||||
|
||||
if not abs_path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {file_path}")
|
||||
|
||||
if not abs_path.is_file():
|
||||
raise HTTPException(status_code=400, detail=f"Path is not a file: {file_path}")
|
||||
|
||||
# Security: only allow access to output directory
|
||||
try:
|
||||
rel_path = abs_path.relative_to(Path.cwd())
|
||||
if not str(rel_path).startswith("output"):
|
||||
raise HTTPException(status_code=403, detail="Access denied: only output directory is accessible")
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Determine media type
|
||||
suffix = abs_path.suffix.lower()
|
||||
media_types = {
|
||||
'.mp4': 'video/mp4',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
}
|
||||
media_type = media_types.get(suffix, 'application/octet-stream')
|
||||
|
||||
# Use inline disposition for browser preview
|
||||
return FileResponse(
|
||||
path=str(abs_path),
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Content-Disposition": f'inline; filename="{abs_path.name}"'
|
||||
}
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"File access error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
42
api/routers/health.py
Normal file
42
api/routers/health.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Health check and system info endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(tags=["Health"])
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Health check response"""
|
||||
status: str = "healthy"
|
||||
version: str = "0.1.0"
|
||||
service: str = "ReelForge API"
|
||||
|
||||
|
||||
class CapabilitiesResponse(BaseModel):
|
||||
"""Capabilities response"""
|
||||
success: bool = True
|
||||
capabilities: dict
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
async def health_check():
|
||||
"""
|
||||
Health check endpoint
|
||||
|
||||
Returns service status and version information.
|
||||
"""
|
||||
return HealthResponse()
|
||||
|
||||
|
||||
@router.get("/version", response_model=HealthResponse)
|
||||
async def get_version():
|
||||
"""
|
||||
Get API version
|
||||
|
||||
Returns version information.
|
||||
"""
|
||||
return HealthResponse()
|
||||
|
||||
49
api/routers/image.py
Normal file
49
api/routers/image.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Image generation endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from api.dependencies import ReelForgeDep
|
||||
from api.schemas.image import ImageGenerateRequest, ImageGenerateResponse
|
||||
|
||||
router = APIRouter(prefix="/image", tags=["Image"])
|
||||
|
||||
|
||||
@router.post("/generate", response_model=ImageGenerateResponse)
|
||||
async def image_generate(
|
||||
request: ImageGenerateRequest,
|
||||
reelforge: ReelForgeDep
|
||||
):
|
||||
"""
|
||||
Image generation endpoint
|
||||
|
||||
Generate image from text prompt using ComfyKit.
|
||||
|
||||
- **prompt**: Image description/prompt
|
||||
- **width**: Image width (512-2048)
|
||||
- **height**: Image height (512-2048)
|
||||
- **workflow**: Optional custom workflow filename
|
||||
|
||||
Returns path to generated image.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Image generation request: {request.prompt[:50]}...")
|
||||
|
||||
# Call image service
|
||||
image_path = await reelforge.image(
|
||||
prompt=request.prompt,
|
||||
width=request.width,
|
||||
height=request.height,
|
||||
workflow=request.workflow
|
||||
)
|
||||
|
||||
return ImageGenerateResponse(
|
||||
image_path=image_path
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Image generation error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
48
api/routers/llm.py
Normal file
48
api/routers/llm.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
LLM (Large Language Model) endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from api.dependencies import ReelForgeDep
|
||||
from api.schemas.llm import LLMChatRequest, LLMChatResponse
|
||||
|
||||
router = APIRouter(prefix="/llm", tags=["LLM"])
|
||||
|
||||
|
||||
@router.post("/chat", response_model=LLMChatResponse)
|
||||
async def llm_chat(
|
||||
request: LLMChatRequest,
|
||||
reelforge: ReelForgeDep
|
||||
):
|
||||
"""
|
||||
LLM chat endpoint
|
||||
|
||||
Generate text response using configured LLM.
|
||||
|
||||
- **prompt**: User prompt/question
|
||||
- **temperature**: Creativity level (0.0-2.0, lower = more deterministic)
|
||||
- **max_tokens**: Maximum response length
|
||||
|
||||
Returns generated text response.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"LLM chat request: {request.prompt[:50]}...")
|
||||
|
||||
# Call LLM service
|
||||
response = await reelforge.llm(
|
||||
prompt=request.prompt,
|
||||
temperature=request.temperature,
|
||||
max_tokens=request.max_tokens
|
||||
)
|
||||
|
||||
return LLMChatResponse(
|
||||
content=response,
|
||||
tokens_used=None # Can add token counting if needed
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LLM chat error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
93
api/routers/tasks.py
Normal file
93
api/routers/tasks.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Task management endpoints
|
||||
|
||||
Endpoints for managing async tasks (checking status, canceling, etc.)
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from loguru import logger
|
||||
|
||||
from api.tasks import task_manager, Task, TaskStatus
|
||||
|
||||
router = APIRouter(prefix="/tasks", tags=["Tasks"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[Task])
|
||||
async def list_tasks(
|
||||
status: Optional[TaskStatus] = Query(None, description="Filter by status"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Maximum number of tasks")
|
||||
):
|
||||
"""
|
||||
List tasks
|
||||
|
||||
Retrieve list of tasks with optional filtering.
|
||||
|
||||
- **status**: Optional filter by status (pending/running/completed/failed/cancelled)
|
||||
- **limit**: Maximum number of tasks to return (default 100)
|
||||
|
||||
Returns list of tasks sorted by creation time (newest first).
|
||||
"""
|
||||
try:
|
||||
tasks = task_manager.list_tasks(status=status, limit=limit)
|
||||
return tasks
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"List tasks error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=Task)
|
||||
async def get_task(task_id: str):
|
||||
"""
|
||||
Get task details
|
||||
|
||||
Retrieve detailed information about a specific task.
|
||||
|
||||
- **task_id**: Task ID
|
||||
|
||||
Returns task details including status, progress, and result (if completed).
|
||||
"""
|
||||
try:
|
||||
task = task_manager.get_task(task_id)
|
||||
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
|
||||
return task
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Get task error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{task_id}")
|
||||
async def cancel_task(task_id: str):
|
||||
"""
|
||||
Cancel task
|
||||
|
||||
Cancel a running or pending task.
|
||||
|
||||
- **task_id**: Task ID
|
||||
|
||||
Returns success status.
|
||||
"""
|
||||
try:
|
||||
success = task_manager.cancel_task(task_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Task {task_id} cancelled successfully"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Cancel task error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
50
api/routers/tts.py
Normal file
50
api/routers/tts.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
TTS (Text-to-Speech) endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from api.dependencies import ReelForgeDep
|
||||
from api.schemas.tts import TTSSynthesizeRequest, TTSSynthesizeResponse
|
||||
from reelforge.utils.tts_util import get_audio_duration
|
||||
|
||||
router = APIRouter(prefix="/tts", tags=["TTS"])
|
||||
|
||||
|
||||
@router.post("/synthesize", response_model=TTSSynthesizeResponse)
|
||||
async def tts_synthesize(
|
||||
request: TTSSynthesizeRequest,
|
||||
reelforge: ReelForgeDep
|
||||
):
|
||||
"""
|
||||
Text-to-Speech synthesis endpoint
|
||||
|
||||
Convert text to speech audio.
|
||||
|
||||
- **text**: Text to synthesize
|
||||
- **voice_id**: Voice ID (e.g., 'zh-CN-YunjianNeural', 'en-US-AriaNeural')
|
||||
|
||||
Returns path to generated audio file and duration.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"TTS synthesis request: {request.text[:50]}...")
|
||||
|
||||
# Call TTS service
|
||||
audio_path = await reelforge.tts(
|
||||
text=request.text,
|
||||
voice_id=request.voice_id
|
||||
)
|
||||
|
||||
# Get audio duration
|
||||
duration = get_audio_duration(audio_path)
|
||||
|
||||
return TTSSynthesizeResponse(
|
||||
audio_path=audio_path,
|
||||
duration=duration
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"TTS synthesis error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
180
api/routers/video.py
Normal file
180
api/routers/video.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Video generation endpoints
|
||||
|
||||
Supports both synchronous and asynchronous video generation.
|
||||
"""
|
||||
|
||||
import os
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from loguru import logger
|
||||
|
||||
from api.dependencies import ReelForgeDep
|
||||
from api.schemas.video import (
|
||||
VideoGenerateRequest,
|
||||
VideoGenerateResponse,
|
||||
VideoGenerateAsyncResponse,
|
||||
)
|
||||
from api.tasks import task_manager, TaskType
|
||||
|
||||
router = APIRouter(prefix="/video", tags=["Video Generation"])
|
||||
|
||||
|
||||
def path_to_url(request: Request, file_path: str) -> str:
|
||||
"""Convert file path to accessible URL"""
|
||||
# file_path is like "output/abc123.mp4"
|
||||
# Remove "output/" prefix for cleaner URL
|
||||
if file_path.startswith("output/"):
|
||||
file_path = file_path[7:] # Remove "output/"
|
||||
base_url = str(request.base_url).rstrip('/')
|
||||
return f"{base_url}/api/files/{file_path}"
|
||||
|
||||
|
||||
@router.post("/generate/sync", response_model=VideoGenerateResponse)
|
||||
async def generate_video_sync(
|
||||
request_body: VideoGenerateRequest,
|
||||
reelforge: ReelForgeDep,
|
||||
request: Request
|
||||
):
|
||||
"""
|
||||
Generate video synchronously
|
||||
|
||||
This endpoint blocks until video generation is complete.
|
||||
Suitable for small videos (< 30 seconds).
|
||||
|
||||
**Note**: May timeout for large videos. Use `/generate/async` instead.
|
||||
|
||||
Request body includes all video generation parameters.
|
||||
See VideoGenerateRequest schema for details.
|
||||
|
||||
Returns path to generated video, duration, and file size.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Sync video generation: {request_body.text[:50]}...")
|
||||
|
||||
# Call video generator service
|
||||
result = await reelforge.generate_video(
|
||||
text=request_body.text,
|
||||
mode=request_body.mode,
|
||||
title=request_body.title,
|
||||
n_scenes=request_body.n_scenes,
|
||||
voice_id=request_body.voice_id,
|
||||
use_uuid_filename=True, # API mode: use UUID filename
|
||||
min_narration_words=request_body.min_narration_words,
|
||||
max_narration_words=request_body.max_narration_words,
|
||||
min_image_prompt_words=request_body.min_image_prompt_words,
|
||||
max_image_prompt_words=request_body.max_image_prompt_words,
|
||||
image_width=request_body.image_width,
|
||||
image_height=request_body.image_height,
|
||||
image_workflow=request_body.image_workflow,
|
||||
video_width=request_body.video_width,
|
||||
video_height=request_body.video_height,
|
||||
video_fps=request_body.video_fps,
|
||||
frame_template=request_body.frame_template,
|
||||
prompt_prefix=request_body.prompt_prefix,
|
||||
bgm_path=request_body.bgm_path,
|
||||
bgm_volume=request_body.bgm_volume,
|
||||
)
|
||||
|
||||
# Get file size
|
||||
file_size = os.path.getsize(result.video_path) if os.path.exists(result.video_path) else 0
|
||||
|
||||
# Convert path to URL
|
||||
video_url = path_to_url(request, result.video_path)
|
||||
|
||||
return VideoGenerateResponse(
|
||||
video_url=video_url,
|
||||
duration=result.duration,
|
||||
file_size=file_size
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Sync video generation error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/generate/async", response_model=VideoGenerateAsyncResponse)
|
||||
async def generate_video_async(
|
||||
request_body: VideoGenerateRequest,
|
||||
reelforge: ReelForgeDep,
|
||||
request: Request
|
||||
):
|
||||
"""
|
||||
Generate video asynchronously
|
||||
|
||||
Creates a background task for video generation.
|
||||
Returns immediately with a task_id for tracking progress.
|
||||
|
||||
**Workflow:**
|
||||
1. Submit video generation request
|
||||
2. Receive task_id in response
|
||||
3. Poll `/api/tasks/{task_id}` to check status
|
||||
4. When status is "completed", retrieve video from result
|
||||
|
||||
Request body includes all video generation parameters.
|
||||
See VideoGenerateRequest schema for details.
|
||||
|
||||
Returns task_id for tracking progress.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Async video generation: {request_body.text[:50]}...")
|
||||
|
||||
# Create task
|
||||
task = task_manager.create_task(
|
||||
task_type=TaskType.VIDEO_GENERATION,
|
||||
request_params=request_body.model_dump()
|
||||
)
|
||||
|
||||
# Define async execution function
|
||||
async def execute_video_generation():
|
||||
"""Execute video generation in background"""
|
||||
result = await reelforge.generate_video(
|
||||
text=request_body.text,
|
||||
mode=request_body.mode,
|
||||
title=request_body.title,
|
||||
n_scenes=request_body.n_scenes,
|
||||
voice_id=request_body.voice_id,
|
||||
use_uuid_filename=True, # API mode: use UUID filename
|
||||
min_narration_words=request_body.min_narration_words,
|
||||
max_narration_words=request_body.max_narration_words,
|
||||
min_image_prompt_words=request_body.min_image_prompt_words,
|
||||
max_image_prompt_words=request_body.max_image_prompt_words,
|
||||
image_width=request_body.image_width,
|
||||
image_height=request_body.image_height,
|
||||
image_workflow=request_body.image_workflow,
|
||||
video_width=request_body.video_width,
|
||||
video_height=request_body.video_height,
|
||||
video_fps=request_body.video_fps,
|
||||
frame_template=request_body.frame_template,
|
||||
prompt_prefix=request_body.prompt_prefix,
|
||||
bgm_path=request_body.bgm_path,
|
||||
bgm_volume=request_body.bgm_volume,
|
||||
# Progress callback can be added here if needed
|
||||
# progress_callback=lambda event: task_manager.update_progress(...)
|
||||
)
|
||||
|
||||
# Get file size
|
||||
file_size = os.path.getsize(result.video_path) if os.path.exists(result.video_path) else 0
|
||||
|
||||
# Convert path to URL
|
||||
video_url = path_to_url(request, result.video_path)
|
||||
|
||||
return {
|
||||
"video_url": video_url,
|
||||
"duration": result.duration,
|
||||
"file_size": file_size
|
||||
}
|
||||
|
||||
# Start execution
|
||||
await task_manager.execute_task(
|
||||
task_id=task.task_id,
|
||||
coro_func=execute_video_generation
|
||||
)
|
||||
|
||||
return VideoGenerateAsyncResponse(
|
||||
task_id=task.task_id
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Async video generation error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
48
api/schemas/__init__.py
Normal file
48
api/schemas/__init__.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
API Schemas (Pydantic models)
|
||||
"""
|
||||
|
||||
from api.schemas.base import BaseResponse, ErrorResponse
|
||||
from api.schemas.llm import LLMChatRequest, LLMChatResponse
|
||||
from api.schemas.tts import TTSSynthesizeRequest, TTSSynthesizeResponse
|
||||
from api.schemas.image import ImageGenerateRequest, ImageGenerateResponse
|
||||
from api.schemas.content import (
|
||||
NarrationGenerateRequest,
|
||||
NarrationGenerateResponse,
|
||||
ImagePromptGenerateRequest,
|
||||
ImagePromptGenerateResponse,
|
||||
TitleGenerateRequest,
|
||||
TitleGenerateResponse,
|
||||
)
|
||||
from api.schemas.video import (
|
||||
VideoGenerateRequest,
|
||||
VideoGenerateResponse,
|
||||
VideoGenerateAsyncResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Base
|
||||
"BaseResponse",
|
||||
"ErrorResponse",
|
||||
# LLM
|
||||
"LLMChatRequest",
|
||||
"LLMChatResponse",
|
||||
# TTS
|
||||
"TTSSynthesizeRequest",
|
||||
"TTSSynthesizeResponse",
|
||||
# Image
|
||||
"ImageGenerateRequest",
|
||||
"ImageGenerateResponse",
|
||||
# Content
|
||||
"NarrationGenerateRequest",
|
||||
"NarrationGenerateResponse",
|
||||
"ImagePromptGenerateRequest",
|
||||
"ImagePromptGenerateResponse",
|
||||
"TitleGenerateRequest",
|
||||
"TitleGenerateResponse",
|
||||
# Video
|
||||
"VideoGenerateRequest",
|
||||
"VideoGenerateResponse",
|
||||
"VideoGenerateAsyncResponse",
|
||||
]
|
||||
|
||||
21
api/schemas/base.py
Normal file
21
api/schemas/base.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
Base schemas
|
||||
"""
|
||||
|
||||
from typing import Any, Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class BaseResponse(BaseModel):
|
||||
"""Base API response"""
|
||||
success: bool = True
|
||||
message: str = "Success"
|
||||
data: Optional[Any] = None
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Error response"""
|
||||
success: bool = False
|
||||
message: str
|
||||
error: Optional[str] = None
|
||||
|
||||
91
api/schemas/content.py
Normal file
91
api/schemas/content.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Content generation API schemas
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Narration Generation
|
||||
# ============================================================================
|
||||
|
||||
class NarrationGenerateRequest(BaseModel):
|
||||
"""Narration generation request"""
|
||||
text: str = Field(..., description="Source text to generate narrations from")
|
||||
n_scenes: int = Field(5, ge=1, le=20, description="Number of scenes")
|
||||
min_words: int = Field(5, ge=1, le=100, description="Minimum words per narration")
|
||||
max_words: int = Field(20, ge=1, le=200, description="Maximum words per narration")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"text": "Atomic Habits is about making small changes that lead to remarkable results.",
|
||||
"n_scenes": 5,
|
||||
"min_words": 5,
|
||||
"max_words": 20
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class NarrationGenerateResponse(BaseModel):
|
||||
"""Narration generation response"""
|
||||
success: bool = True
|
||||
message: str = "Success"
|
||||
narrations: List[str] = Field(..., description="Generated narrations")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Image Prompt Generation
|
||||
# ============================================================================
|
||||
|
||||
class ImagePromptGenerateRequest(BaseModel):
|
||||
"""Image prompt generation request"""
|
||||
narrations: List[str] = Field(..., description="List of narrations")
|
||||
min_words: int = Field(30, ge=10, le=100, description="Minimum words per prompt")
|
||||
max_words: int = Field(60, ge=10, le=200, description="Maximum words per prompt")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"narrations": [
|
||||
"Small habits compound over time",
|
||||
"Focus on systems, not goals"
|
||||
],
|
||||
"min_words": 30,
|
||||
"max_words": 60
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ImagePromptGenerateResponse(BaseModel):
|
||||
"""Image prompt generation response"""
|
||||
success: bool = True
|
||||
message: str = "Success"
|
||||
image_prompts: List[str] = Field(..., description="Generated image prompts")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Title Generation
|
||||
# ============================================================================
|
||||
|
||||
class TitleGenerateRequest(BaseModel):
|
||||
"""Title generation request"""
|
||||
text: str = Field(..., description="Source text")
|
||||
style: Optional[str] = Field(None, description="Title style (e.g., 'engaging', 'formal')")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"text": "Atomic Habits is about making small changes that lead to remarkable results.",
|
||||
"style": "engaging"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TitleGenerateResponse(BaseModel):
|
||||
"""Title generation response"""
|
||||
success: bool = True
|
||||
message: str = "Success"
|
||||
title: str = Field(..., description="Generated title")
|
||||
|
||||
31
api/schemas/image.py
Normal file
31
api/schemas/image.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Image generation API schemas
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ImageGenerateRequest(BaseModel):
|
||||
"""Image generation request"""
|
||||
prompt: str = Field(..., description="Image generation prompt")
|
||||
width: int = Field(1024, ge=512, le=2048, description="Image width")
|
||||
height: int = Field(1024, ge=512, le=2048, description="Image height")
|
||||
workflow: Optional[str] = Field(None, description="Custom workflow filename")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"prompt": "A serene mountain landscape at sunset, photorealistic style",
|
||||
"width": 1024,
|
||||
"height": 1024
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ImageGenerateResponse(BaseModel):
|
||||
"""Image generation response"""
|
||||
success: bool = True
|
||||
message: str = "Success"
|
||||
image_path: str = Field(..., description="Path to generated image")
|
||||
|
||||
31
api/schemas/llm.py
Normal file
31
api/schemas/llm.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
LLM API schemas
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LLMChatRequest(BaseModel):
|
||||
"""LLM chat request"""
|
||||
prompt: str = Field(..., description="User prompt")
|
||||
temperature: float = Field(0.7, ge=0.0, le=2.0, description="Temperature (0.0-2.0)")
|
||||
max_tokens: int = Field(2000, ge=1, le=32000, description="Maximum tokens")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"prompt": "Explain the concept of atomic habits in 3 sentences",
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 2000
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class LLMChatResponse(BaseModel):
|
||||
"""LLM chat response"""
|
||||
success: bool = True
|
||||
message: str = "Success"
|
||||
content: str = Field(..., description="Generated response")
|
||||
tokens_used: Optional[int] = Field(None, description="Tokens used (if available)")
|
||||
|
||||
28
api/schemas/tts.py
Normal file
28
api/schemas/tts.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
TTS API schemas
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TTSSynthesizeRequest(BaseModel):
|
||||
"""TTS synthesis request"""
|
||||
text: str = Field(..., description="Text to synthesize")
|
||||
voice_id: str = Field("zh-CN-YunjianNeural", description="Voice ID")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"text": "Hello, welcome to ReelForge!",
|
||||
"voice_id": "zh-CN-YunjianNeural"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TTSSynthesizeResponse(BaseModel):
|
||||
"""TTS synthesis response"""
|
||||
success: bool = True
|
||||
message: str = "Success"
|
||||
audio_path: str = Field(..., description="Path to generated audio file")
|
||||
duration: float = Field(..., description="Audio duration in seconds")
|
||||
|
||||
80
api/schemas/video.py
Normal file
80
api/schemas/video.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
Video generation API schemas
|
||||
"""
|
||||
|
||||
from typing import Optional, Literal
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class VideoGenerateRequest(BaseModel):
|
||||
"""Video generation request"""
|
||||
|
||||
# === Input ===
|
||||
text: str = Field(..., description="Source text for video generation")
|
||||
|
||||
# === Processing Mode ===
|
||||
mode: Literal["generate", "fixed"] = Field(
|
||||
"generate",
|
||||
description="Processing mode: 'generate' (AI generates narrations) or 'fixed' (use text as-is)"
|
||||
)
|
||||
|
||||
# === Optional Title ===
|
||||
title: Optional[str] = Field(None, description="Video title (auto-generated if not provided)")
|
||||
|
||||
# === Basic Config ===
|
||||
n_scenes: int = Field(5, ge=1, le=20, description="Number of scenes (generate mode only)")
|
||||
voice_id: str = Field("zh-CN-YunjianNeural", description="TTS voice ID")
|
||||
|
||||
# === LLM Parameters ===
|
||||
min_narration_words: int = Field(5, ge=1, le=100, description="Min narration words")
|
||||
max_narration_words: int = Field(20, ge=1, le=200, description="Max narration words")
|
||||
min_image_prompt_words: int = Field(30, ge=10, le=100, description="Min image prompt words")
|
||||
max_image_prompt_words: int = Field(60, ge=10, le=200, description="Max image prompt words")
|
||||
|
||||
# === Image Parameters ===
|
||||
image_width: int = Field(1024, ge=512, le=2048, description="Image width")
|
||||
image_height: int = Field(1024, ge=512, le=2048, description="Image height")
|
||||
image_workflow: Optional[str] = Field(None, description="Custom image workflow")
|
||||
|
||||
# === Video Parameters ===
|
||||
video_width: int = Field(1080, ge=512, le=3840, description="Video width")
|
||||
video_height: int = Field(1920, ge=512, le=3840, description="Video height")
|
||||
video_fps: int = Field(30, ge=15, le=60, description="Video FPS")
|
||||
|
||||
# === Frame Template ===
|
||||
frame_template: Optional[str] = Field(None, description="HTML template name (e.g., 'default.html')")
|
||||
|
||||
# === Image Style ===
|
||||
prompt_prefix: Optional[str] = Field(None, description="Image style prefix")
|
||||
|
||||
# === BGM ===
|
||||
bgm_path: Optional[str] = Field(None, description="Background music path")
|
||||
bgm_volume: float = Field(0.3, ge=0.0, le=1.0, description="BGM volume (0.0-1.0)")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"text": "Atomic Habits teaches us that small changes compound over time to produce remarkable results.",
|
||||
"mode": "generate",
|
||||
"n_scenes": 5,
|
||||
"voice_id": "zh-CN-YunjianNeural",
|
||||
"title": "The Power of Atomic Habits"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class VideoGenerateResponse(BaseModel):
|
||||
"""Video generation response (synchronous)"""
|
||||
success: bool = True
|
||||
message: str = "Success"
|
||||
video_url: str = Field(..., description="URL to access generated video")
|
||||
duration: float = Field(..., description="Video duration in seconds")
|
||||
file_size: int = Field(..., description="File size in bytes")
|
||||
|
||||
|
||||
class VideoGenerateAsyncResponse(BaseModel):
|
||||
"""Video generation async response"""
|
||||
success: bool = True
|
||||
message: str = "Task created successfully"
|
||||
task_id: str = Field(..., description="Task ID for tracking progress")
|
||||
|
||||
9
api/tasks/__init__.py
Normal file
9
api/tasks/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Task management for async operations
|
||||
"""
|
||||
|
||||
from api.tasks.models import Task, TaskStatus, TaskType
|
||||
from api.tasks.manager import task_manager
|
||||
|
||||
__all__ = ["Task", "TaskStatus", "TaskType", "task_manager"]
|
||||
|
||||
254
api/tasks/manager.py
Normal file
254
api/tasks/manager.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""
|
||||
Task Manager
|
||||
|
||||
In-memory task management for video generation jobs.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Callable
|
||||
from loguru import logger
|
||||
|
||||
from api.tasks.models import Task, TaskStatus, TaskType, TaskProgress
|
||||
from api.config import api_config
|
||||
|
||||
|
||||
class TaskManager:
|
||||
"""
|
||||
Task manager for handling async video generation tasks
|
||||
|
||||
Features:
|
||||
- In-memory storage (can be replaced with Redis later)
|
||||
- Task lifecycle management
|
||||
- Progress tracking
|
||||
- Auto cleanup of old tasks
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._tasks: Dict[str, Task] = {}
|
||||
self._task_futures: Dict[str, asyncio.Task] = {}
|
||||
self._cleanup_task: Optional[asyncio.Task] = None
|
||||
self._running = False
|
||||
|
||||
async def start(self):
|
||||
"""Start task manager and cleanup scheduler"""
|
||||
if self._running:
|
||||
logger.warning("Task manager already running")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
|
||||
logger.info("✅ Task manager started")
|
||||
|
||||
async def stop(self):
|
||||
"""Stop task manager and cancel all tasks"""
|
||||
self._running = False
|
||||
|
||||
# Cancel cleanup task
|
||||
if self._cleanup_task:
|
||||
self._cleanup_task.cancel()
|
||||
try:
|
||||
await self._cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Cancel all running tasks
|
||||
for task_id, future in self._task_futures.items():
|
||||
if not future.done():
|
||||
future.cancel()
|
||||
logger.info(f"Cancelled task: {task_id}")
|
||||
|
||||
self._tasks.clear()
|
||||
self._task_futures.clear()
|
||||
logger.info("✅ Task manager stopped")
|
||||
|
||||
def create_task(
|
||||
self,
|
||||
task_type: TaskType,
|
||||
request_params: Optional[dict] = None
|
||||
) -> Task:
|
||||
"""
|
||||
Create a new task
|
||||
|
||||
Args:
|
||||
task_type: Type of task
|
||||
request_params: Original request parameters
|
||||
|
||||
Returns:
|
||||
Created task
|
||||
"""
|
||||
task_id = str(uuid.uuid4())
|
||||
task = Task(
|
||||
task_id=task_id,
|
||||
task_type=task_type,
|
||||
status=TaskStatus.PENDING,
|
||||
request_params=request_params,
|
||||
)
|
||||
|
||||
self._tasks[task_id] = task
|
||||
logger.info(f"Created task {task_id} ({task_type})")
|
||||
return task
|
||||
|
||||
async def execute_task(
|
||||
self,
|
||||
task_id: str,
|
||||
coro_func: Callable,
|
||||
*args,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
Execute task asynchronously
|
||||
|
||||
Args:
|
||||
task_id: Task ID
|
||||
coro_func: Async function to execute
|
||||
*args: Positional arguments
|
||||
**kwargs: Keyword arguments
|
||||
"""
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
logger.error(f"Task {task_id} not found")
|
||||
return
|
||||
|
||||
# Create async task
|
||||
async def _execute():
|
||||
try:
|
||||
task.status = TaskStatus.RUNNING
|
||||
task.started_at = datetime.now()
|
||||
logger.info(f"Task {task_id} started")
|
||||
|
||||
# Execute the actual work
|
||||
result = await coro_func(*args, **kwargs)
|
||||
|
||||
# Update task with result
|
||||
task.status = TaskStatus.COMPLETED
|
||||
task.result = result
|
||||
task.completed_at = datetime.now()
|
||||
logger.info(f"Task {task_id} completed")
|
||||
|
||||
except Exception as e:
|
||||
task.status = TaskStatus.FAILED
|
||||
task.error = str(e)
|
||||
task.completed_at = datetime.now()
|
||||
logger.error(f"Task {task_id} failed: {e}")
|
||||
|
||||
# Start execution
|
||||
future = asyncio.create_task(_execute())
|
||||
self._task_futures[task_id] = future
|
||||
|
||||
def get_task(self, task_id: str) -> Optional[Task]:
|
||||
"""Get task by ID"""
|
||||
return self._tasks.get(task_id)
|
||||
|
||||
def list_tasks(
|
||||
self,
|
||||
status: Optional[TaskStatus] = None,
|
||||
limit: int = 100
|
||||
) -> List[Task]:
|
||||
"""
|
||||
List tasks with optional filtering
|
||||
|
||||
Args:
|
||||
status: Filter by status
|
||||
limit: Maximum number of tasks to return
|
||||
|
||||
Returns:
|
||||
List of tasks
|
||||
"""
|
||||
tasks = list(self._tasks.values())
|
||||
|
||||
if status:
|
||||
tasks = [t for t in tasks if t.status == status]
|
||||
|
||||
# Sort by created_at descending
|
||||
tasks.sort(key=lambda t: t.created_at, reverse=True)
|
||||
|
||||
return tasks[:limit]
|
||||
|
||||
def update_progress(
|
||||
self,
|
||||
task_id: str,
|
||||
current: int,
|
||||
total: int,
|
||||
message: str = ""
|
||||
):
|
||||
"""
|
||||
Update task progress
|
||||
|
||||
Args:
|
||||
task_id: Task ID
|
||||
current: Current progress
|
||||
total: Total steps
|
||||
message: Progress message
|
||||
"""
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return
|
||||
|
||||
percentage = (current / total * 100) if total > 0 else 0
|
||||
task.progress = TaskProgress(
|
||||
current=current,
|
||||
total=total,
|
||||
percentage=percentage,
|
||||
message=message
|
||||
)
|
||||
|
||||
def cancel_task(self, task_id: str) -> bool:
|
||||
"""
|
||||
Cancel a running task
|
||||
|
||||
Args:
|
||||
task_id: Task ID
|
||||
|
||||
Returns:
|
||||
True if cancelled, False otherwise
|
||||
"""
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return False
|
||||
|
||||
# Cancel future if running
|
||||
future = self._task_futures.get(task_id)
|
||||
if future and not future.done():
|
||||
future.cancel()
|
||||
|
||||
# Update task status
|
||||
task.status = TaskStatus.CANCELLED
|
||||
task.completed_at = datetime.now()
|
||||
logger.info(f"Cancelled task {task_id}")
|
||||
return True
|
||||
|
||||
async def _cleanup_loop(self):
|
||||
"""Periodically clean up old completed tasks"""
|
||||
while self._running:
|
||||
try:
|
||||
await asyncio.sleep(api_config.task_cleanup_interval)
|
||||
self._cleanup_old_tasks()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in cleanup loop: {e}")
|
||||
|
||||
def _cleanup_old_tasks(self):
|
||||
"""Remove old completed/failed tasks"""
|
||||
cutoff_time = datetime.now() - timedelta(seconds=api_config.task_retention_time)
|
||||
|
||||
tasks_to_remove = []
|
||||
for task_id, task in self._tasks.items():
|
||||
if task.status in [TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED]:
|
||||
if task.completed_at and task.completed_at < cutoff_time:
|
||||
tasks_to_remove.append(task_id)
|
||||
|
||||
for task_id in tasks_to_remove:
|
||||
del self._tasks[task_id]
|
||||
if task_id in self._task_futures:
|
||||
del self._task_futures[task_id]
|
||||
|
||||
if tasks_to_remove:
|
||||
logger.info(f"Cleaned up {len(tasks_to_remove)} old tasks")
|
||||
|
||||
|
||||
# Global task manager instance
|
||||
task_manager = TaskManager()
|
||||
|
||||
58
api/tasks/models.py
Normal file
58
api/tasks/models.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Task data models
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TaskStatus(str, Enum):
|
||||
"""Task status"""
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class TaskType(str, Enum):
|
||||
"""Task type"""
|
||||
VIDEO_GENERATION = "video_generation"
|
||||
|
||||
|
||||
class TaskProgress(BaseModel):
|
||||
"""Task progress information"""
|
||||
current: int = 0
|
||||
total: int = 0
|
||||
percentage: float = 0.0
|
||||
message: str = ""
|
||||
|
||||
|
||||
class Task(BaseModel):
|
||||
"""Task model"""
|
||||
task_id: str
|
||||
task_type: TaskType
|
||||
status: TaskStatus = TaskStatus.PENDING
|
||||
|
||||
# Progress tracking
|
||||
progress: Optional[TaskProgress] = None
|
||||
|
||||
# Result
|
||||
result: Optional[Any] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
# Metadata
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
|
||||
# Request parameters (for reference)
|
||||
request_params: Optional[dict] = None
|
||||
|
||||
class Config:
|
||||
json_encoders = {
|
||||
datetime: lambda v: v.isoformat()
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ dependencies = [
|
||||
"html2image>=2.0.7",
|
||||
"streamlit>=1.40.0",
|
||||
"openai>=2.6.0",
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"python-multipart>=0.0.12",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -56,6 +56,7 @@ class VideoGeneratorService:
|
||||
n_scenes: int = 5, # Only used in generate mode; ignored in fixed mode
|
||||
voice_id: str = "zh-CN-YunjianNeural",
|
||||
output_path: Optional[str] = None,
|
||||
use_uuid_filename: bool = False, # Use UUID instead of timestamp for filename
|
||||
|
||||
# === LLM Parameters ===
|
||||
min_narration_words: int = 5,
|
||||
@@ -194,10 +195,17 @@ class VideoGeneratorService:
|
||||
|
||||
# Auto-generate output path if not provided
|
||||
if output_path is None:
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
# Use first 10 chars of final_title for filename
|
||||
safe_name = final_title[:10].replace('/', '_').replace(' ', '_')
|
||||
output_path = f"output/{timestamp}_{safe_name}.mp4"
|
||||
if use_uuid_filename:
|
||||
# API mode: use UUID for filename
|
||||
import uuid
|
||||
filename = str(uuid.uuid4()).replace('-', '')
|
||||
output_path = f"output/{filename}.mp4"
|
||||
else:
|
||||
# Default mode: use timestamp + title
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
# Use first 10 chars of final_title for filename
|
||||
safe_name = final_title[:10].replace('/', '_').replace(' ', '_')
|
||||
output_path = f"output/{timestamp}_{safe_name}.mp4"
|
||||
|
||||
# Ensure output directory exists
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -158,6 +158,32 @@ async def edge_tts(
|
||||
raise RuntimeError("Edge TTS failed without error (unexpected)")
|
||||
|
||||
|
||||
def get_audio_duration(audio_path: str) -> float:
|
||||
"""
|
||||
Get audio file duration in seconds
|
||||
|
||||
Args:
|
||||
audio_path: Path to audio file
|
||||
|
||||
Returns:
|
||||
Duration in seconds
|
||||
"""
|
||||
try:
|
||||
# Try using ffmpeg-python
|
||||
import ffmpeg
|
||||
probe = ffmpeg.probe(audio_path)
|
||||
duration = float(probe['format']['duration'])
|
||||
return duration
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get audio duration: {e}, using estimate")
|
||||
# Fallback: estimate based on file size (very rough)
|
||||
import os
|
||||
file_size = os.path.getsize(audio_path)
|
||||
# Assume ~16kbps for MP3, so 2KB per second
|
||||
estimated_duration = file_size / 2000
|
||||
return max(1.0, estimated_duration) # At least 1 second
|
||||
|
||||
|
||||
async def list_voices(locale: str = None, retry_count: int = _RETRY_COUNT, retry_delay: float = _RETRY_DELAY) -> list[str]:
|
||||
"""
|
||||
List all available voices for Edge TTS
|
||||
|
||||
46
start_api.py
Executable file
46
start_api.py
Executable file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Start ReelForge API Server
|
||||
|
||||
Run this script to start the FastAPI server:
|
||||
uv run python start_api.py
|
||||
|
||||
Or with custom settings:
|
||||
uv run python start_api.py --host 0.0.0.0 --port 8080 --reload
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import uvicorn
|
||||
|
||||
|
||||
def main():
|
||||
"""Start API server"""
|
||||
parser = argparse.ArgumentParser(description="Start ReelForge API Server")
|
||||
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to")
|
||||
parser.add_argument("--port", type=int, default=8000, help="Port to bind to")
|
||||
parser.add_argument("--reload", action="store_true", help="Enable auto-reload")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"""
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ ReelForge API Server ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
|
||||
Starting server at http://{args.host}:{args.port}
|
||||
API Docs: http://{args.host}:{args.port}/docs
|
||||
ReDoc: http://{args.host}:{args.port}/redoc
|
||||
|
||||
Press Ctrl+C to stop the server
|
||||
""")
|
||||
|
||||
uvicorn.run(
|
||||
"api.app:app",
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
reload=args.reload,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
202
uv.lock
generated
202
uv.lock
generated
@@ -156,6 +156,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/62/47452306e84d4d2e67f9c559380aeb230f5e6ca84fafb428dd36b96a99ba/altair-4.2.2-py3-none-any.whl", hash = "sha256:8b45ebeaf8557f2d760c5c77b79f02ae12aee7c46c27c06014febab6f849bc87", size = 813630 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/a6/dc46877b911e40c00d395771ea710d5e77b6de7bacd5fdcd78d70cc5a48f/annotated_doc-0.0.3.tar.gz", hash = "sha256:e18370014c70187422c33e945053ff4c286f453a984eba84d0dbfa0c935adeda", size = 5535 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/02/b7/cf592cb5de5cb3bade3357f8d2cf42bf103bbe39f459824b4939fd212911/annotated_doc-0.0.3-py3-none-any.whl", hash = "sha256:348ec6664a76f1fd3be81f43dffbee4c7e8ce931ba71ec67cc7f4ade7fbbb580", size = 5488 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
@@ -570,6 +579,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.120.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/0e/7f29e8f7219e4526747db182e1afb5a4b6abc3201768fb38d81fa2536241/fastapi-0.120.0.tar.gz", hash = "sha256:6ce2c1cfb7000ac14ffd8ddb2bc12e62d023a36c20ec3710d09d8e36fab177a0", size = 337603 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/60/7a639ceaba54aec4e1d5676498c568abc654b95762d456095b6cb529b1ca/fastapi-0.120.0-py3-none-any.whl", hash = "sha256:84009182e530c47648da2f07eb380b44b69889a4acfd9e9035ee4605c5cfc469", size = 108243 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastmcp"
|
||||
version = "2.12.5"
|
||||
@@ -777,6 +801,42 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httptools"
|
||||
version = "0.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521 },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954 },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875 },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004 },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655 },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440 },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694 },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180 },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268 },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743 },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619 },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631 },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910 },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
@@ -2042,6 +2102,7 @@ dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "comfykit" },
|
||||
{ name = "edge-tts" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "fastmcp" },
|
||||
{ name = "ffmpeg-python" },
|
||||
{ name = "html2image" },
|
||||
@@ -2050,8 +2111,10 @@ dependencies = [
|
||||
{ name = "openai" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "streamlit" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -2066,6 +2129,7 @@ requires-dist = [
|
||||
{ name = "certifi", specifier = ">=2025.10.5" },
|
||||
{ name = "comfykit", specifier = ">=0.1.0" },
|
||||
{ name = "edge-tts", specifier = ">=7.2.3" },
|
||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||
{ name = "fastmcp", specifier = ">=2.0.0" },
|
||||
{ name = "ffmpeg-python", specifier = ">=0.2.0" },
|
||||
{ name = "html2image", specifier = ">=2.0.7" },
|
||||
@@ -2076,9 +2140,11 @@ requires-dist = [
|
||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.12" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6.0" },
|
||||
{ name = "streamlit", specifier = ">=1.40.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.32.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
@@ -2483,6 +2549,55 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109 },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
standard = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "httptools" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||
{ name = "watchfiles" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvloop"
|
||||
version = "0.22.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677 },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819 },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267 },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769 },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307 },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970 },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343 },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562 },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051 },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101 },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360 },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783 },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065 },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchdog"
|
||||
version = "6.0.0"
|
||||
@@ -2501,6 +2616,93 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722 },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088 },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923 },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046 },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473 },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598 },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210 },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485 },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812 },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196 },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042 },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410 },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405 },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976 },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936 },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147 },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280 },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909 },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389 },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964 },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826 },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919 },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099 },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664 },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154 },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408 },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968 },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096 },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040 },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072 },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websocket-client"
|
||||
version = "1.9.0"
|
||||
|
||||
Reference in New Issue
Block a user