chore: 清理仓库废弃代码和临时文件
删除文件: - 根目录: package.json, package-lock.json, .DS_Store - 后端未使用模块: token_manager_example.py, kling_dto.py, crud/avatar.py - 未注册路由: ai_models.py, klingai.py, qiniu.py, video.py - 废弃配置: docker-compose.dev.yml - 前端未使用页面: AudioMixing.tsx/css, VideoEditing.tsx/css - 所有 .DS_Store 临时文件 新增: .gitignore(忽略 .DS_Store, node_modules, __pycache__ 等) 清理后减少 ~2700+ 行无效代码
This commit is contained in:
+21
@@ -0,0 +1,21 @@
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
Vendored
BIN
Binary file not shown.
Generated
-6
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "meijiaka-zj",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -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")
|
||||
@@ -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)}")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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}")
|
||||
@@ -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))
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<number | null>(
|
||||
segments.length > 0 ? segments[0].id : null
|
||||
);
|
||||
|
||||
const selectedSegment = segments.find(s => s.id === selectedSegmentId);
|
||||
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!selectedSegmentId) return;
|
||||
const voiceVolume = parseFloat(e.target.value);
|
||||
updateSegment(selectedSegmentId, { voiceVolume });
|
||||
toast.success('音量已更新');
|
||||
};
|
||||
|
||||
const handleBgmVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!selectedSegmentId) return;
|
||||
const bgmVolume = parseFloat(e.target.value);
|
||||
updateSegment(selectedSegmentId, { bgmVolume });
|
||||
toast.success('背景音乐音量已更新');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="audio-mixing">
|
||||
<div className="audio-mixing-header">
|
||||
<h2>音频合成</h2>
|
||||
<p className="audio-mixing-desc">为每个分镜配置背景音乐和音量</p>
|
||||
</div>
|
||||
|
||||
<div className="audio-mixing-content">
|
||||
{/* 左侧:分镜列表 */}
|
||||
<div className="audio-mixing-sidebar">
|
||||
<h3>分镜列表</h3>
|
||||
<div className="segment-list">
|
||||
{segments.map((segment, index) => (
|
||||
<div
|
||||
key={segment.id}
|
||||
className={`segment-item ${segment.id === selectedSegmentId ? 'active' : ''}`}
|
||||
onClick={() => setSelectedSegmentId(segment.id)}
|
||||
>
|
||||
<span className="segment-index">镜头 {index + 1}</span>
|
||||
<span className="segment-duration">{segment.duration || 0}s</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:音频设置 */}
|
||||
<div className="audio-mixing-panel">
|
||||
{selectedSegment ? (
|
||||
<>
|
||||
<h3>分镜 {segments.findIndex(s => s.id === selectedSegmentId) + 1} 音频设置</h3>
|
||||
|
||||
<div className="audio-control">
|
||||
<label>人声音量</label>
|
||||
<div className="slider-container">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={selectedSegment.voiceVolume ?? 1}
|
||||
onChange={handleVolumeChange}
|
||||
/>
|
||||
<span className="slider-value">
|
||||
{Math.round((selectedSegment.voiceVolume ?? 1) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="audio-control">
|
||||
<label>背景音乐音量</label>
|
||||
<div className="slider-container">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={selectedSegment.bgmVolume ?? 0.3}
|
||||
onChange={handleBgmVolumeChange}
|
||||
/>
|
||||
<span className="slider-value">
|
||||
{Math.round((selectedSegment.bgmVolume ?? 0.3) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="audio-info">
|
||||
<p>人声将使用 Kling AI TTS 生成的音频</p>
|
||||
<p>背景音乐可在视频生成后添加</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<p>请先在第一步生成脚本</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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<string>(segments[0]?.id?.toString() || '');
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [processingId, setProcessingId] = useState<string | null>(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<HTMLVideoElement>(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 (
|
||||
<div className="video-editing">
|
||||
<div className="step-header">
|
||||
<h2>视频剪辑</h2>
|
||||
<p className="step-desc">
|
||||
裁剪视频时长、静音原音、替换为配音。总时长:{totalClippedDuration}s
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="editing-layout">
|
||||
{/* 左侧:分镜列表 */}
|
||||
<div className="editing-sidebar">
|
||||
<div className="sidebar-title">分镜列表</div>
|
||||
<div className="segment-list">
|
||||
{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 (
|
||||
<div
|
||||
key={segId}
|
||||
className={`segment-clip-item ${isActive ? 'active' : ''} ${isProcessingThis ? 'processing' : ''}`}
|
||||
onClick={() => handleSegmentSelect(segId)}
|
||||
>
|
||||
<div className="segment-info">
|
||||
<span className="segment-index">镜头 {i + 1}</span>
|
||||
<span className="segment-duration">{dur}s</span>
|
||||
</div>
|
||||
<div className="segment-meta">
|
||||
{audio ? (
|
||||
<span className="audio-badge has-audio">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
|
||||
</svg>
|
||||
配音
|
||||
</span>
|
||||
) : (
|
||||
<span className="audio-badge no-audio">无配音</span>
|
||||
)}
|
||||
</div>
|
||||
{isProcessingThis && (
|
||||
<div className="processing-overlay">
|
||||
<div className="mini-spinner" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:编辑控制区 */}
|
||||
<div className="editing-panel">
|
||||
{activeSegment ? (
|
||||
<>
|
||||
{/* 视频预览 */}
|
||||
<div className="video-preview-area">
|
||||
{activeSegment.videoPath ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={`file://${activeSegment.videoPath}`}
|
||||
controls
|
||||
className="preview-video"
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
/>
|
||||
) : (
|
||||
<div className="preview-placeholder">
|
||||
<span>暂无视频预览</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 裁剪控制 */}
|
||||
<div className="clip-controls">
|
||||
<div className="control-section">
|
||||
<h4>裁剪范围</h4>
|
||||
<div className="trim-controls">
|
||||
<label>
|
||||
起始 <input
|
||||
type="number"
|
||||
min={0}
|
||||
max={trimEnd - 1}
|
||||
value={trimStart}
|
||||
onChange={e => setTrimStart(Math.max(0, parseInt(e.target.value) || 0))}
|
||||
className="trim-input"
|
||||
/>s
|
||||
</label>
|
||||
<label>
|
||||
结束 <input
|
||||
type="number"
|
||||
min={trimStart + 1}
|
||||
value={trimEnd}
|
||||
onChange={e => setTrimEnd(parseInt(e.target.value) || trimEnd)}
|
||||
className="trim-input"
|
||||
/>s
|
||||
</label>
|
||||
<span className="trim-duration">
|
||||
时长:{Math.max(0, trimEnd - trimStart)}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 音频替换 */}
|
||||
<div className="control-section">
|
||||
<h4>音频设置</h4>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={muteOriginal}
|
||||
onChange={e => setMuteOriginal(e.target.checked)}
|
||||
/>
|
||||
静音原音
|
||||
</label>
|
||||
{audioMeta && (
|
||||
<div className="audio-replace-info">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
|
||||
</svg>
|
||||
已选择配音:{audioMeta.name}
|
||||
</div>
|
||||
)}
|
||||
{!audioMeta && (
|
||||
<div className="audio-replace-hint">
|
||||
请前往「配音管理」添加配音后再替换
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleApplyClip}
|
||||
disabled={isProcessing || !activeSegment.videoPath}
|
||||
>
|
||||
{isProcessing ? '处理中...' : '应用剪辑'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 分镜旁白 */}
|
||||
<div className="segment-voiceover">
|
||||
<h4>旁白文本</h4>
|
||||
<p className="voiceover-text">{activeSegment.voiceover || '暂无旁白'}</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="no-segment">
|
||||
<p>请从左侧选择一个分镜进行编辑</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user