diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index e61e6f7..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f661d32 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# macOS +.DS_Store + +# Node +node_modules/ + +# Python +__pycache__/ +*.pyc +.venv/ + +# IDE +.vscode/ +.idea/ + +# Logs +*.log + +# Environment +.env +.env.local diff --git a/docs/.DS_Store b/docs/.DS_Store deleted file mode 100644 index 5008ddf..0000000 Binary files a/docs/.DS_Store and /dev/null differ diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 827e62f..0000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "meijiaka-zj", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} diff --git a/package.json b/package.json deleted file mode 100644 index 0967ef4..0000000 --- a/package.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/python-api/app/ai/providers/kling_dto.py b/python-api/app/ai/providers/kling_dto.py deleted file mode 100644 index 1f00bcc..0000000 --- a/python-api/app/ai/providers/kling_dto.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -Kling AI Provider DTO -===================== - -Provider 层数据模型,封装 Kling API 返回结构。 -禁止向业务层泄漏裸 dict[str, Any]。 -""" - -from pydantic import BaseModel, Field - -from app.schemas.enums import KlingTaskStatus - - -class KlingVideoResult(BaseModel): - """Kling 视频生成结果""" - - task_id: str | None = Field(None, alias="task_id") - task_status: KlingTaskStatus | None = Field(None, alias="task_status") - task_status_msg: str | None = Field(None, alias="task_status_msg") - task_result: dict | None = Field(None, alias="task_result") - - -class KlingImageResult(BaseModel): - """Kling 图片生成结果""" - - task_id: str | None = Field(None, alias="task_id") - task_status: KlingTaskStatus | None = Field(None, alias="task_status") - task_status_msg: str | None = Field(None, alias="task_status_msg") - task_result: dict | None = Field(None, alias="task_result") - - -class KlingVoiceResult(BaseModel): - """Kling 自定义音色结果""" - - task_id: str | None = Field(None, alias="task_id") - task_status: KlingTaskStatus | None = Field(None, alias="task_status") - task_result: dict | None = Field(None, alias="task_result") - - -class KlingElementResult(BaseModel): - """Kling 主体创建结果""" - - task_id: str | None = Field(None, alias="task_id") - task_status: KlingTaskStatus | None = Field(None, alias="task_status") - task_result: dict | None = Field(None, alias="task_result") diff --git a/python-api/app/api/v1/ai_models.py b/python-api/app/api/v1/ai_models.py deleted file mode 100644 index 0e07b69..0000000 --- a/python-api/app/api/v1/ai_models.py +++ /dev/null @@ -1,552 +0,0 @@ -""" -AI 模型管理与生成 API -===================== - -提供模型列表查询、文本生成、脚本生成、润色等功能。 - -模型配置存储在 config/ai_models.yaml,支持热重载。 -""" - -import logging - -logger = logging.getLogger(__name__) - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel, Field - -from app.ai.model_router import get_model_router -from app.core.config_loader import get_config_loader -from app.schemas.common import ApiResponse, success_response -from app.services.ai_response_utils import ( - safe_parse_ai_json_response, - validate_and_normalize_shots, - validate_shots_structure, -) - -router = APIRouter() - - -# ============ 请求/响应 Schema ============ - - -class PlatformResponse(BaseModel): - """平台响应""" - - id: str - name: str - provider: str - - -class ModelResponse(BaseModel): - """模型响应""" - - id: str - platform_id: str - model_name: str - display_name: str - capabilities: list[str] - default_params: dict - is_enabled: bool - full_model_id: str - - -class GenerateRequest(BaseModel): - """生成请求""" - - prompt: str = Field(..., description="提示词") - model_id: str | None = Field(None, description="指定模型 ID") - task_type: str | None = Field( - None, description="任务类型,用于自动选模型: script/polish" - ) - temperature: float | None = Field(None, description="随机性 (0-2)") - max_tokens: int | None = Field(None, description="最大生成长度") - - -class GenerateResponse(BaseModel): - """生成响应""" - - content: str - model: str - usage: dict | None - - -class HealthResponse(BaseModel): - """健康检查响应""" - - status: str - total_models: int - available_models: int - models: list[dict] - - -# ============ API 路由 ============ - - -@router.get("/platforms", response_model=ApiResponse[list[PlatformResponse]]) -async def list_platforms(): - """获取所有平台列表""" - config_loader = get_config_loader() - platforms = config_loader.get_all_platforms() - - return success_response( - data=[ - PlatformResponse( - id=p.id, - name=p.name, - provider=p.provider, - ) - for p in platforms - ] - ) - - -@router.get("/models", response_model=ApiResponse[list[ModelResponse]]) -async def list_models(capability: str | None = None): - """获取模型列表 - - Args: - capability: 按能力过滤,如 script、polish、chat - """ - router = await get_model_router() - models = router.list_models(capability=capability) - - return success_response( - data=[ - ModelResponse( - id=m["id"], - platform_id=m["platform_id"], - model_name=m["model_name"], - display_name=m["display_name"], - capabilities=m["capabilities"], - default_params=m["default_params"], - is_enabled=True, # 列表中的都是启用的 - full_model_id=f"{m['platform_id']}/{m['id']}", - ) - for m in models - ] - ) - - -@router.post("/generate", response_model=ApiResponse[GenerateResponse]) -async def generate_text(data: GenerateRequest): - """文本生成(自动路由到对应平台)""" - router = await get_model_router() - - try: - result = await router.generate( - prompt=data.prompt, - model_id=data.model_id, - task_type=data.task_type, - temperature=data.temperature, - max_tokens=data.max_tokens, - ) - - return success_response( - data=GenerateResponse( - content=result.content, - model=result.model, - usage=result.usage, - ) - ) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/health", response_model=ApiResponse[HealthResponse]) -async def health_check(model_id: str | None = None): - """检查模型健康状态""" - router = await get_model_router() - - health_results = await router.health_check(model_id) - - models_status = [] - available_count = 0 - - for mid, health in health_results.items(): - models_status.append( - { - "id": mid, - "name": health.name, - "is_available": health.is_available, - "response_time": health.response_time, - "last_error": health.last_error, - } - ) - if health.is_available: - available_count += 1 - - return success_response( - data={ - "status": "healthy" if available_count > 0 else "unhealthy", - "total_models": len(models_status), - "available_models": available_count, - "models": models_status, - } - ) - - -@router.get("/platforms/{platform_id}/test", response_model=ApiResponse[dict]) -async def test_platform_connection(platform_id: str): - """测试平台连接""" - from app.ai.model_router import PlatformInstance - - config_loader = get_config_loader() - platform = config_loader.get_platform(platform_id) - - if not platform: - raise HTTPException(status_code=404, detail="平台不存在") - - try: - # PlatformInstance 自动从 Settings 读取 API Key - instance = PlatformInstance( - { - "id": platform.id, - "name": platform.name, - "provider": platform.provider, - "base_url": platform.base_url, - } - ) - - # 尝试调用 - result = await instance.provider.health_check() - - return success_response( - data={ - "platform_id": platform_id, - "success": result.is_available, - "response_time": result.response_time, - "message": "连接成功" if result.is_available else result.last_error, - } - ) - except Exception as e: - return success_response( - data={ - "platform_id": platform_id, - "success": False, - "error": str(e), - } - ) - - -@router.post("/reload", response_model=ApiResponse[dict]) -async def reload_config(): - """重新加载配置文件""" - router = await get_model_router() - reloaded = router.reload_config() - - if reloaded: - return success_response(data={"reloaded": True}, message="配置已重新加载") - else: - return success_response(data={"reloaded": False}, message="配置文件无变化") - - -# ============================================================================= -# Prompt 模板 API -# ============================================================================= - -from app.ai.prompts import ( - SCRIPT_TYPES, - VIDEO_STYLES, - PolishPromptBuilder, - ScriptPromptBuilder, -) - - -class PromptTemplatesResponse(BaseModel): - """Prompt 模板配置响应""" - - script_types: list[dict] - video_styles: list[dict] - tones: list[str] - - -class ScriptGenerateRequest(BaseModel): - """脚本生成请求""" - - topic: str = Field(..., description="脚本主题", example="水电改造的3个致命错误") - duration: int = Field(30, ge=15, le=120, description="视频时长(秒)") - script_type: str = Field("干货型", description="脚本类型") - video_style: str = Field("口播", description="视频风格") - tone: str | None = Field(None, description="语气风格") - requirements: str | None = Field(None, description="额外要求") - model_id: str | None = Field(None, description="指定模型ID,默认使用系统默认模型") - - -class ScriptGenerateResponse(BaseModel): - """脚本生成响应 - 针对前端展示优化""" - - success: bool - script: list[ - dict | None - ] # 镜头列表,包含 shot_number, type, scene/prompt, voiceover, duration, word_count - total_duration: int | None # 预计总时长(秒) - target_duration: int # 目标时长(秒) - total_word_count: int | None # 总字数(供前端展示) - segment_count: int | None # 分镜数量(供前端展示) - empty_shot_count: int | None # 空镜数量(供前端展示) - script_type: str - model: str - usage: dict | None - error: str | None - raw_content: str | None - - -class PolishRequest(BaseModel): - """润色请求""" - - content: str = Field(..., description="需要润色的内容") - polish_type: str = Field("voiceover", description="润色类型:scene/voiceover") - model_id: str | None = Field(None, description="指定模型ID") - - -class PolishResponse(BaseModel): - """润色响应""" - - success: bool - original: str - polished: str | None - polish_type: str - model: str - usage: dict | None - - -@router.get("/prompts/templates", response_model=ApiResponse[PromptTemplatesResponse]) -async def get_prompt_templates(): - """ - 获取所有可用的 Prompt 模板配置 - - 包括脚本类型、视频风格、语气风格等选项。 - """ - return success_response( - data={ - "script_types": [ - { - "id": key, - "name": value["name"], - "description": value["description"], - "key_points": value["key_points"], - } - for key, value in SCRIPT_TYPES.items() - if key != "default" - ], - "video_styles": [ - { - "id": key, - "name": value["name"], - "description": value["description"], - } - for key, value in VIDEO_STYLES.items() - ], - "tones": ["专业", "亲和", "幽默", "严肃", "激情"], - } - ) - - -@router.post("/prompts/build", response_model=ApiResponse[dict]) -async def build_system_prompt( - duration: int = 30, - script_type: str = "干货型", - video_style: str = "口播", - tone: str | None = None, -): - """ - 构建系统 Prompt(用于调试和预览) - - 返回构建好的系统 Prompt,可用于前端预览或调试。 - """ - builder = ScriptPromptBuilder() - prompt = builder.build( - duration=duration, - script_type=script_type, - video_style=video_style, - industry="家装", - tone=tone, - ) - - return success_response( - data={ - "system_prompt": prompt, - "length": len(prompt), - "parameters": { - "duration": duration, - "script_type": script_type, - "video_style": video_style, - "tone": tone, - }, - } - ) - - -@router.post("/scripts/generate", response_model=ApiResponse[ScriptGenerateResponse]) -async def generate_script(data: ScriptGenerateRequest): - """ - 生成家装行业短视频脚本 - - 使用专业的 Prompt 模板生成包含分镜+空镜的混合脚本。 - 针对前端展示优化,返回分镜数、空镜数、总字数等统计信息。 - """ - router = await get_model_router() - - # 构建系统 Prompt - builder = ScriptPromptBuilder() - system_prompt = builder.build( - duration=data.duration, - script_type=data.script_type, - video_style=data.video_style, - industry="家装", - tone=data.requirements, - custom_requirements=data.requirements, - ) - - # 构建用户输入 - user_prompt = f"""主题是"{data.topic}" - -要求: -1. 严格按照时长要求控制 -2. 每个镜头的配音字数必须匹配时长(4-5字/秒) -3. 空镜必须有画外音,不能为空 -4. 只返回JSON数组,不要有其他文字""" - - full_prompt = f"{system_prompt}\n\n【用户输入】\n{user_prompt}\n\n请生成脚本,只返回JSON数组:" - - # 调用模型 - try: - result = await router.generate( - prompt=full_prompt, - model_id=data.model_id, - task_type="script", - temperature=0.7, - max_tokens=2500, - ) - - # 安全地解析 JSON 响应 - success_parsed, parsed_data, error_msg = safe_parse_ai_json_response( - result.content - ) - - if not success_parsed: - logger.error(f"AI 响应解析失败: {error_msg}") - return success_response( - data={ - "success": False, - "script": None, - "total_duration": None, - "target_duration": data.duration, - "total_word_count": None, - "segment_count": None, - "empty_shot_count": None, - "script_type": data.script_type, - "model": result.model, - "usage": result.usage, - "error": error_msg or "JSON解析失败", - "raw_content": result.content, - } - ) - - # 验证并标准化分镜数据 - try: - script = validate_and_normalize_shots(parsed_data) - except Exception as e: - logger.error(f"分镜数据标准化失败: {e}") - return success_response( - data={ - "success": False, - "script": None, - "total_duration": None, - "target_duration": data.duration, - "total_word_count": None, - "segment_count": None, - "empty_shot_count": None, - "script_type": data.script_type, - "model": result.model, - "usage": result.usage, - "error": f"分镜数据格式错误: {e}", - "raw_content": result.content, - } - ) - - # 验证分镜结构 - is_valid, validation_errors = validate_shots_structure(script) - if not is_valid: - logger.warning(f"分镜结构验证失败: {validation_errors}") - # 继续处理,但记录警告 - - # 计算统计信息(供前端展示) - total_duration = sum( - int(shot.get("duration", "5s").rstrip("s秒")) - for shot in script - if isinstance(shot, dict) - ) - total_word_count = sum( - len(shot.get("voiceover", "")) for shot in script if isinstance(shot, dict) - ) - segment_count = sum( - 1 - for shot in script - if isinstance(shot, dict) and shot.get("type") == "segment" - ) - empty_shot_count = sum( - 1 - for shot in script - if isinstance(shot, dict) and shot.get("type") == "empty_shot" - ) - - return success_response( - data={ - "success": True, - "script": script, - "total_duration": total_duration, - "target_duration": data.duration, - "total_word_count": total_word_count, - "segment_count": segment_count, - "empty_shot_count": empty_shot_count, - "script_type": data.script_type, - "model": result.model, - "usage": result.usage, - "error": None, - "raw_content": None, - } - ) - - except Exception as e: - raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}") - - -@router.post("/scripts/polish", response_model=ApiResponse[PolishResponse]) -async def polish_script_content(data: PolishRequest): - """ - 润色脚本内容 - - 对场景描述或口播文案进行专业润色。 - """ - router = await get_model_router() - - # 构建润色 Prompt - builder = PolishPromptBuilder() - system_prompt = builder.build(data.polish_type) - - full_prompt = f"{system_prompt}\n\n【待润色内容】\n{data.content}\n\n请润色:" - - # 调用模型 - try: - result = await router.generate( - prompt=full_prompt, - model_id=data.model_id, - task_type="polish", - temperature=0.6, - max_tokens=1000, - ) - - return success_response( - data={ - "success": True, - "original": data.content, - "polished": result.content, - "polish_type": data.polish_type, - "model": result.model, - "usage": result.usage, - } - ) - - except Exception as e: - raise HTTPException(status_code=500, detail=f"润色失败: {str(e)}") diff --git a/python-api/app/api/v1/klingai.py b/python-api/app/api/v1/klingai.py deleted file mode 100644 index a0cfc8b..0000000 --- a/python-api/app/api/v1/klingai.py +++ /dev/null @@ -1,1046 +0,0 @@ -""" -KlingAI (可灵 AI) API 路由 -========================== - -提供视频生成、图像生成、对口型等功能。 -""" - -import logging -from typing import Any - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel, Field - -from app.ai.providers.klingai_provider import KlingAIProvider -from app.config import get_settings -from app.core.config_loader import get_config_loader -from app.schemas.common import ApiResponse, success_response - -logger = logging.getLogger(__name__) -router = APIRouter(prefix="/klingai", tags=["KlingAI"]) - - -# ============ 请求/响应 Schema ============ - - -class OmniVideoRequest(BaseModel): - """Omni-Video 视频生成请求(kling-v3-omni / kling-video-o1)""" - - prompt: str = Field( - ..., - description="视频描述提示词(不超过2500字符),支持 <<>>/<<>>/<<>> 引用语法", - example="一只<<>>在花园里奔跑,阳光明媚", - ) - model: str | None = Field("kling-v3-omni", description="模型: kling-v3-omni, kling-video-o1") - duration: int = Field(5, ge=3, le=15, description="视频时长(秒): 3/5/10/15,Omni支持3-15秒") - aspect_ratio: str = Field("16:9", description="宽高比: 16:9, 9:16, 1:1") - mode: str = Field("pro", description="生成模式: pro(高质量) 或 std(标准)") - sound: str = Field("on", description="声音控制: on=音画同出, off=无声") - negative_prompt: str | None = Field(None, description="负面提示词") - # 多镜头参数 - multi_shot: bool = Field(False, description="是否启用多镜头模式") - shot_type: str | None = Field( - None, description="分镜方式: customize=自定义, intelligence=智能分镜" - ) - multi_prompt: list[dict | None] = Field( - None, - description="多镜头提示词列表,每个元素包含 index, prompt, duration,最多6个分镜", - ) - # 参考资源 - image_list: list[dict | None] = Field(None, description="参考图片列表,最多4张") - element_list: list[dict | None] = Field( - None, description="主体参考列表,格式: [{'elementId': 123}], 最多7个" - ) - video_list: list[dict | None] = Field(None, description="参考视频列表") - callback_url: str | None = Field(None, description="回调通知地址") - external_task_id: str | None = Field(None, description="自定义任务ID") - - -class VideoGenerateRequest(BaseModel): - """文生视频请求""" - - prompt: str = Field( - ..., - description="视频描述提示词(不超过2500字符)", - example="一只猫在花园里玩耍", - ) - model: str | None = Field("kling-v2.6", description="视频模型: kling-v2.6, kling-v2.5-turbo") - duration: int = Field(5, ge=5, le=10, description="视频时长(秒): 5 或 10") - aspect_ratio: str = Field("16:9", description="宽高比: 16:9, 9:16, 1:1, 4:3, 3:4") - mode: str = Field("pro", description="生成模式: pro(高质量) 或 std(标准)") - negative_prompt: str | None = Field(None, description="负面提示词") - callback_url: str | None = Field(None, description="回调通知地址") - - -class VideoGenerateResponse(BaseModel): - """视频生成响应""" - - task_id: str - task_status: str - created_at: int - updated_at: int - - -class Image2VideoRequest(BaseModel): - """图生视频请求""" - - image_url: str = Field(..., description="输入图片 URL") - prompt: str | None = Field(None, description="视频运动描述提示词") - model: str | None = Field("kling-v2.6", description="视频模型") - duration: int = Field(5, ge=5, le=10, description="视频时长(秒)") - aspect_ratio: str | None = Field(None, description="宽高比") - mode: str = Field("pro", description="生成模式") - callback_url: str | None = Field(None, description="回调通知地址") - - -class IdentifyFaceRequest(BaseModel): - """人脸识别请求""" - - video_id: str | None = Field(None, description="KlingAI 生成的视频 ID") - video_url: str | None = Field(None, description="上传的视频 URL(与 videoId 二选一") - - -class IdentifyFaceResponse(BaseModel): - """人脸识别响应""" - - session_id: str - face_data: list[dict[str, Any]] - - -class FaceChooseItem(BaseModel): - """新版对口型人脸配置""" - - face_id: str = Field(..., description="人脸ID,由 identify-face 接口返回") - audio_id: str | None = Field(None, description="通过TTS生成的音频ID(与 sound_file 二选一)") - sound_file: str | None = Field(None, description="音频文件URL或Base64(与 audio_id 二选一)") - sound_start_time: int = Field(0, description="音频裁剪起点时间(ms)") - sound_end_time: int = Field(..., description="音频裁剪终点时间(ms)") - sound_insert_time: int = Field(0, description="裁剪后音频插入时间(ms)") - - -class AdvancedLipSyncRequest(BaseModel): - """新版对口型请求(advanced-lip-sync)""" - - session_id: str = Field(..., description="人脸识别返回的会话ID") - face_choose: list[FaceChooseItem] = Field(..., description="人脸对口型配置列表") - callback_url: str | None = Field(None, description="回调通知地址") - - -class OmniImageRequest(BaseModel): - """Omni-Image 图像生成请求""" - - prompt: str = Field(..., description="图像描述提示词") - model: str | None = Field("kling-image-o1", description="模型: kling-image-o1, kling-v3-omni") - aspect_ratio: str | None = Field("9:16", description="宽高比: 16:9/9:16/1:1/4:3/3:4") - resolution: str | None = Field("1k", description="清晰度: 1k/2k/4k") - result_type: str | None = Field("single", description="结果类型: single/series") - n: int | None = Field(1, description="生成数量 1-9") - element_list: list[dict[str, Any]] | None = Field(None, description="主体参考列表") - image_list: list[dict[str, Any]] | None = Field(None, description="参考图列表") - callback_url: str | None = Field(None, description="回调通知地址") - - -class ImageGenerateRequest(BaseModel): - """图像生成请求""" - - prompt: str = Field(..., description="图像描述提示词") - model: str | None = Field("kolors-v1", description="图像模型: kolors-v1") - width: int = Field(1024, description="图像宽度") - height: int = Field(1024, description="图像高度") - negative_prompt: str | None = Field(None, description="负面提示词") - callback_url: str | None = Field(None, description="回调通知地址") - - -class TaskStatusResponse(BaseModel): - """任务状态响应""" - - task_id: str - task_status: str # submitted, processing, succeed, failed - created_at: int - updated_at: int - video_url: str | None = None - image_url: str | None = None - error_message: str | None = None - - -class VirtualTryonRequest(BaseModel): - """虚拟试穿请求""" - - person_image_url: str = Field(..., description="人物图片 URL") - cloth_image_url: str = Field(..., description="衣服图片 URL") - callback_url: str | None = Field(None, description="回调通知地址") - - -# ============ 自定义音色 Schema ============ - - -class CreateCustomVoiceRequest(BaseModel): - """创建自定义音色请求""" - - voice_name: str = Field(..., description="音色名称(最多20字符)", example="我的音色") - audio_url: str | None = Field(None, description="音频文件URL(mp3/wav/mp4/mov)") - video_url: str | None = Field(None, description="视频文件URL") - video_id: str | None = Field( - None, description="历史作品ID(v2.6/sound=on/数字人/对口型生成的视频)" - ) - callback_url: str | None = Field(None, description="回调通知地址") - external_task_id: str | None = Field(None, description="自定义任务ID") - - -class ElementImage(BaseModel): - """主体参考图片(对应 KlingAI 官方格式 imageUrl)""" - - image_url: str = Field(..., description="图片URL") - name: str | None = Field(None, description="图片名称") - - -class ElementVideo(BaseModel): - """主体参考视频(对应 KlingAI 官方格式 videoUrl)""" - - video_url: str = Field(..., description="视频URL") - name: str | None = Field(None, description="视频名称") - - -class CreateElementRequest(BaseModel): - """创建主体请求""" - - element_name: str = Field(..., description="主体名称(最多20字符)", example="我的小猫") - element_description: str = Field( - ..., description="主体描述(最多100字符)", example="一只橘色的小猫,毛茸茸的" - ) - reference_type: str = Field("image_refer", description="参考类型: image_refer 或 video_refer") - element_image_list: list[ElementImage] | None = Field( - None, description="图片参考列表(图片定制时必填,第一个作为正面图)" - ) - element_video_list: list[ElementVideo] | None = Field( - None, description="视频参考列表(视频定制时必填,第一个作为正面视频)" - ) - element_voice_id: str | None = Field(None, description="音色ID,绑定音色到主体") - callback_url: str | None = Field(None, description="回调通知地址") - - -class ElementResponse(BaseModel): - """主体响应""" - - element_id: int | None = None - element_name: str | None = None - element_description: str | None = None - element_type: str | None = None # image_refer / video_refer - status: str | None = None - task_id: str | None = None - task_status: str | None = None - created_at: int | None = None - updated_at: int | None = None - element_image_list: dict | None = None - element_video_list: dict | None = None - element_voice_info: dict | None = None - owned_by: str | None = None - - -class CreateCustomVoiceResponse(BaseModel): - """创建自定义音色响应""" - - task_id: str - task_status: str - created_at: int - updated_at: int - - -class VoiceInfo(BaseModel): - """音色信息""" - - voice_id: str - voice_name: str - trial_url: str | None = None - owned_by: str | None = None - status: str | None = None - - -# ============ 辅助函数 ============ - - -async def get_klingai_provider() -> KlingAIProvider: - """获取 KlingAI Provider 实例 - - API Key 从 Settings 读取(符合配置规范) - """ - settings = get_settings() - config_loader = get_config_loader() - platform = config_loader.get_platform("klingai") - - if not platform: - raise HTTPException(status_code=404, detail="KlingAI 平台未配置") - - # 从 Settings 读取 AK/SK(符合配置规范:.env → Settings → 服务层) - access_key = settings.KLINGAI_ACCESS_KEY - secret_key = settings.KLINGAI_SECRET_KEY - - if not access_key or not secret_key: - raise HTTPException( - status_code=400, - detail="KlingAI Access Key 或 Secret Key 未配置,请设置环境变量 KLINGAI_ACCESS_KEY 和 KLINGAI_SECRET_KEY", - ) - - # 从 YAML 读取 base_url(模型配置) - base_url = platform.base_url if platform else None - - return KlingAIProvider( - { - "access_key": access_key, - "secret_key": secret_key, - "base_url": base_url or "https://api-beijing.klingai.com", - } - ) - - -# ============ API 路由 ============ - - -@router.post("/videos/omni", response_model=ApiResponse[VideoGenerateResponse]) -async def create_omni_video(data: OmniVideoRequest): - """ - Omni-Video 多模态视频生成 - - 支持文本、图片、主体、视频等多种输入方式组合生成视频。 - 适用于 kling-v3-omni 和 kling-video-o1 模型。 - - **特性:** - - 支持 3-15 秒视频生成 - - 支持多镜头和智能分镜 (shotType=intelligence) - - 支持引用主体、图片、视频作为参考 - - 支持 <<>>/<<>>/<<>> 语法引用提示词中的资源 - """ - try: - provider = await get_klingai_provider() - - result = await provider.generate_video_omni( - prompt=data.prompt, - model=data.model, - mode=data.mode, - aspect_ratio=data.aspect_ratio, - duration=data.duration, - sound=data.sound, - negative_prompt=data.negative_prompt, - multi_shot=data.multi_shot, - shot_type=data.shot_type, - multi_prompt=data.multi_prompt, - image_list=data.image_list, - element_list=data.element_list, - video_list=data.video_list, - callback_url=data.callback_url, - external_task_id=data.external_task_id, - ) - - return success_response(data=VideoGenerateResponse(**result)) - - except HTTPException: - raise - except Exception as e: - logger.error(f"Omni-Video 生成失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/videos/omni/{task_id}", response_model=ApiResponse[TaskStatusResponse]) -async def get_omni_video_task(task_id: str): - """ - 查询 Omni-Video 任务状态 - - 查询指定 Omni-Video 任务的执行状态和结果。 - """ - try: - provider = await get_klingai_provider() - - result = await provider.get_omni_video_task(task_id) - - return success_response(data=TaskStatusResponse(**result)) - - except HTTPException: - raise - except Exception as e: - logger.error(f"查询 Omni-Video 任务失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/videos/omni", response_model=ApiResponse[dict]) -async def list_omni_video_tasks( - page: int = 1, - page_size: int = 30, -): - """ - 查询 Omni-Video 任务列表 - - 查询历史 Omni-Video 任务列表。 - """ - try: - provider = await get_klingai_provider() - - result = await provider.list_omni_video_tasks( - page=page, - page_size=page_size, - ) - - return success_response(data=result) - - except HTTPException: - raise - except Exception as e: - logger.error(f"查询 Omni-Video 任务列表失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/videos/image2video", response_model=ApiResponse[VideoGenerateResponse]) -async def create_image_to_video(data: Image2VideoRequest): - """ - 图生视频 - - 根据输入图片生成视频。 - """ - try: - provider = await get_klingai_provider() - - result = await provider.generate_video_from_image( - image_url=data.image_url, - prompt=data.prompt, - model=data.model, - duration=data.duration, - aspect_ratio=data.aspect_ratio, - mode=data.mode, - callback_url=data.callback_url, - ) - - return success_response(data=VideoGenerateResponse(**result)) - - except HTTPException: - raise - except Exception as e: - logger.error(f"图生视频失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/videos/extend", response_model=ApiResponse[VideoGenerateResponse]) -async def extend_video( - video_id: str, - prompt: str | None = None, - duration: int = 5, - callback_url: str | None = None, -): - """ - 视频延长 - - 延长现有视频的时长。 - """ - try: - provider = await get_klingai_provider() - - result = await provider.extend_video( - video_id=video_id, - prompt=prompt, - duration=duration, - callback_url=callback_url, - ) - - return success_response(data=VideoGenerateResponse(**result)) - - except HTTPException: - raise - except Exception as e: - logger.error(f"视频延长失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/videos/identify-face", response_model=ApiResponse[IdentifyFaceResponse]) -async def identify_face(data: IdentifyFaceRequest): - """ - 对口型前置:人脸识别 - - 分析视频中的人脸信息,返回 sessionId 和 faceId,用于后续的 advanced-lip-sync。 - """ - try: - if not data.videoId and not data.videoUrl: - raise HTTPException(status_code=400, detail="必须提供 videoId 或 videoUrl") - - provider = await get_klingai_provider() - result = await provider.identify_face(video_id=data.videoId, video_url=data.videoUrl) - - return success_response(data=IdentifyFaceResponse(**result)) - - except HTTPException: - raise - except Exception as e: - logger.error(f"人脸识别失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/videos/advanced-lip-sync", response_model=ApiResponse[VideoGenerateResponse]) -async def create_advanced_lip_sync(data: AdvancedLipSyncRequest): - """ - 新版对口型视频生成 - - 基于 KlingAI advanced-lip-sync 接口,先调用 /videos/identify-face 获取 sessionId 和 faceId, - 再传入本接口生成对口型视频。 - - 支持 audio_id(TTS 生成)或 soundFile(外部音频 URL)驱动口型。 - """ - try: - provider = await get_klingai_provider() - - face_choose = [item.model_dump() for item in data.faceChoose] - - result = await provider.advanced_lip_sync( - session_id=data.sessionId, - face_choose=face_choose, - callback_url=data.callbackUrl, - ) - - return success_response(data=VideoGenerateResponse(**result)) - - except HTTPException: - raise - except Exception as e: - logger.error(f"对口型生成失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/videos/advanced-lip-sync/{taskId}", response_model=ApiResponse[TaskStatusResponse]) -async def get_advanced_lip_sync_task(taskId: str): - """ - 查询新版对口型任务状态 - """ - try: - provider = await get_klingai_provider() - result = await provider.get_advanced_lip_sync_task(taskId) - - taskStatus = result.get("taskStatus", "unknown") - videos = result.get("task_result", {}).get("videos", []) - videoUrl = videos[0].get("url") if videos else None - - return success_response( - data=TaskStatusResponse( - taskId=result.get("taskId", taskId), - taskStatus=taskStatus, - createdAt=result.get("createdAt", 0), - updatedAt=result.get("updatedAt", 0), - videoUrl=videoUrl, - errorMessage=result.get("taskStatus_msg"), - ) - ) - - except HTTPException: - raise - except Exception as e: - logger.error(f"查询对口型任务失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/images/omni", response_model=ApiResponse[VideoGenerateResponse]) -async def create_omni_image(data: OmniImageRequest): - """ - Omni-Image 图像生成 - - 支持文本、主体、参考图等多种输入方式组合生成图像。 - """ - try: - provider = await get_klingai_provider() - - result = await provider.generate_omni_image( - prompt=data.prompt, - model=data.model, - aspect_ratio=data.aspect_ratio, - resolution=data.resolution, - result_type=data.result_type, - n=data.n, - element_list=data.element_list, - image_list=data.image_list, - callback_url=data.callback_url, - ) - - return success_response(data=VideoGenerateResponse(**result)) - - except HTTPException: - raise - except Exception as e: - logger.error(f"Omni-Image 生成失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/images/omni/{task_id}", response_model=ApiResponse[TaskStatusResponse]) -async def get_omni_image_task(task_id: str): - """ - 查询 Omni-Image 任务状态 - """ - try: - provider = await get_klingai_provider() - result = await provider.get_omni_image_task(task_id) - - task_status = result.get("task_status", "unknown") - images = result.get("task_result", {}).get("images", []) - image_url = images[0].get("url") if images else None - - return success_response( - data=TaskStatusResponse( - task_id=result.get("task_id", task_id), - task_status=task_status, - created_at=result.get("created_at", 0), - updated_at=result.get("updated_at", 0), - image_url=image_url, - error_message=result.get("task_status_msg"), - ) - ) - - except HTTPException: - raise - except Exception as e: - logger.error(f"查询 Omni-Image 任务失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/images/generations", response_model=ApiResponse[VideoGenerateResponse]) -async def create_image(data: ImageGenerateRequest): - """ - 文生图 - - 根据文本描述生成图像。 - """ - try: - provider = await get_klingai_provider() - - result = await provider.generate_image( - prompt=data.prompt, - model=data.model, - width=data.width, - height=data.height, - negative_prompt=data.negativePrompt, - callback_url=data.callbackUrl, - ) - - return success_response(data=VideoGenerateResponse(**result)) - - except HTTPException: - raise - except Exception as e: - logger.error(f"图像生成失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/virtual-tryon", response_model=ApiResponse[VideoGenerateResponse]) -async def create_virtual_tryon(data: VirtualTryonRequest): - """ - 虚拟试穿 - - 将衣服虚拟试穿到人物身上。 - """ - try: - provider = await get_klingai_provider() - - result = await provider.virtual_tryon( - person_image_url=data.person_imageUrl, - cloth_image_url=data.cloth_imageUrl, - callback_url=data.callbackUrl, - ) - - return success_response(data=VideoGenerateResponse(**result)) - - except HTTPException: - raise - except Exception as e: - logger.error(f"虚拟试穿失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/tasks/{taskId}", response_model=ApiResponse[TaskStatusResponse]) -async def get_taskStatus( - taskId: str, - task_type: str = "video", -): - """ - 查询任务状态 - - 查询指定任务的执行状态和结果。 - - Args: - taskId: 任务 ID - task_type: 任务类型 (video, image2video, image, lip-sync, virtual-tryon) - """ - try: - provider = await get_klingai_provider() - - result = await provider.get_taskStatus( - task_id=taskId, - task_type=task_type, - ) - - return success_response(data=TaskStatusResponse(**result)) - - except HTTPException: - raise - except Exception as e: - logger.error(f"查询任务状态失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/tasks", response_model=ApiResponse[dict]) -async def list_tasks( - task_type: str = "video", - page: int = 1, - page_size: int = 10, -): - """ - 查询任务列表 - - 查询历史任务列表。 - """ - try: - provider = await get_klingai_provider() - - result = await provider.list_tasks( - task_type=task_type, - page=page, - page_size=page_size, - ) - - return success_response(data=result) - - except HTTPException: - raise - except Exception as e: - logger.error(f"查询任务列表失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -# ============ 主体管理 API ============ - - -@router.post("/elements", response_model=ApiResponse[ElementResponse]) -async def create_element(data: CreateElementRequest): - """ - 创建主体(自定义元素) - - 通过上传图片或视频创建可复用的主体,用于视频/图像生成时保持角色一致性。 - - 图片要求: - - 格式:jpg, jpeg, png - - 大小:≤10MB - - 数量:正面图 + 1-3张其他角度 - - 视频要求: - - 格式:mp4, mov - - 时长:3-8秒 - - 分辨率:1080P - - 大小:≤200MB - """ - try: - provider = await get_klingai_provider() - - imageList = None - if data.element_imageList: - imageList = { - "frontalImage": data.element_imageList[0].imageUrl, - "referImages": [ - {"imageUrl": img.imageUrl, "name": img.name} - for img in data.element_imageList[1:] - if img.imageUrl - ], - } - - videoList = None - if data.element_videoList: - videoList = { - "frontal_video": data.element_videoList[0].videoUrl, - "referVideos": [ - {"videoUrl": vid.videoUrl, "name": vid.name} - for vid in data.element_videoList[1:] - if vid.videoUrl - ], - } - - result = await provider.create_element( - element_name=data.elementName, - element_description=data.elementDescription, - reference_type=data.referenceType, - element_image_list=imageList, - element_video_list=videoList, - element_voice_id=data.element_voiceId, - callback_url=data.callbackUrl, - ) - - return success_response(data=ElementResponse(**result)) - - except HTTPException: - raise - except Exception as e: - logger.error(f"创建主体失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/elements", response_model=ApiResponse[list[ElementResponse]]) -async def list_elements(): - """ - 查询主体列表 - - 获取所有已创建的主体(自定义元素)列表。 - """ - try: - provider = await get_klingai_provider() - - result = await provider.list_elements() - - elements = [ElementResponse(**item) for item in result if isinstance(item, dict)] - - return success_response(data=elements) - - except HTTPException: - raise - except Exception as e: - logger.error(f"查询主体列表失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/elements/{elementId}", response_model=ApiResponse[ElementResponse]) -async def get_element(elementId: str): - """ - 查询单个主体详情 - - 获取指定主体的详细信息。 - """ - try: - provider = await get_klingai_provider() - - result = await provider.get_element(elementId) - - return success_response(data=ElementResponse(**result)) - - except HTTPException: - raise - except Exception as e: - logger.error(f"查询主体详情失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.delete("/elements/{elementId}", response_model=ApiResponse[dict]) -async def delete_element(elementId: str): - """ - 删除主体 - - 删除不再使用的主体(自定义元素)。 - """ - try: - provider = await get_klingai_provider() - - result = await provider.delete_element(elementId) - - return success_response(data=result) - - except HTTPException: - raise - except Exception as e: - logger.error(f"删除主体失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -# ============ 智能补全主体图 API ============ - - -class AiMultiShotRequest(BaseModel): - """智能补全主体图请求""" - - frontal_image: str = Field( - ..., description="主体正面参考图 URL", example="https://example.com/front.jpg" - ) - callback_url: str | None = Field(None, description="回调通知地址") - - -class AiMultiShotResponse(BaseModel): - """智能补全主体图响应""" - - task_id: str - task_status: str - created_at: int - updated_at: int - - -@router.post("/elements/ai-multi-shot", response_model=ApiResponse[AiMultiShotResponse]) -async def ai_multiShot(data: AiMultiShotRequest): - """ - 智能补全主体不同角度图片 - - 通过主体正面图,自动推理出该主体其他角度图片。 - 每次可生成3组结果供选择,每次扣减0.5积分。 - - 使用流程: - 1. 调用此接口传入正面图 - 2. 轮询查询任务状态 - 3. 获取生成的多组角度图片 - 4. 选择合适的图片创建主体 - """ - try: - provider = await get_klingai_provider() - - result = await provider.ai_multiShot( - frontal_image=data.frontalImage, - callback_url=data.callbackUrl, - ) - - return success_response(data=AiMultiShotResponse(**result)) - - except HTTPException: - raise - except Exception as e: - logger.error(f"智能补全主体图失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/elements/ai-multi-shot/{taskId}", response_model=ApiResponse[dict]) -async def get_ai_multiShot_task(taskId: str): - """ - 查询智能补全主体图任务状态 - - 获取指定任务的执行状态和生成的多角度图片结果。 - """ - try: - provider = await get_klingai_provider() - - result = await provider.get_ai_multiShot_task(taskId) - - return success_response(data=result) - - except HTTPException: - raise - except Exception as e: - logger.error(f"查询智能补全任务失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -# ============ 自定义音色 API ============ - - -@router.post("/voices/custom", response_model=ApiResponse[CreateCustomVoiceResponse]) -async def create_custom_voice(data: CreateCustomVoiceRequest): - """ - 创建自定义音色 - - 通过上传音频文件或引用历史视频创建自定义音色,用于对口型视频。 - - 音频要求: - - 格式:mp3, wav, mp4, mov - - 时长:5-30 秒 - - 人声干净、无杂音、单一人声 - """ - try: - provider = await get_klingai_provider() - - result = await provider.create_custom_voice( - voice_name=data.voiceName, - audio_url=data.audioUrl, - video_url=data.videoUrl, - video_id=data.videoId, - callback_url=data.callbackUrl, - external_task_id=data.externalTaskId, - ) - - return success_response(data=CreateCustomVoiceResponse(**result)) - - except HTTPException: - raise - except Exception as e: - logger.error(f"创建自定义音色失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/voices/custom", response_model=ApiResponse[list[VoiceInfo]]) -async def list_custom_voices(): - """ - 查询自定义音色列表 - - 获取所有已创建的自定义音色。 - """ - try: - provider = await get_klingai_provider() - - result = await provider.list_custom_voices() - - voices = [] - for item in result: - if isinstance(item, dict) and "task_result" in item: - task_result = item.get("task_result", {}) - voices_data = task_result.get("voices", []) - for voice in voices_data: - voices.append(VoiceInfo(**voice)) - - return success_response(data=voices) - - except HTTPException: - raise - except Exception as e: - logger.error(f"查询自定义音色列表失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/voices/custom/{voiceId}", response_model=ApiResponse[dict]) -async def get_custom_voice(voiceId: str): - """ - 查询单个自定义音色 - - 获取指定自定义音色的详细信息。 - """ - try: - provider = await get_klingai_provider() - - result = await provider.get_custom_voice(voiceId) - - return success_response(data=result) - - except HTTPException: - raise - except Exception as e: - logger.error(f"查询自定义音色失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/voices/presets", response_model=ApiResponse[list[VoiceInfo]]) -async def list_preset_voices(): - """ - 查询官方预设音色列表 - - 获取 KlingAI 提供的官方音色列表。 - """ - try: - provider = await get_klingai_provider() - - result = await provider.list_preset_voices() - - voices = [] - for item in result: - if isinstance(item, dict) and "task_result" in item: - task_result = item.get("task_result", {}) - voices_data = task_result.get("voices", []) - for voice in voices_data: - voices.append(VoiceInfo(**voice)) - - return success_response(data=voices) - - except HTTPException: - raise - except Exception as e: - logger.error(f"查询官方音色列表失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.delete("/voices/custom/{voiceId}", response_model=ApiResponse[dict]) -async def delete_custom_voice(voiceId: str): - """ - 删除自定义音色 - - 删除不再使用的自定义音色。 - """ - try: - provider = await get_klingai_provider() - - result = await provider.delete_custom_voice(voiceId) - - return success_response(data=result) - - except HTTPException: - raise - except Exception as e: - logger.error(f"删除自定义音色失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) diff --git a/python-api/app/api/v1/qiniu.py b/python-api/app/api/v1/qiniu.py deleted file mode 100644 index 2a5f22e..0000000 --- a/python-api/app/api/v1/qiniu.py +++ /dev/null @@ -1,339 +0,0 @@ -""" -七牛云对象存储 API 路由 -======================== - -提供音视频文件上传、管理和访问功能。 - -主要功能: -1. 生成上传凭证(客户端直传) -2. 服务端文件上传 -3. 声音克隆样本上传 -4. 文件删除和管理 -""" - -import contextlib -import logging -import os -import shutil -import tempfile -from pathlib import Path - -logger = logging.getLogger(__name__) - -from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile -from pydantic import BaseModel, Field - -from app.api.deps import get_current_user -from app.models.user import User -from app.schemas.common import ApiResponse, success_response -from app.services.qiniu_service import get_qiniu_service - -router = APIRouter(prefix="/qiniu", tags=["Qiniu Storage"]) - - -# ============ 请求/响应模型 ============ - - -class UploadTokenRequest(BaseModel): - """上传凭证请求""" - - key: str = Field(..., description="文件存储 Key") - expires: int = Field(3600, description="Token 有效期(秒)") - - -class UploadTokenResponse(BaseModel): - """上传凭证响应""" - - token: str - key: str - uploadUrl: str = "https://upload.qiniup.com" - - -class FileUploadResponse(BaseModel): - """文件上传响应""" - - key: str - url: str - hash: str - mimeType: str - fsize: int - isDuplicate: bool = False - message: str | None = None - existingTaskId: str | None = None # 当检测到重复任务时返回 - - -class DeleteFileRequest(BaseModel): - """删除文件请求""" - - key: str = Field(..., description="文件 Key") - - -# ============ API 路由 ============ - - -@router.post("/upload-token", response_model=ApiResponse[UploadTokenResponse]) -async def get_upload_token(request: UploadTokenRequest): - """ - 获取上传凭证(客户端直传) - - 前端获取 Token 后,可直接上传到七牛云,无需经过服务端。 - - 上传地址: https://upload.qiniup.com - 请求方式: POST (multipart/form-data) - 请求参数: - - token: 上传凭证(本接口返回) - - key: 文件存储 Key(本接口返回) - - file: 文件内容 - """ - try: - service = get_qiniu_service() - token = service.get_upload_token(request.key, request.expires) - - return success_response( - data=UploadTokenResponse( - token=token, key=request.key, uploadUrl="https://upload.qiniup.com" - ) - ) - - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - raise HTTPException(status_code=500, detail=f"生成上传凭证失败: {e}") - - -@router.post("/upload/audio", response_model=ApiResponse[FileUploadResponse]) -async def upload_audio( - file: UploadFile = File(..., description="音频文件(MP3, WAV, M4A, AAC, OGG)"), - userId: str | None = Form(None, description="用户ID(可选,用于目录隔离)"), -): - """ - 上传音频文件 - - 支持格式: MP3, WAV, M4A, AAC, OGG - 文件会自动存储到: audios/{userId}/{date}/{uuid}.{ext} - """ - service = get_qiniu_service() - - # 保存临时文件 - suffix = Path(file.filename).suffix if file.filename else ".mp3" - with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: - shutil.copyfileobj(file.file, tmp) - tmp_path = tmp.name - - try: - result = service.upload_audio(tmp_path, userId=userId) - return success_response(data=FileUploadResponse(**result)) - - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - raise HTTPException(status_code=500, detail=f"上传失败: {e}") - finally: - os.unlink(tmp_path) - - -@router.post("/upload/video", response_model=ApiResponse[FileUploadResponse]) -async def upload_video( - file: UploadFile = File(..., description="视频文件(MP4, MOV, AVI, WebM)"), - userId: str | None = Form(None, description="用户ID(可选,用于目录隔离)"), -): - """ - 上传视频文件 - - 支持格式: MP4, MOV, AVI, WebM - 文件会自动存储到: videos/{userId}/{date}/{uuid}.{ext} - """ - service = get_qiniu_service() - - suffix = Path(file.filename).suffix if file.filename else ".mp4" - with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: - shutil.copyfileobj(file.file, tmp) - tmp_path = tmp.name - - try: - result = service.upload_video(tmp_path, userId=userId) - return success_response(data=FileUploadResponse(**result)) - - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - raise HTTPException(status_code=500, detail=f"上传失败: {e}") - finally: - os.unlink(tmp_path) - - -async def _check_existing_avatar_task( - video_url: str, - user_id: str, -) -> dict | None: - """ - 检查是否有相同视频URL的正在进行的任务(从 Redis 读取) - - Returns: - 如果找到进行中的任务,返回 {'task_id': str, 'status': str} - 否则返回 None - """ - import json - - from app.core.redis_client import get_redis_client - from app.scheduler.registry import JobRegistry - - redis = get_redis_client() - registry = JobRegistry(redis) - job_ids = await registry.get_running_job_ids() - - for job_id in job_ids: - data = await redis.hgetall(f"job:{job_id}") - if not data: - continue - if data.get("type") != "avatar_clone": - continue - - params = {} - if "params" in data and data["params"]: - with contextlib.suppress(json.JSONDecodeError): - params = json.loads(data["params"]) - - if params.get("user_id") == user_id and params.get("video_url") == video_url: - avatar_status = data.get("avatar_status", data.get("status", "")) - return { - "task_id": job_id, - "status": avatar_status, - "voice_id": data.get("voice_id"), - "provider_element_id": data.get("provider_element_id"), - "video_url": video_url, - "file_size": 0, - } - return None - - -@router.post("/upload/avatar", response_model=ApiResponse[FileUploadResponse]) -async def upload_avatar( - file: UploadFile = File(..., description="形象克隆视频(MP4, MOV)"), - userId: str | None = Form(None, description="用户ID(可选,用于目录隔离)"), - fileHash: str | None = Form(None, description="前端计算的文件SHA256哈希,用于重复检测"), - current_user: User = Depends(get_current_user), -): - """ - 上传形象克隆视频 - - 用于形象克隆功能,上传的视频将同时用于创建自定义音色和主体。 - - KlingAI 要求: - - 格式: MP4, MOV - - 时长: 5-30 秒 (建议 5-8 秒) - - 大小: 不超过 200MB - - 分辨率: 高度 720px~2160px - - 内容: 写实风格人物正面特写,人脸清晰、无遮挡,视频中有清晰人声 - - 文件存储路径: meijiaka-zj/avatars/{userId}/{date}/{uuid}.{ext} - - 重复检测: - - 如果提供了 fileHash,会检查是否已有相同文件的任务在进行中 - - 返回的 isDuplicate 表示是否复用了已有资源 - - existingTaskId 表示已存在任务的ID(如果有) - """ - service = get_qiniu_service() - - # 使用当前登录用户的ID - effective_user_id = userId or str(current_user.id) - - suffix = Path(file.filename).suffix if file.filename else ".mp4" - with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: - shutil.copyfileobj(file.file, tmp) - tmp_path = tmp.name - - try: - result = service.upload_avatar_video( - tmp_path, - user_id=effective_user_id, - file_hash=fileHash, - ) - - # 如果七牛云返回了现有文件,检查数据库中是否有进行中的任务 - if result.get("isDuplicate") and result.get("url"): - existing_task = await _check_existing_avatar_task(result["url"], effective_user_id) - if existing_task: - logger.info( - f"Found existing avatar task for uploaded file: {existing_task['task_id']}" - ) - result["existingTaskId"] = existing_task["task_id"] - result["message"] = "检测到相同视频的任务正在进行中,已复用现有任务" - - return success_response(data=FileUploadResponse(**result)) - - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.exception("Upload avatar failed") - raise HTTPException(status_code=500, detail=f"上传失败: {e}") - finally: - os.unlink(tmp_path) - - -@router.get("/files/{key:path}", response_model=ApiResponse[dict]) -async def get_file_info(key: str): - """ - 获取文件信息 - - Args: - key: 文件存储 Key(路径格式) - """ - try: - service = get_qiniu_service() - # 根据 key 推断 bucket - bucket = service.image_bucket if "/images/" in key else service.video_bucket - info = service.get_file_info(bucket, key) - - if info is None: - raise HTTPException(status_code=404, detail="文件不存在") - - return success_response(data=info) - - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=f"获取文件信息失败: {e}") - - -@router.delete("/files/{key:path}", response_model=ApiResponse[dict]) -async def delete_file(key: str): - """ - 删除文件 - - Args: - key: 文件存储 Key - """ - try: - service = get_qiniu_service() - # 根据 key 推断 bucket - bucket = service.image_bucket if "/images/" in key else service.video_bucket - success = service.delete_file(bucket, key) - - return success_response( - data={ - "success": success, - "key": key, - "message": "删除成功" if success else "删除失败或文件不存在", - } - ) - - except Exception as e: - raise HTTPException(status_code=500, detail=f"删除失败: {e}") - - -@router.post("/refresh-cdn", response_model=ApiResponse[dict]) -async def refresh_cdn(keys: list[str]): - """ - 刷新 CDN 缓存 - - 文件更新后,调用此接口刷新 CDN 缓存,确保用户访问到最新内容。 - """ - try: - service = get_qiniu_service() - result = service.refresh_cdn(keys) - - return success_response(data=result) - - except Exception as e: - raise HTTPException(status_code=500, detail=f"刷新 CDN 失败: {e}") diff --git a/python-api/app/api/v1/video.py b/python-api/app/api/v1/video.py deleted file mode 100644 index 039ed54..0000000 --- a/python-api/app/api/v1/video.py +++ /dev/null @@ -1,511 +0,0 @@ -""" -视频生成 API 路由 -================ - -提供数字人视频、文生视频、图生视频功能。 -基于 KlingAI API 实现。 -""" - -import logging -import uuid -from datetime import datetime -from pathlib import Path - -from fastapi import APIRouter, File, Form, HTTPException, UploadFile -from fastapi.responses import FileResponse, StreamingResponse -from pydantic import BaseModel, Field - -from app.ai.providers.klingai_provider import KlingAIProvider -from app.config import get_settings -from app.core.config_loader import get_config_loader -from app.schemas.common import ApiResponse, success_response -from app.schemas.segment import Segment -from app.services.kling_video_service import get_kling_video_service - -router = APIRouter(prefix="/video", tags=["Video"]) - -# 视频文件存储目录 -VIDEO_STORAGE_DIR = Path("data/video") -VIDEO_STORAGE_DIR.mkdir(parents=True, exist_ok=True) - -# 上传文件临时目录 -UPLOAD_DIR = Path("data/uploads") -UPLOAD_DIR.mkdir(parents=True, exist_ok=True) - -logger = logging.getLogger(__name__) - - -# ============ 数据模型 ============ - - -class DigitalHuman(BaseModel): - """数字人信息""" - - id: str - name: str - desc: str - avatar_url: str | None = None - type: str = "preset" # preset, custom, upload - - -class VideoGenerateRequest(BaseModel): - """视频生成请求""" - - project_id: str = Field(..., description="项目ID") - human_id: int | None = Field(None, description="数字人主体ID(分镜类型使用)") - segments: list[Segment] = Field(..., description="分镜列表") - - -class VideoGenerateResponse(BaseModel): - """视频生成响应""" - - job_id: str = Field(..., description="作业ID") - task_id: str = Field(..., description="任务ID(与job_id相同)") - status: str = Field(..., description="作业状态") - message: str = Field(..., description="状态消息") - sse_url: str = Field(..., description="SSE进度流URL") - - -class VideoJobStatus(BaseModel): - """视频作业状态""" - - job_id: str - project_id: str - status: str # pending, processing, completed, partial, failed - progress: int - total_segments: int - completed_segments: int - failed_segments: int - created_at: float - updated_at: float - error_message: str | None = None - - -class ShotResult(BaseModel): - """单个分镜结果""" - - segment_id: str - type: str - status: str - task_id: str | None = None - video_url: str | None = None - local_path: str | None = None - error_message: str | None = None - - -class VideoJobDetail(BaseModel): - """视频作业详情""" - - job_id: str - project_id: str - status: str - progress: int - total_segments: int - completed_segments: int - failed_segments: int - segments: list[ShotResult] - created_at: float - updated_at: float - - -# ============ 内存存储 ============ - -# 数字人库 -digital_humans_db: dict[str, DigitalHuman] = { - "dh_001": DigitalHuman( - id="dh_001", - name="商务男士", - desc="专业稳重的商务形象,适合正式场合", - type="preset", - ), - "dh_002": DigitalHuman( - id="dh_002", - name="亲和女士", - desc="温和亲切的女性形象,适合讲解分享", - type="preset", - ), - "dh_003": DigitalHuman( - id="dh_003", - name="活力青年", - desc="年轻有活力的形象,适合轻松内容", - type="preset", - ), - "dh_004": DigitalHuman( - id="dh_004", name="知性女性", desc="知性优雅的形象,适合知识分享", type="preset" - ), -} - -# ============ 辅助函数 ============ - - -async def get_klingai_provider() -> KlingAIProvider: - """获取 KlingAI Provider 实例 - - API Key 从 Settings 读取(符合配置规范) - """ - settings = get_settings() - config_loader = get_config_loader() - platform = config_loader.get_platform("klingai") - - # 从 Settings 读取 AK/SK(符合配置规范:.env → Settings → 服务层) - access_key = settings.KLINGAI_ACCESS_KEY - secret_key = settings.KLINGAI_SECRET_KEY - - if not access_key or not secret_key: - raise HTTPException( - status_code=400, - detail="KlingAI 未配置,请设置 KLINGAI_ACCESS_KEY 和 KLINGAI_SECRET_KEY", - ) - - # 从 YAML 读取 base_url(模型配置) - base_url = platform.base_url if platform else None - - return KlingAIProvider( - { - "access_key": access_key, - "secret_key": secret_key, - "base_url": base_url or "https://api-beijing.klingai.com", - } - ) - - -# ============ 新版 API 路由(推荐) ============ - - -@router.post("/generate", response_model=ApiResponse[VideoGenerateResponse]) -async def create_video_generation(data: VideoGenerateRequest): - """ - 创建视频生成任务 - - 接收项目ID、数字人ID和分镜列表,创建视频生成作业。 - 支持 SSE 流式查询进度。 - - **分镜类型说明:** - - `segment`: 分镜(带数字人),使用 omni-video 接口,需要 human_id - - `empty_shot`: 空镜,使用文生图 + 图生视频流程 - - **调用流程:** - 1. 调用此接口创建任务,获取 job_id - 2. 使用 SSE 接口 `/video/jobs/{job_id}/stream` 监听进度 - 3. 或使用 `/video/jobs/{job_id}` 查询状态 - """ - try: - service = get_kling_video_service() - - # 转换分镜数据 - segments_data = [] - for segment in data.segments: - segments_data.append( - { - "id": segment.id, - "type": segment.type, - "scene": segment.scene, - "voiceover": segment.voiceover, - "voice_id": segment.voice_id, - } - ) - - # 创建作业 - job = await service.create_job( - project_id=data.project_id, - human_id=data.human_id, - segments_data=segments_data, - ) - - # 构建SSE URL - sse_url = f"/video/jobs/{job.job_id}/stream" - - return success_response( - data=VideoGenerateResponse( - job_id=job.job_id, - task_id=job.job_id, - status=job.status, - message="视频生成任务已创建", - sse_url=sse_url, - ) - ) - - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error(f"创建视频生成任务失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/jobs/{job_id}", response_model=ApiResponse[VideoJobDetail]) -async def get_video_job(job_id: str): - """ - 查询视频生成作业详情 - - 获取指定作业的详细信息和所有分镜的处理结果。 - """ - try: - service = get_kling_video_service() - job = service.get_job(job_id) - - if not job: - raise HTTPException(status_code=404, detail="作业不存在") - - # 构建分镜结果 - segments = [] - for segment in job.segments: - segments.append( - ShotResult( - segment_id=segment.id, - type=segment.type, - status=segment.status, - task_id=segment.provider_task_id, - video_url=segment.video_url, - local_path=segment.local_path, - error_message=segment.error_message, - ) - ) - - return success_response( - data=VideoJobDetail( - job_id=job.job_id, - project_id=job.project_id, - status=job.status, - progress=job.progress, - total_segments=len(job.segments), - completed_segments=sum(1 for s in job.segments if s.status.value == "completed"), - failed_segments=sum(1 for s in job.segments if s.status.value == "failed"), - segments=segments, - created_at=job.created_at, - updated_at=job.updated_at, - ) - ) - - except HTTPException: - raise - except Exception as e: - logger.error(f"查询作业详情失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/jobs/{job_id}/stream") -async def stream_video_job(job_id: str): - """ - SSE 流式获取视频生成进度 - - 使用 Server-Sent Events 实时推送视频生成进度。 - - **事件类型:** - - `start`: 开始生成 - - `processing`: 处理中(包含进度信息) - - `finalizing`: 完成整理 - - `complete`: 全部完成 - - `error`: 发生错误 - - **示例:** - ``` - const eventSource = new EventSource('/api/v1/video/jobs/{job_id}/stream'); - eventSource.onmessage = (e) => { - const data = JSON.parse(e.data); - console.log(data.progress + '%: ' + data.message); - }; - ``` - """ - try: - service = get_kling_video_service() - - # 验证作业存在 - job = service.get_job(job_id) - if not job: - raise HTTPException(status_code=404, detail="作业不存在") - - async def event_generator(): - """SSE 事件生成器""" - async for event in service.process_job_stream(job_id): - yield f"data: {__import__('json').dumps(event, ensure_ascii=False)}\n\n" - - return StreamingResponse( - event_generator(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - }, - ) - - except HTTPException: - raise - except Exception as e: - logger.error(f"流式获取作业进度失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/jobs/{job_id}/status", response_model=ApiResponse[VideoJobStatus]) -async def get_video_job_status(job_id: str): - """ - 获取视频生成作业状态(简化版) - """ - try: - service = get_kling_video_service() - status = service.get_job_status(job_id) - - if not status: - raise HTTPException(status_code=404, detail="作业不存在") - - return success_response( - data=VideoJobStatus( - job_id=str(status["job_id"]), - project_id=str(status["project_id"]), - status=str(status["status"]), - progress=int(status["progress"]), # type: ignore[arg-type] - total_segments=int(status["total_segments"]), # type: ignore[arg-type] - completed_segments=int(status["completed_segments"]), # type: ignore[arg-type] - failed_segments=int(status["failed_segments"]), # type: ignore[arg-type] - created_at=float(status["created_at"]), # type: ignore[arg-type] - updated_at=float(status["updated_at"]), # type: ignore[arg-type] - error_message=status.get("error_message"), - ) - ) - - except HTTPException: - raise - except Exception as e: - logger.error(f"获取作业状态失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -# ============ 数字人管理 ============ - - -@router.get("/library", response_model=ApiResponse[list[DigitalHuman]]) -async def get_digital_humans(): - """ - 获取数字人素材库 - - 返回系统预设的数字人列表。 - """ - try: - humans = list(digital_humans_db.values()) - return success_response(data=humans) - except Exception as e: - logger.error(f"获取数字人库失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/upload", response_model=ApiResponse[DigitalHuman]) -async def upload_video( - file: UploadFile = File(..., description="视频文件"), - name: str | None = Form(None, description="数字人名称"), -): - """ - 上传人物视频作为数字人素材 - - 文件要求: - - 格式:mp4, mov - - 时长:2-60秒 - - 分辨率:720p 或 1080p - """ - try: - # 验证文件格式 - allowed_types = ["video/mp4", "video/quicktime", "video/x-msvideo"] - if file.content_type not in allowed_types: - raise HTTPException( - status_code=400, - detail=f"不支持的文件格式: {file.content_type},请上传 mp4/mov 视频", - ) - - # 保存文件 - file_ext = Path(file.filename or "").suffix or ".mp4" - video_id = f"upload_{uuid.uuid4().hex[:16]}" - video_filename = f"{video_id}{file_ext}" - video_path = UPLOAD_DIR / video_filename - - content = await file.read() - video_path.write_bytes(content) - - logger.info(f"视频上传成功: {video_path}, 大小: {len(content)} bytes") - - # 创建数字人记录 - human = DigitalHuman( - id=video_id, - name=name or f"上传视频_{datetime.now().strftime('%m%d_%H%M')}", - desc="用户上传的自定义数字人", - type="upload", - avatar_url=f"/api/v1/video/{video_id}/thumbnail", - ) - - # 添加到数据库 - digital_humans_db[video_id] = human - - return success_response(data=human) - - except HTTPException: - raise - except Exception as e: - logger.error(f"上传视频失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/{video_id}/download") -async def download_video(video_id: str): - """ - 下载视频文件 - - 支持三种查找位置: - 1. data/video/{video_id}.mp4 - 传统存储 - 2. data/uploads/{video_id}.ext - 上传文件 - 3. ~/Documents/Meijiaka-zj/projects/*/videos/{video_id}.mp4 - 项目生成的视频 - 文件名格式: scene_{shot_id}.mp4 - """ - try: - # 1. 首先查找传统存储位置 - video_path = VIDEO_STORAGE_DIR / f"{video_id}.mp4" - found = False - - if not video_path.exists(): - # 2. 尝试从上传目录查找 - for ext in [".mp4", ".mov", ".avi"]: - candidate = UPLOAD_DIR / f"{video_id}{ext}" - if candidate.exists(): - video_path = candidate - found = True - break - else: - found = True - - # 3. 如果还没找到,尝试在项目视频目录中查找 - # video_id 可能是 scene_{id} 格式 - if not found: - from app.services.kling_video_service import KlingVideoService - - # 遍历项目目录查找(递归查找) - base_dir = KlingVideoService.BASE_STORAGE_DIR - if base_dir.exists(): - for project_dir in base_dir.iterdir(): - if project_dir.is_dir(): - candidate = project_dir / "videos" / f"{video_id}.mp4" - if candidate.exists(): - video_path = candidate - found = True - break - - if not found or not video_path.exists(): - raise HTTPException(status_code=404, detail="视频文件不存在") - - return FileResponse(path=video_path, media_type="video/mp4", filename=f"{video_id}.mp4") - - except HTTPException: - raise - except Exception as e: - logger.error(f"下载视频失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/{video_id}/thumbnail") -async def get_video_thumbnail(video_id: str): - """ - 获取视频缩略图 - """ - try: - # 简化实现:返回占位图 - # 实际应该使用 FFmpeg 提取视频第一帧 - raise HTTPException(status_code=404, detail="缩略图功能暂未实现") - - except Exception as e: - logger.error(f"获取缩略图失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) diff --git a/python-api/app/core/token_manager_example.py b/python-api/app/core/token_manager_example.py deleted file mode 100644 index 7c95586..0000000 --- a/python-api/app/core/token_manager_example.py +++ /dev/null @@ -1,170 +0,0 @@ -""" -TokenManager 使用示例 - -展示如何在 Provider 中使用 TokenManager 来管理认证 Token。 -""" - -import asyncio -import logging - -from app.core.token_manager import ( - JWTTokenStrategy, - OAuth2TokenStrategy, - TokenManager, - get_jwt_token, -) - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -async def example_jwt(): - """JWT Token 示例(KlingAI 模式)""" - print("=" * 60) - print("JWT Token 示例 (KlingAI)") - print("=" * 60) - - # 方法1: 使用便捷函数(推荐简单场景) - try: - token_info = await get_jwt_token( - access_key="test_access_key", - secret_key="test_secret_key", - ) - print(f"Token: {token_info.token[:50]}...") - print(f"Expires in: {token_info.expires_in:.0f} seconds") - print(f"Is expired: {token_info.is_expired}") - except Exception as e: - print(f"JWT generation failed (expected in demo): {e}") - - # 方法2: 使用 TokenManager + Strategy(推荐 Provider 集成) - strategy = JWTTokenStrategy( - access_key="your_access_key", - secret_key="your_secret_key", - expires_in=1800, # 30分钟 - ) - - # 第一次获取会生成新 token - token1 = await TokenManager.get_instance().get_token(strategy) - print(f"\nFirst token: {token1.token[:30]}...") - - # 第二次获取会命中缓存(如果未过期) - token2 = await TokenManager.get_instance().get_token(strategy) - print(f"Second token: {token2.token[:30]}...") - print(f"Same token: {token1.token == token2.token}") - - # 查看缓存统计 - stats = TokenManager.get_instance().get_stats() - print(f"\nCache stats: {stats}") - - -async def example_oauth2(): - """OAuth2 Token 示例""" - print("\n" + "=" * 60) - print("OAuth2 Token 示例") - print("=" * 60) - - strategy = OAuth2TokenStrategy( - client_id="your_client_id", - client_secret="your_client_secret", - token_url="https://api.example.com/oauth2/token", - scope="read write", - ) - - print("OAuth2 strategy created") - print(f"Cache key: {strategy.get_cache_key()}") - - -async def example_provider_integration(): - """Provider 集成示例""" - print("\n" + "=" * 60) - print("Provider 集成示例") - print("=" * 60) - - # 这是一个模拟的 Provider 类 - class ExampleProvider: - def __init__(self, access_key: str, secret_key: str): - self.access_key = access_key - self.secret_key = secret_key - self._token_strategy = JWTTokenStrategy( - access_key=access_key, - secret_key=secret_key, - expires_in=1800, - ) - - async def _get_headers(self) -> dict[str, str]: - """获取带认证的请求头""" - token_info = await TokenManager.get_instance().get_token(self._token_strategy) - return { - "Authorization": f"Bearer {token_info.token}", - "Content-Type": "application/json", - } - - async def make_request(self): - """模拟 API 请求""" - headers = await self._get_headers() - print(f"Request headers: {headers}") - # 实际使用时: await session.post(url, headers=headers, ...) - - provider = ExampleProvider("access_key_123", "secret_key_456") - await provider.make_request() - - -async def example_concurrent_requests(): - """并发请求示例 - 测试 token 刷新时的并发安全""" - print("\n" + "=" * 60) - print("并发请求示例") - print("=" * 60) - - strategy = JWTTokenStrategy( - access_key="concurrent_test_key", - secret_key="concurrent_test_secret", - expires_in=1800, - ) - - async def request_task(task_id: int): - """模拟单个请求""" - token_info = await TokenManager.get_instance().get_token(strategy) - print(f"Task {task_id}: got token (expires in {token_info.expires_in:.0f}s)") - return token_info - - # 并发10个请求,应该只触发一次 token 生成 - print("Launching 10 concurrent requests...") - results = await asyncio.gather(*[request_task(i) for i in range(10)]) - - # 验证所有请求拿到的是同一个 token - tokens = [r.token for r in results] - unique_tokens = set(tokens) - print(f"\nTotal requests: {len(tokens)}") - print(f"Unique tokens generated: {len(unique_tokens)}") - print(f"Concurrent safety: {'✓ PASS' if len(unique_tokens) == 1 else '✗ FAIL'}") - - -async def example_stats(): - """查看 TokenManager 统计信息""" - print("\n" + "=" * 60) - print("TokenManager 统计") - print("=" * 60) - - manager = TokenManager.get_instance() - stats = manager.get_stats() - - print(f"Total cached tokens: {stats['total_cached']}") - print(f"Active background tasks: {stats['active_tasks']}") - print(f"Token details: {stats['tokens']}") - - -async def main(): - """运行所有示例""" - await example_jwt() - await example_oauth2() - await example_provider_integration() - await example_concurrent_requests() - await example_stats() - - print("\n" + "=" * 60) - print("所有示例完成") - print("=" * 60) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python-api/app/crud/avatar.py b/python-api/app/crud/avatar.py deleted file mode 100644 index 7cdd769..0000000 --- a/python-api/app/crud/avatar.py +++ /dev/null @@ -1,104 +0,0 @@ -""" -Avatar CRUD 操作 -================ - -形象克隆记录的数据访问层。 -""" - -from datetime import UTC, datetime, timedelta - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.crud.base import CRUDBase -from app.models.avatar import Avatar -from app.schemas.avatar import AvatarCreate, AvatarUpdate - - -class CRUDAvatar(CRUDBase[Avatar, AvatarCreate, AvatarUpdate]): - """Avatar 数据访问对象""" - - def __init__(self) -> None: - super().__init__(Avatar) - - async def get_multi_by_user( - self, db: AsyncSession, *, user_id: str, skip: int = 0, limit: int = 100 - ) -> list[Avatar]: - """获取用户的形象列表(排除已软删除)""" - result = await db.execute( - select(Avatar) - .where(Avatar.user_id == user_id) - .where(Avatar.deleted_at.is_(None)) - .offset(skip) - .limit(limit) - .order_by(Avatar.created_at.desc()) - ) - return list(result.scalars().all()) - - async def soft_delete(self, db: AsyncSession, *, id: str, commit: bool = True) -> Avatar | None: - """软删除形象记录""" - obj = await self.get(db, id) - if obj: - obj.deleted_at = datetime.now(UTC) - if commit: - await db.commit() - await db.refresh(obj) - else: - await db.flush() - return obj - - async def get_stuck_tasks( - self, - db: AsyncSession, - processing_statuses: list[str], - timeout_minutes: int = 30, - limit: int = 100, - ) -> list[Avatar]: - """获取卡住的任务(超过指定时间未更新的处理中任务) - - Args: - db: 数据库会话 - processing_statuses: 需要检查的处理中状态列表 - timeout_minutes: 超时时间(分钟) - limit: 最大返回数量 - """ - timeout_threshold = datetime.now(UTC) - timedelta(minutes=timeout_minutes) - - result = await db.execute( - select(Avatar) - .where(Avatar.status.in_(processing_statuses)) - .where(Avatar.deleted_at.is_(None)) - .where(Avatar.updated_at < timeout_threshold) - .limit(limit) - .order_by(Avatar.updated_at.asc()) - ) - return list(result.scalars().all()) - - async def get_by_status_in( - self, - db: AsyncSession, - statuses: list[str], - updated_before: datetime | None = None, - limit: int = 100, - ) -> list[Avatar]: - """根据状态列表查询任务 - - Args: - db: 数据库会话 - statuses: 状态列表 - updated_before: 更新时间早于该时间的记录 - limit: 最大返回数量 - """ - query = select(Avatar).where(Avatar.status.in_(statuses)).where(Avatar.deleted_at.is_(None)) - - if updated_before: - query = query.where(Avatar.updated_at < updated_before) - - query = query.limit(limit).order_by(Avatar.updated_at.asc()) - - result = await db.execute(query) - return list(result.scalars().all()) - - -# 全局单例 -avatar = CRUDAvatar() diff --git a/python-api/docker-compose.dev.yml b/python-api/docker-compose.dev.yml deleted file mode 100644 index e6000bc..0000000 --- a/python-api/docker-compose.dev.yml +++ /dev/null @@ -1,105 +0,0 @@ -# 美家卡智剪 - 开发服务器配置 -# 自包含:PostgreSQL + Redis + API + Scheduler -# usage: docker compose -f docker-compose.dev.yml up -d --build - -version: "3.8" - -services: - db: - image: postgres:15-alpine - container_name: meijiaka-dev-db - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: meijiaka_dev - volumes: - - postgres_dev_data:/var/lib/postgresql/data - ports: - - "127.0.0.1:5432:5432" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 5s - timeout: 5s - retries: 5 - networks: - - dev-network - - redis: - image: redis:7-alpine - container_name: meijiaka-dev-redis - volumes: - - redis_dev_data:/data - ports: - - "127.0.0.1:6379:6379" - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 5s - timeout: 5s - retries: 5 - networks: - - dev-network - - api: - build: - context: . - dockerfile: Dockerfile - container_name: meijiaka-dev-api - environment: - - ENV=development - - DEBUG=true - - DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/meijiaka_dev - - REDIS_HOST=redis - - REDIS_PORT=6379 - - REDIS_DB=0 - - SECRET_KEY=dev-secret-key-do-not-use-in-prod - - MINIMAX_API_KEY=${MINIMAX_API_KEY} - - MINIMAX_BASE_URL=${MINIMAX_BASE_URL:-https://api.minimaxi.com} - - VIDU_API_KEY=${VIDU_API_KEY} - - VIDU_BASE_URL=${VIDU_BASE_URL:-https://api.vidu.cn} - - LOG_LEVEL=DEBUG - volumes: - - .:/app - - ../data:/root/Documents/Meijiaka-zj - ports: - - "8080:8000" - command: gunicorn app.main:app -w 1 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --reload - depends_on: - db: - condition: service_healthy - redis: - condition: service_healthy - networks: - - dev-network - - scheduler: - build: - context: . - dockerfile: Dockerfile - container_name: meijiaka-dev-scheduler - environment: - - ENV=development - - DEBUG=true - - DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/meijiaka_dev - - REDIS_HOST=redis - - REDIS_PORT=6379 - - REDIS_DB=0 - - SECRET_KEY=dev-secret-key-do-not-use-in-prod - volumes: - - .:/app - - ../data:/root/Documents/Meijiaka-zj - command: python -m app.scheduler.main - depends_on: - db: - condition: service_healthy - redis: - condition: service_healthy - networks: - - dev-network - -volumes: - postgres_dev_data: - redis_dev_data: - -networks: - dev-network: - driver: bridge diff --git a/tauri-app/src/pages/VideoCreation/AudioMixing.css b/tauri-app/src/pages/VideoCreation/AudioMixing.css deleted file mode 100644 index fc2540f..0000000 --- a/tauri-app/src/pages/VideoCreation/AudioMixing.css +++ /dev/null @@ -1,173 +0,0 @@ -/** - * 音频合成页面样式 - */ - -.audio-mixing { - height: 100%; - display: flex; - flex-direction: column; - gap: var(--spacing-lg); -} - -.audio-mixing-header { - padding: var(--spacing-lg); - border-bottom: 1px solid var(--border-light); -} - -.audio-mixing-header h2 { - margin: 0 0 var(--spacing-xs); - font-size: var(--font-xl); - font-weight: 600; -} - -.audio-mixing-desc { - margin: 0; - color: var(--text-secondary); - font-size: var(--font-sm); -} - -.audio-mixing-content { - flex: 1; - display: flex; - gap: var(--spacing-lg); - padding: 0 var(--spacing-lg); - overflow: hidden; -} - -/* 左侧分镜列表 */ -.audio-mixing-sidebar { - width: 240px; - flex-shrink: 0; - display: flex; - flex-direction: column; - gap: var(--spacing-md); -} - -.audio-mixing-sidebar h3 { - font-size: var(--font-base); - font-weight: 600; - margin: 0; -} - -.segment-list { - flex: 1; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: var(--spacing-xs); -} - -.segment-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: var(--spacing-md); - border-radius: var(--radius-md); - background: var(--bg-secondary); - cursor: pointer; - transition: all var(--transition-fast); -} - -.segment-item:hover { - background: var(--bg-hover); -} - -.segment-item.active { - background: var(--primary-light); - color: var(--primary); -} - -.segment-index { - font-weight: 500; -} - -.segment-duration { - font-size: var(--font-sm); - color: var(--text-tertiary); -} - -/* 右侧音频设置面板 */ -.audio-mixing-panel { - flex: 1; - background: var(--card-bg); - border-radius: var(--radius-lg); - padding: var(--spacing-xl); - overflow-y: auto; -} - -.audio-mixing-panel h3 { - margin: 0 0 var(--spacing-xl); - font-size: var(--font-lg); - font-weight: 600; -} - -.audio-control { - margin-bottom: var(--spacing-xl); -} - -.audio-control label { - display: block; - font-size: var(--font-sm); - font-weight: 500; - color: var(--text-secondary); - margin-bottom: var(--spacing-sm); -} - -.slider-container { - display: flex; - align-items: center; - gap: var(--spacing-md); -} - -.slider-container input[type="range"] { - flex: 1; - height: 6px; - border-radius: 3px; - background: var(--border-light); - -webkit-appearance: none; - appearance: none; -} - -.slider-container input[type="range"]::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 18px; - height: 18px; - border-radius: 50%; - background: var(--primary); - cursor: pointer; -} - -.slider-value { - min-width: 50px; - text-align: right; - font-size: var(--font-sm); - font-weight: 500; - color: var(--text-primary); -} - -.audio-info { - margin-top: var(--spacing-xl); - padding: var(--spacing-md); - background: var(--bg-secondary); - border-radius: var(--radius-md); -} - -.audio-info p { - margin: 0 0 var(--spacing-xs); - font-size: var(--font-sm); - color: var(--text-tertiary); -} - -.audio-info p:last-child { - margin-bottom: 0; -} - -/* 空状态 */ -.empty-state { - display: flex; - align-items: center; - justify-content: center; - height: 200px; - color: var(--text-tertiary); -} diff --git a/tauri-app/src/pages/VideoCreation/AudioMixing.tsx b/tauri-app/src/pages/VideoCreation/AudioMixing.tsx deleted file mode 100644 index 374e06a..0000000 --- a/tauri-app/src/pages/VideoCreation/AudioMixing.tsx +++ /dev/null @@ -1,115 +0,0 @@ -/** - * 音频合成页面 (Step 2) - * ==================== - * - * 为每个分镜配置背景音乐和音量 - */ - -import { useState } from 'react'; -import { useProjectStore } from '../../store'; -import { toast } from '../../store/uiStore'; -import './AudioMixing.css'; - -export default function AudioMixing() { - const segments = useProjectStore(state => state.segments); - const updateSegment = useProjectStore(state => state.updateSegment); - const [selectedSegmentId, setSelectedSegmentId] = useState( - segments.length > 0 ? segments[0].id : null - ); - - const selectedSegment = segments.find(s => s.id === selectedSegmentId); - - const handleVolumeChange = (e: React.ChangeEvent) => { - if (!selectedSegmentId) return; - const voiceVolume = parseFloat(e.target.value); - updateSegment(selectedSegmentId, { voiceVolume }); - toast.success('音量已更新'); - }; - - const handleBgmVolumeChange = (e: React.ChangeEvent) => { - if (!selectedSegmentId) return; - const bgmVolume = parseFloat(e.target.value); - updateSegment(selectedSegmentId, { bgmVolume }); - toast.success('背景音乐音量已更新'); - }; - - return ( -
-
-

音频合成

-

为每个分镜配置背景音乐和音量

-
- -
- {/* 左侧:分镜列表 */} -
-

分镜列表

-
- {segments.map((segment, index) => ( -
setSelectedSegmentId(segment.id)} - > - 镜头 {index + 1} - {segment.duration || 0}s -
- ))} -
-
- - {/* 右侧:音频设置 */} -
- {selectedSegment ? ( - <> -

分镜 {segments.findIndex(s => s.id === selectedSegmentId) + 1} 音频设置

- -
- -
- - - {Math.round((selectedSegment.voiceVolume ?? 1) * 100)}% - -
-
- -
- -
- - - {Math.round((selectedSegment.bgmVolume ?? 0.3) * 100)}% - -
-
- -
-

人声将使用 Kling AI TTS 生成的音频

-

背景音乐可在视频生成后添加

-
- - ) : ( -
-

请先在第一步生成脚本

-
- )} -
-
-
- ); -} diff --git a/tauri-app/src/pages/VideoCreation/VideoEditing.css b/tauri-app/src/pages/VideoCreation/VideoEditing.css deleted file mode 100644 index 6faf219..0000000 --- a/tauri-app/src/pages/VideoCreation/VideoEditing.css +++ /dev/null @@ -1,256 +0,0 @@ -/** - * VideoEditing 样式 - * ================== - */ - -.video-editing { - width: 100%; -} - -.editing-layout { - display: grid; - grid-template-columns: 280px 1fr; - gap: var(--spacing-lg); - margin-top: var(--spacing-md); -} - -.editing-sidebar { - display: flex; - flex-direction: column; - gap: var(--spacing-sm); -} - -.sidebar-title { - font-size: 13px; - font-weight: 600; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.05em; - padding: 0 var(--spacing-xs); -} - -.segment-list { - display: flex; - flex-direction: column; - gap: var(--spacing-xs); -} - -.segment-clip-item { - background: var(--bg-card); - border: 1px solid var(--border-light); - border-radius: var(--radius-md); - padding: var(--spacing-sm) var(--spacing-md); - cursor: pointer; - transition: all 0.15s ease; - position: relative; - overflow: hidden; -} - -.segment-clip-item:hover { - border-color: var(--primary-light); - background: var(--bg-hover); -} - -.segment-clip-item.active { - border-color: var(--primary); - background: color-mix(in srgb, var(--primary) 5%, var(--bg-card)); -} - -.segment-clip-item.processing { - opacity: 0.7; -} - -.segment-info { - display: flex; - justify-content: space-between; - align-items: center; -} - -.segment-index { - font-size: 13px; - font-weight: 600; - color: var(--text-primary); -} - -.segment-duration { - font-size: 12px; - color: var(--text-secondary); - background: var(--bg-light); - padding: 2px 6px; - border-radius: var(--radius-sm); -} - -.segment-meta { - margin-top: 4px; -} - -.audio-badge { - display: inline-flex; - align-items: center; - gap: 3px; - font-size: 11px; - padding: 2px 6px; - border-radius: var(--radius-sm); -} - -.audio-badge.has-audio { - background: color-mix(in srgb, var(--success) 15%, transparent); - color: var(--success); -} - -.audio-badge.no-audio { - background: var(--bg-light); - color: var(--text-secondary); -} - -.processing-overlay { - position: absolute; - inset: 0; - background: rgba(255, 255, 255, 0.6); - display: flex; - align-items: center; - justify-content: center; -} - -.mini-spinner { - width: 16px; - height: 16px; - border: 2px solid var(--border-light); - border-top-color: var(--primary); - border-radius: 50%; - animation: spin 0.6s linear infinite; -} - -.editing-panel { - display: flex; - flex-direction: column; - gap: var(--spacing-md); -} - -.video-preview-area { - background: var(--bg-card); - border-radius: var(--radius-lg); - overflow: hidden; - aspect-ratio: 9/16; - max-height: 400px; - display: flex; - align-items: center; - justify-content: center; - border: 1px solid var(--border-light); -} - -.preview-video { - width: 100%; - height: 100%; - object-fit: contain; -} - -.preview-placeholder { - color: var(--text-secondary); - font-size: 14px; -} - -.clip-controls { - background: var(--bg-card); - border-radius: var(--radius-lg); - padding: var(--spacing-md); - border: 1px solid var(--border-light); - display: flex; - flex-direction: column; - gap: var(--spacing-md); -} - -.control-section h4 { - font-size: 13px; - font-weight: 600; - color: var(--text-primary); - margin-bottom: var(--spacing-sm); -} - -.trim-controls { - display: flex; - align-items: center; - gap: var(--spacing-md); - flex-wrap: wrap; -} - -.trim-controls label { - display: flex; - align-items: center; - gap: 6px; - font-size: 13px; - color: var(--text-secondary); -} - -.trim-input { - width: 60px; - padding: 4px 8px; - border: 1px solid var(--border-light); - border-radius: var(--radius-sm); - font-size: 13px; - text-align: center; -} - -.trim-duration { - font-size: 13px; - color: var(--primary); - font-weight: 600; - background: color-mix(in srgb, var(--primary) 8%, transparent); - padding: 4px 10px; - border-radius: var(--radius-sm); -} - -.checkbox-label { - display: flex; - align-items: center; - gap: 8px; - font-size: 13px; - cursor: pointer; - color: var(--text-primary); -} - -.audio-replace-info { - display: flex; - align-items: center; - gap: 6px; - font-size: 12px; - color: var(--success); - margin-top: 6px; -} - -.audio-replace-hint { - font-size: 12px; - color: var(--text-secondary); - margin-top: 6px; - font-style: italic; -} - -.segment-voiceover { - background: var(--bg-card); - border-radius: var(--radius-lg); - padding: var(--spacing-md); - border: 1px solid var(--border-light); -} - -.segment-voiceover h4 { - font-size: 13px; - font-weight: 600; - color: var(--text-primary); - margin-bottom: var(--spacing-sm); -} - -.voiceover-text { - font-size: 13px; - color: var(--text-secondary); - line-height: 1.6; -} - -.no-segment { - text-align: center; - padding: var(--spacing-xl); - color: var(--text-secondary); -} - -@keyframes spin { - to { transform: rotate(360deg); } -} diff --git a/tauri-app/src/pages/VideoCreation/VideoEditing.tsx b/tauri-app/src/pages/VideoCreation/VideoEditing.tsx deleted file mode 100644 index 450d1e7..0000000 --- a/tauri-app/src/pages/VideoCreation/VideoEditing.tsx +++ /dev/null @@ -1,281 +0,0 @@ -/** - * 视频编辑页面 (Step 3.5) - * ====================== - * - * 手动剪辑:裁剪视频时长、调整播放速度、替换/静音原音。 - * 位于字幕压制之后、封面制作之前。 - */ - -import { useState, useCallback, useRef } from 'react'; -import { useProjectStore } from '../../store'; -import { useVoiceStore } from '../../store/voiceStore'; -import { getCurrentProjectId } from '../../api/modules/localStorage'; -import { replaceAudioTrack } from '../../api/modules/voice'; -import { toast } from '../../store/uiStore'; -import './VideoEditing.css'; - -export default function VideoEditing() { - const segments = useProjectStore(state => state.segments); - const updateSegment = useProjectStore(state => state.updateSegment); - const projectId = getCurrentProjectId(); - - const { getAudioForSegment } = useVoiceStore(); - - const [activeId, setActiveId] = useState(segments[0]?.id?.toString() || ''); - const [isProcessing, setIsProcessing] = useState(false); - const [processingId, setProcessingId] = useState(null); - - // 当前分镜 - const activeSegment = segments.find(s => s.id?.toString() === activeId); - const audioMeta = activeSegment ? getAudioForSegment(activeSegment.id?.toString() || '') : undefined; - - // 裁剪状态 - const [trimStart, setTrimStart] = useState(0); - const [trimEnd, setTrimEnd] = useState( - activeSegment?.duration ? parseFloat(activeSegment.duration.replace(/[^0-9.]/g, '') || '0') : 0 - ); - const [muteOriginal, setMuteOriginal] = useState(false); - const videoRef = useRef(null); - - // 分镜切换时重置状态 - const handleSegmentSelect = (id: string) => { - setActiveId(id); - const seg = segments.find(s => s.id?.toString() === id); - if (seg) { - const dur = parseFloat(seg.duration?.replace(/[^0-9.]/g, '') || '0'); - setTrimStart(0); - setTrimEnd(dur); - setMuteOriginal(false); - } - }; - - // 应用裁剪并静音/替换音频 - const handleApplyClip = useCallback(async () => { - if (!activeSegment || !activeId || !projectId) return; - - setIsProcessing(true); - setProcessingId(activeId); - - try { - const segId = activeId; - const clippedDuration = `${trimEnd - trimStart}s`; - - // 如果选择了音频替换 - if (audioMeta?.filePath && activeSegment.videoPath) { - // 音频替换:用 TTS 配音替换视频原音 - // 输出路径基于原视频,添加后缀标记 - const outputPath = activeSegment.videoPath.replace('.mp4', `_dubbed_${Date.now()}.mp4`); - - await replaceAudioTrack({ - videoPath: activeSegment.videoPath, - audioPath: audioMeta.filePath, - outputPath, - }); - - // 更新分镜数据(视频路径 + 时长) - updateSegment(activeSegment.id!, { - videoPath: outputPath, - duration: clippedDuration, - }); - - toast.success(`分镜 ${segId} 音频替换完成`); - } else if (muteOriginal && activeSegment.videoPath) { - // 静音标记:记录到分镜元数据,后续合成时处理 - updateSegment(activeSegment.id!, { - duration: clippedDuration, - // 静音频标记(需要在视频合成时处理) - }); - toast.info('已标记静音,将在最终合成时处理'); - } else { - // 仅更新时长 - updateSegment(activeSegment.id!, { - duration: clippedDuration, - }); - } - - toast.success('剪辑参数已保存'); - } catch (err) { - console.error('[VideoEditing] 处理失败:', err); - toast.error(`处理失败: ${err instanceof Error ? err.message : String(err)}`); - } finally { - setIsProcessing(false); - setProcessingId(null); - } - }, [activeSegment, activeId, projectId, audioMeta, muteOriginal, trimStart, trimEnd, updateSegment]); - - // 视频预览时间更新 - const handleTimeUpdate = () => { - if (videoRef.current) { - const current = videoRef.current.currentTime; - // 限制在裁剪范围内 - if (current < trimStart) { - videoRef.current.currentTime = trimStart; - } else if (current > trimEnd) { - videoRef.current.currentTime = trimEnd; - videoRef.current.pause(); - } - } - }; - - // 计算总剪辑后时长 - const totalClippedDuration = segments.reduce((sum, s) => { - const dur = parseFloat(s.duration?.replace(/[^0-9.]/g, '') || '0'); - return sum + dur; - }, 0); - - return ( -
-
-

视频剪辑

-

- 裁剪视频时长、静音原音、替换为配音。总时长:{totalClippedDuration}s -

-
- -
- {/* 左侧:分镜列表 */} -
-
分镜列表
-
- {segments.map((seg, i) => { - const segId = seg.id?.toString() || String(i); - const audio = getAudioForSegment(segId); - const dur = parseFloat(seg.duration?.replace(/[^0-9.]/g, '') || '0'); - const isActive = segId === activeId; - const isProcessingThis = processingId === segId; - - return ( -
handleSegmentSelect(segId)} - > -
- 镜头 {i + 1} - {dur}s -
-
- {audio ? ( - - - - - 配音 - - ) : ( - 无配音 - )} -
- {isProcessingThis && ( -
-
-
- )} -
- ); - })} -
-
- - {/* 右侧:编辑控制区 */} -
- {activeSegment ? ( - <> - {/* 视频预览 */} -
- {activeSegment.videoPath ? ( -
- - {/* 裁剪控制 */} -
-
-

裁剪范围

-
- - - - 时长:{Math.max(0, trimEnd - trimStart)}s - -
-
- - {/* 音频替换 */} -
-

音频设置

- - {audioMeta && ( -
- - - - 已选择配音:{audioMeta.name} -
- )} - {!audioMeta && ( -
- 请前往「配音管理」添加配音后再替换 -
- )} -
- - -
- - {/* 分镜旁白 */} -
-

旁白文本

-

{activeSegment.voiceover || '暂无旁白'}

-
- - ) : ( -
-

请从左侧选择一个分镜进行编辑

-
- )} -
-
-
- ); -}