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:
小鱼开发
2026-04-26 22:32:12 +08:00
parent 571324ef50
commit 69c2fe1c1c
17 changed files with 21 additions and 3704 deletions
Vendored
BIN
View File
Binary file not shown.
+21
View File
@@ -0,0 +1,21 @@
# macOS
.DS_Store
# Node
node_modules/
# Python
__pycache__/
*.pyc
.venv/
# IDE
.vscode/
.idea/
# Logs
*.log
# Environment
.env
.env.local
BIN
View File
Binary file not shown.
-6
View File
@@ -1,6 +0,0 @@
{
"name": "meijiaka-zj",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
-1
View File
@@ -1 +0,0 @@
{}
-45
View File
@@ -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")
-552
View File
@@ -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
-339
View File
@@ -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}")
-511
View File
@@ -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())
-104
View File
@@ -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()
-105
View File
@@ -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>
);
}